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
2 changes: 1 addition & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ app.whenReady().then(async () => {
initializeAppLifecycle(mb, contextMenu, protocol);

// Configure window event handlers (Escape key, DevTools resize)
configureWindowEvents(mb);
configureWindowEvents(mb, menuBuilder);

// Register IPC handlers for various channels
registerTrayHandlers(mb);
Expand Down
44 changes: 44 additions & 0 deletions src/main/lifecycle/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ vi.mock('../../shared/logger', () => ({
logWarn: (...a: unknown[]) => logWarnMock(...a),
}));

const isLinuxMock = vi.fn(() => false);
vi.mock('../../shared/platform', () => ({
isLinux: () => isLinuxMock(),
}));

function createMb() {
return {
on: vi.fn(),
Expand All @@ -38,6 +43,7 @@ function createMb() {
setIgnoreDoubleClickEvents: vi.fn(),
on: vi.fn(),
popUpContextMenu: vi.fn(),
setContextMenu: vi.fn(),
},
};
}
Expand All @@ -63,6 +69,44 @@ describe('main/lifecycle/startup.ts', () => {
expect(appQuitMock).toHaveBeenCalled();
expect(logWarnMock).toHaveBeenCalled();
});

it('uses setContextMenu on Linux', () => {
isLinuxMock.mockReturnValueOnce(true);
const mb = createMb();
const contextMenu = {} as Electron.Menu;

initializeAppLifecycle(mb as unknown as Menubar, contextMenu, 'gitify');

const readyHandler = (mb.on as unknown as ReturnType<typeof vi.fn>).mock
.calls[0]?.[1];
expect(readyHandler).toBeDefined();
readyHandler?.();

expect(mb.tray.setContextMenu).toHaveBeenCalledWith(contextMenu);
expect(mb.tray.on).not.toHaveBeenCalledWith(
'right-click',
expect.any(Function),
);
});

it('uses popUpContextMenu on non-Linux platforms', () => {
isLinuxMock.mockReturnValueOnce(false);
const mb = createMb();
const contextMenu = {} as Electron.Menu;

initializeAppLifecycle(mb as unknown as Menubar, contextMenu, 'gitify');

const readyHandler = (mb.on as unknown as ReturnType<typeof vi.fn>).mock
.calls[0]?.[1];
expect(readyHandler).toBeDefined();
readyHandler?.();

expect(mb.tray.setContextMenu).not.toHaveBeenCalled();
expect(mb.tray.on).toHaveBeenCalledWith(
'right-click',
expect.any(Function),
);
});
});

describe('handleProtocolURL', () => {
Expand Down
17 changes: 14 additions & 3 deletions src/main/lifecycle/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Menubar } from 'menubar';
import { APPLICATION } from '../../shared/constants';
import { EVENTS } from '../../shared/events';
import { logInfo, logWarn } from '../../shared/logger';
import { isLinux } from '../../shared/platform';

import { sendRendererEvent } from '../events';

Expand All @@ -27,9 +28,19 @@ export function initializeAppLifecycle(

mb.tray.setIgnoreDoubleClickEvents(true);

mb.tray.on('right-click', (_event, bounds) => {
mb.tray.popUpContextMenu(contextMenu, { x: bounds.x, y: bounds.y });
});
if (isLinux()) {
// Linux trays go through libappindicator / StatusNotifierItem (D-Bus),
// where Electron only emits 'right-click' if no context menu is set.
// setContextMenu hands the menu to the host indicator so it renders
// natively on right-click. Don't use this on macOS — there
// setContextMenu intercepts left-click too and would break the
// menubar window toggle.
mb.tray.setContextMenu(contextMenu);
} else {
mb.tray.on('right-click', (_event, bounds) => {
mb.tray.popUpContextMenu(contextMenu, { x: bounds.x, y: bounds.y });
});
}
});

preventSecondInstance(mb, protocol);
Expand Down
43 changes: 34 additions & 9 deletions src/main/lifecycle/window.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Menubar } from 'menubar';

