Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/main/handlers/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import type { Menubar } from 'menubar';

import { EVENTS } from '../../shared/events';

import { applyKeepWindowOnBlur } from '../lifecycle/window';
import { registerSystemHandlers } from './system';

vi.mock('../lifecycle/window', () => ({
applyKeepWindowOnBlur: vi.fn(),
}));

const onMock = vi.fn();
const handleMock = vi.fn();

Expand Down Expand Up @@ -66,10 +71,24 @@ describe('main/handlers/system.ts', () => {

expect(onEvents).toContain(EVENTS.OPEN_EXTERNAL);
expect(onEvents).toContain(EVENTS.UPDATE_AUTO_LAUNCH);
expect(onEvents).toContain(EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR);
expect(handleEvents).toContain(EVENTS.UPDATE_KEYBOARD_SHORTCUT);
});
});

describe('UPDATE_KEEP_WINDOW_ON_BLUR', () => {
it('forwards the value to applyKeepWindowOnBlur', () => {
registerSystemHandlers(menubar);

const handler = onMock.mock.calls.find(
(call: unknown[]) => call[0] === EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR,
)?.[1];
handler?.({}, true);

expect(applyKeepWindowOnBlur).toHaveBeenCalledWith(menubar, true);
});
});

describe('UPDATE_KEYBOARD_SHORTCUT', () => {
it('registers shortcut when enabled', () => {
const handler = getKeyboardShortcutHandler();
Expand Down
8 changes: 8 additions & 0 deletions src/main/handlers/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Menubar } from 'menubar';
import { EVENTS } from '../../shared/events';

import { handleMainEvent, onMainEvent } from '../events';
import { applyKeepWindowOnBlur } from '../lifecycle/window';

/**
* Register IPC handlers for OS-level system operations.
Expand Down Expand Up @@ -69,4 +70,11 @@ export function registerSystemHandlers(mb: Menubar): void {
onMainEvent(EVENTS.UPDATE_AUTO_LAUNCH, (_, settings) => {
app.setLoginItemSettings(settings);
});

/**
* Toggle whether the window stays open when it loses focus.
*/
onMainEvent(EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR, (_, value: boolean) => {
applyKeepWindowOnBlur(mb, value);
});
}
48 changes: 47 additions & 1 deletion src/main/lifecycle/window.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { Menubar } from 'menubar';

import type MenuBuilder from '../menu';
import { __resetWindowLifecycleForTests, configureWindowEvents } from './window';
import {
__resetWindowLifecycleForTests,
configureWindowEvents,
applyKeepWindowOnBlur,
} from './window';

const appOnMock = vi.fn();
const appQuitMock = vi.fn();
Expand Down Expand Up @@ -43,6 +47,12 @@ const findWindowHandler = (
return call?.[1] as ((event: { preventDefault: () => void }) => void) | undefined;
};

const findWebContentsHandler = (menubar: Menubar, eventName: string): (() => void) | undefined => {
const onMock = menubar.window?.webContents.on as ReturnType<typeof vi.fn>;
const call = onMock.mock.calls.find(([name]) => name === eventName);
return call?.[1] as (() => void) | undefined;
};

const flushDeferred = () => new Promise((resolve) => setImmediate(resolve));

describe('main/lifecycle/window.ts', () => {
Expand Down Expand Up @@ -189,6 +199,42 @@ describe('main/lifecycle/window.ts', () => {
});
});

describe('applyKeepWindowOnBlur', () => {
it('forwards the value to the underlying window', () => {
applyKeepWindowOnBlur(menubar, true);

expect(menubar.window?.setAlwaysOnTop).toHaveBeenCalledWith(true);
});

it('skips the call when the window is destroyed', () => {
// oxlint-disable-next-line no-unsafe-optional-chaining -- window is guaranteed defined in this test
(menubar.window?.isDestroyed as ReturnType<typeof vi.fn>).mockReturnValue(true);

applyKeepWindowOnBlur(menubar, true);

expect(menubar.window?.setAlwaysOnTop).not.toHaveBeenCalled();
});

it('is restored after DevTools closes', () => {
configureWindowEvents(menubar, menuBuilder);
applyKeepWindowOnBlur(menubar, true);
// oxlint-disable-next-line no-unsafe-optional-chaining -- window is guaranteed defined in this test
(menubar.window?.setAlwaysOnTop as ReturnType<typeof vi.fn>).mockClear();

findWebContentsHandler(menubar, 'devtools-closed')?.();

expect(menubar.window?.setAlwaysOnTop).toHaveBeenCalledWith(true);
});

it('is cleared after DevTools closes when the user did not opt in', () => {
configureWindowEvents(menubar, menuBuilder);

findWebContentsHandler(menubar, 'devtools-closed')?.();

expect(menubar.window?.setAlwaysOnTop).toHaveBeenCalledWith(false);
});
});

describe('window-all-closed handler', () => {
it('keeps the app alive when not quitting', () => {
configureWindowEvents(menubar, menuBuilder);
Expand Down
22 changes: 22 additions & 0 deletions src/main/lifecycle/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { WindowConfig } from '../config';
import type MenuBuilder from '../menu';

let isQuitting = false;
let keepWindowOnBlur = false;

/**
* Reset module-level lifecycle flags. Module-level state is unavoidable
Expand All @@ -18,6 +19,22 @@ let isQuitting = false;
*/
export function __resetWindowLifecycleForTests(): void {
isQuitting = false;
keepWindowOnBlur = false;
}

/**
* Apply the user's "keep window open when it loses focus" preference.
*
* Implemented by toggling the window's `alwaysOnTop` flag, which the
* `menubar` library checks to short-circuit its blur-driven hide. The
* value is also remembered so the `devtools-closed` handler can restore
* it after DevTools temporarily forces it on.
*/
export function applyKeepWindowOnBlur(mb: Menubar, value: boolean): void {
keepWindowOnBlur = value;
if (mb.window && !mb.window.isDestroyed()) {
mb.window.setAlwaysOnTop(value);
}
}

/**
Expand Down Expand Up @@ -147,6 +164,10 @@ export function configureWindowEvents(mb: Menubar, menuBuilder: MenuBuilder): vo

/**
* When DevTools is closed, restore the window to its original size and position it centered on the tray icon.
*
* `devtools-opened` forces `alwaysOnTop` true for usability while
* debugging; restore it to the user's preference here so DevTools
* doesn't leave the flag stuck on.
*/
mb.window.webContents.on('devtools-closed', () => {
if (!mb.window) {
Expand All @@ -157,5 +178,6 @@ export function configureWindowEvents(mb: Menubar, menuBuilder: MenuBuilder): vo
mb.window.setSize(WindowConfig.width!, WindowConfig.height!);
mb.positioner.move('trayCenter', trayBounds);
mb.window.resizable = false;
mb.window.setAlwaysOnTop(keepWindowOnBlur);
});
}
10 changes: 10 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ export const api = {
openAsHidden: value,
}),

/**
* Enable or disable keeping the window open when it loses focus.
*
* Implemented by toggling the window's `alwaysOnTop` flag, which the
* `menubar` library uses to short-circuit its blur-driven hide.
*
* @param value - `true` to keep the window open on blur, `false` to hide.
*/
setKeepWindowOnBlur: (value: boolean) => sendMainEvent(EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR, value),

/**
* Apply the global keyboard shortcut for toggling the app window visibility.
*
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ window.gitify = {
onAuthCallback: vi.fn(),
onResetApp: vi.fn(),
setAutoLaunch: vi.fn(),
setKeepWindowOnBlur: vi.fn(),
applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }),
raiseNativeNotification: vi.fn(),
};
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const mockSystemSettings: SystemSettingsState = {
playSound: true,
notificationVolume: 20 as Percentage,
openAtStartup: false,
keepWindowOnBlur: false,
};

export const mockSettings: SettingsState = {
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/components/settings/SystemSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,4 +255,17 @@ describe('renderer/components/settings/SystemSettings.tsx', () => {
expect(updateSettingMock).toHaveBeenCalledTimes(1);
expect(updateSettingMock).toHaveBeenCalledWith('openAtStartup', true);
});

it('should toggle the keepWindowOnBlur checkbox', async () => {
await act(async () => {
renderWithProviders(<SystemSettings />, {
updateSetting: updateSettingMock,
});
});

await userEvent.click(screen.getByTestId('checkbox-keepWindowOnBlur'));

expect(updateSettingMock).toHaveBeenCalledTimes(1);
expect(updateSettingMock).toHaveBeenCalledWith('keepWindowOnBlur', true);
});
});
13 changes: 13 additions & 0 deletions src/renderer/components/settings/SystemSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,19 @@ export const SystemSettings: FC = () => {
</ButtonGroup>
</Stack>

<Checkbox
checked={settings.keepWindowOnBlur}
label="Keep window open when it loses focus"
name="keepWindowOnBlur"
onChange={() => updateSetting('keepWindowOnBlur', !settings.keepWindowOnBlur)}
tooltip={
<Text>
Prevent the {APPLICATION.NAME} window from automatically hiding when you click outside
it.
</Text>
}
/>

<Checkbox
checked={settings.openAtStartup}
label="Open at startup"
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/context/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
decryptValue,
encryptValue,
setAutoLaunch,
setKeepWindowOnBlur,
setUseAlternateIdleIcon,
setUseUnreadActiveIcon,
} from '../utils/system/comms';
Expand Down Expand Up @@ -370,6 +371,10 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
setAutoLaunch(settings.openAtStartup);
}, [settings.openAtStartup]);

useEffect(() => {
setKeepWindowOnBlur(settings.keepWindowOnBlur);
}, [settings.keepWindowOnBlur]);

useEffect(() => {
window.gitify.onResetApp(() => {
clearState();
Expand Down
1 change: 1 addition & 0 deletions src/renderer/context/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const defaultSystemSettings: SystemSettingsState = {
playSound: true,
notificationVolume: 20 as Percentage,
openAtStartup: false,
keepWindowOnBlur: false,
};

export const defaultSettings: SettingsState = {
Expand Down
61 changes: 55 additions & 6 deletions src/renderer/routes/__snapshots__/Settings.test.tsx.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/renderer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export interface SystemSettingsState {
playSound: boolean;
notificationVolume: Percentage;
openAtStartup: boolean;
keepWindowOnBlur: boolean;
}

export interface AuthState {
Expand Down
Loading
Loading