import type MenuBuilder from '../menu';
import {
__resetWindowLifecycleForTests,
configureWindowEvents,
Expand Down Expand Up @@ -51,6 +52,7 @@ const flushDeferred = () => new Promise((resolve) => setImmediate(resolve));

describe('main/lifecycle/window.ts', () => {
let menubar: Menubar;
let menuBuilder: MenuBuilder;

beforeEach(() => {
appOnMock.mockClear();
Expand Down Expand Up @@ -80,6 +82,9 @@ describe('main/lifecycle/window.ts', () => {
move: vi.fn(),
},
} as unknown as Menubar;
menuBuilder = {
setWindowVisibility: vi.fn(),
} as unknown as MenuBuilder;
});

afterEach(() => {
Expand All @@ -90,12 +95,12 @@ describe('main/lifecycle/window.ts', () => {
const mbNoWindow = { ...menubar, window: null };

expect(() =>
configureWindowEvents(mbNoWindow as unknown as Menubar),
configureWindowEvents(mbNoWindow as unknown as Menubar, menuBuilder),
).not.toThrow();
});

it('configureWindowEvents registers webContents event listeners', () => {
configureWindowEvents(menubar);
configureWindowEvents(menubar, menuBuilder);

expect(menubar.window?.webContents.on).toHaveBeenCalledWith(
'before-input-event',
Expand All @@ -112,7 +117,7 @@ describe('main/lifecycle/window.ts', () => {
});

it('configureWindowEvents registers window close, before-quit and window-all-closed listeners', () => {
configureWindowEvents(menubar);
configureWindowEvents(menubar, menuBuilder);

expect(menubar.window?.on).toHaveBeenCalledWith(
'close',
Expand All @@ -125,9 +130,29 @@ describe('main/lifecycle/window.ts', () => {
);
});

describe('window visibility forwarding', () => {
it('forwards window show events to menu builder', () => {
configureWindowEvents(menubar, menuBuilder);

const showHandler = findWindowHandler(menubar, 'show');
showHandler?.({ preventDefault: vi.fn() });

expect(menuBuilder.setWindowVisibility).toHaveBeenCalledWith(true);
});

it('forwards window hide events to menu builder', () => {
configureWindowEvents(menubar, menuBuilder);

const hideHandler = findWindowHandler(menubar, 'hide');
hideHandler?.({ preventDefault: vi.fn() });

expect(menuBuilder.setWindowVisibility).toHaveBeenCalledWith(false);
});
});

describe('window close handler', () => {
it('hides the window and restores menubar reference on a WM close', async () => {
configureWindowEvents(menubar);
configureWindowEvents(menubar, menuBuilder);

const closeHandler = findWindowHandler(menubar, 'close');
const event = { preventDefault: vi.fn() };
Expand All @@ -149,7 +174,7 @@ describe('main/lifecycle/window.ts', () => {
});

it('skips the deferred hide when the captured window is destroyed', async () => {
configureWindowEvents(menubar);
configureWindowEvents(menubar, menuBuilder);

const captured = menubar.window;
const closeHandler = findWindowHandler(menubar, 'close');
Expand All @@ -163,7 +188,7 @@ describe('main/lifecycle/window.ts', () => {
});

it('lets the window close during quit', async () => {
configureWindowEvents(menubar);
configureWindowEvents(menubar, menuBuilder);

findAppHandler('before-quit')?.();

Expand All @@ -180,15 +205,15 @@ describe('main/lifecycle/window.ts', () => {

describe('window-all-closed handler', () => {
it('keeps the app alive when not quitting', () => {
configureWindowEvents(menubar);
configureWindowEvents(menubar, menuBuilder);

findAppHandler('window-all-closed')?.();

expect(appQuitMock).not.toHaveBeenCalled();
});

it('quits on linux when the user is quitting', () => {
configureWindowEvents(menubar);
configureWindowEvents(menubar, menuBuilder);

findAppHandler('before-quit')?.();
findAppHandler('window-all-closed')?.();
Expand All @@ -198,7 +223,7 @@ describe('main/lifecycle/window.ts', () => {

it('does not quit on macOS', () => {
setPlatform('darwin');
configureWindowEvents(menubar);
configureWindowEvents(menubar, menuBuilder);

findAppHandler('before-quit')?.();
findAppHandler('window-all-closed')?.();
Expand Down
16 changes: 15 additions & 1 deletion src/main/lifecycle/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { logWarn, toError } from '../../shared/logger';
import { isMacOS } from '../../shared/platform';

import { WindowConfig } from '../config';
import type MenuBuilder from '../menu';

let isQuitting = false;

Expand Down Expand Up @@ -44,13 +45,26 @@ function restoreMenubarWindowReference(mb: Menubar, win: BrowserWindow): void {
* Attach window-level event listeners for keyboard input and DevTools.
*
* @param mb - The menubar instance whose window events are configured.
* @param menuBuilder - The menu builder used to keep the Show / Hide tray
* menu items in sync with window visibility.
*/
export function configureWindowEvents(mb: Menubar): void {
export function configureWindowEvents(
mb: Menubar,
menuBuilder: MenuBuilder,
): void {
const win = mb.window;
if (!win) {
return;
}

win.on('show', () => {
menuBuilder.setWindowVisibility(true);
});

win.on('hide', () => {
menuBuilder.setWindowVisibility(false);
});

/**
* Track explicit quit requests so the close handlers can distinguish
* between an app quit and a WM-initiated window close.
Expand Down
91 changes: 89 additions & 2 deletions src/main/menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Menubar } from 'menubar';
import type { Mock } from 'vitest';

import { APPLICATION } from '../shared/constants';
import { isMacOS } from '../shared/platform';
import { isLinux, isMacOS } from '../shared/platform';

import { resetApp } from './lifecycle/reset';
import MenuBuilder from './menu';
Expand Down Expand Up @@ -54,6 +54,7 @@ vi.mock('./lifecycle/reset', () => ({
}));

vi.mock('../shared/platform', () => ({
isLinux: vi.fn(() => false),
isMacOS: vi.fn(),
}));

Expand Down Expand Up @@ -90,9 +91,18 @@ describe('main/menu.ts', () => {
};

beforeEach(() => {
vi.mocked(isLinux).mockReturnValue(false);
vi.mocked(isMacOS).mockReturnValue(false);
menuItemInstances.length = 0; // Clear tracked instances
menubar = { app: { quit: vi.fn() } } as unknown as Menubar;
menubar = {
app: { quit: vi.fn() },
showWindow: vi.fn(),
hideWindow: vi.fn(),
tray: {
isDestroyed: vi.fn(() => false),
setContextMenu: vi.fn(),
},
} as unknown as Menubar;
menuBuilder = new MenuBuilder(menubar);
});

Expand Down Expand Up @@ -197,6 +207,71 @@ describe('main/menu.ts', () => {
});
});

describe('windowVisibilityMenuItems', () => {
it('show item is visible by default; hide item is not', () => {
const showCfg = getMenuItemConfigByLabel(`Show ${APPLICATION.NAME}`);
const hideCfg = getMenuItemConfigByLabel(`Hide ${APPLICATION.NAME}`);

expect(showCfg?.visible).toBe(true);
expect(hideCfg?.visible).toBe(false);
});

it('setWindowVisibility(true) shows hide item, hides show item', () => {
menuBuilder.setWindowVisibility(true);

// biome-ignore lint/complexity/useLiteralKeys: This is a test
expect(menuBuilder['showWindowMenuItem'].visible).toBe(false);
// biome-ignore lint/complexity/useLiteralKeys: This is a test
expect(menuBuilder['hideWindowMenuItem'].visible).toBe(true);
});

it('setWindowVisibility(false) shows show item, hides hide item', () => {
menuBuilder.setWindowVisibility(true);
menuBuilder.setWindowVisibility(false);

// biome-ignore lint/complexity/useLiteralKeys: This is a test
expect(menuBuilder['showWindowMenuItem'].visible).toBe(true);
// biome-ignore lint/complexity/useLiteralKeys: This is a test
expect(menuBuilder['hideWindowMenuItem'].visible).toBe(false);
});

it('does not re-publish the menu on non-Linux platforms', () => {
menuBuilder.buildMenu();
menuBuilder.setWindowVisibility(true);

expect(menubar.tray.setContextMenu).not.toHaveBeenCalled();
});

it('re-publishes the menu over D-Bus on Linux', () => {
vi.mocked(isLinux).mockReturnValue(true);
const menu = {} as Electron.Menu;
(Menu.buildFromTemplate as Mock).mockReturnValueOnce(menu);
menuBuilder.buildMenu();

menuBuilder.setWindowVisibility(true);

expect(menubar.tray.setContextMenu).toHaveBeenCalledWith(menu);
});

it('skips re-publishing if buildMenu has not run yet', () => {
vi.mocked(isLinux).mockReturnValue(true);

menuBuilder.setWindowVisibility(true);

expect(menubar.tray.setContextMenu).not.toHaveBeenCalled();
});

it('skips re-publishing when the tray is destroyed', () => {
vi.mocked(isLinux).mockReturnValue(true);
menuBuilder.buildMenu();
(menubar.tray.isDestroyed as Mock).mockReturnValue(true);

menuBuilder.setWindowVisibility(true);

expect(menubar.tray.setContextMenu).not.toHaveBeenCalled();
});
});

describe('click handlers', () => {
it('invokes autoUpdater.checkForUpdatesAndNotify when clicking "Check for updates"', () => {
const cfg = getMenuItemConfigByLabel('Check for updates');
Expand Down Expand Up @@ -257,6 +332,18 @@ describe('main/menu.ts', () => {
expect(menubar.app.quit).toHaveBeenCalled();
});

it('show window menu item calls showWindow', () => {
const cfg = getMenuItemConfigByLabel(`Show ${APPLICATION.NAME}`);
cfg?.click?.();
expect(menubar.showWindow).toHaveBeenCalled();
});

it('hide window menu item calls hideWindow', () => {
const cfg = getMenuItemConfigByLabel(`Hide ${APPLICATION.NAME}`);
cfg?.click?.();
expect(menubar.hideWindow).toHaveBeenCalled();
});

it('developer submenu includes expected static accelerators', () => {
const template = buildAndGetTemplate();
const devEntry = template.find(
Expand Down
Loading
Loading