From 505fdc229b793c72a375f6d247f8601433534b15 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Fri, 8 May 2026 15:36:32 +0200 Subject: [PATCH 1/2] fix(tray): populate Linux right-click menu via setContextMenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux, trays go through libappindicator / StatusNotifierItem (D-Bus). Electron only emits 'right-click' on that path when no context menu is set, so the popUpContextMenu call inside our JS handler showed an empty menu (or nothing) depending on the desktop environment. Hand the menu to the host indicator instead so it renders natively on right-click. Keep the existing right-click + popUpContextMenu path on macOS / Windows — setContextMenu intercepts left-click on macOS and would break the menubar window toggle. Refs #1612, #2096. Co-Authored-By: Claude Opus 4.7 --- src/main/lifecycle/startup.test.ts | 44 ++++++++++++++++++++++++++++++ src/main/lifecycle/startup.ts | 17 ++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/main/lifecycle/startup.test.ts b/src/main/lifecycle/startup.test.ts index fbf7eff69..211e3bcb0 100644 --- a/src/main/lifecycle/startup.test.ts +++ b/src/main/lifecycle/startup.test.ts @@ -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(), @@ -38,6 +43,7 @@ function createMb() { setIgnoreDoubleClickEvents: vi.fn(), on: vi.fn(), popUpContextMenu: vi.fn(), + setContextMenu: vi.fn(), }, }; } @@ -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).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).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', () => { diff --git a/src/main/lifecycle/startup.ts b/src/main/lifecycle/startup.ts index 87b65d6cd..6296be019 100644 --- a/src/main/lifecycle/startup.ts +++ b/src/main/lifecycle/startup.ts @@ -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'; @@ -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); From d686264be9a84c66bbd6fd0286ee16ce50266120 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Fri, 8 May 2026 15:56:48 +0200 Subject: [PATCH 2/2] feat(tray): add Toggle Gitify entry to context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some Linux tray providers (e.g. KStatusNotifier-backed indicators on GNOME) require a double-click on the icon to trigger the primary action. A "Toggle Gitify" menu item gives users a guaranteed single-click path to show or hide the popup regardless of the provider's activation behavior. Static label keeps the menu cheap on Linux — no need to rebuild and re-call setContextMenu on visibility changes for a Show/Hide label flip to propagate over D-Bus. Co-Authored-By: Claude Opus 4.7 --- src/main/menu.test.ts | 72 +++++++++++++++++++++++++++++++++++++++++++ src/main/menu.ts | 11 +++++++ 2 files changed, 83 insertions(+) diff --git a/src/main/menu.test.ts b/src/main/menu.test.ts index 95a54e7b4..d27bec864 100644 --- a/src/main/menu.test.ts +++ b/src/main/menu.test.ts @@ -257,6 +257,78 @@ describe('main/menu.ts', () => { expect(menubar.app.quit).toHaveBeenCalled(); }); + it('toggle menu item shows the window when hidden', () => { + const showWindow = vi.fn(); + const hideWindow = vi.fn(); + const mb = { + app: { quit: vi.fn() }, + window: { isVisible: () => false }, + showWindow, + hideWindow, + } as unknown as Menubar; + const builder = new MenuBuilder(mb); + builder.buildMenu(); + const template = (Menu.buildFromTemplate as Mock).mock.calls.slice( + -1, + )[0][0] as TemplateItem[]; + + const item = template.find( + (i) => i.label === `Toggle ${APPLICATION.NAME}`, + ); + item?.click?.(); + + expect(showWindow).toHaveBeenCalled(); + expect(hideWindow).not.toHaveBeenCalled(); + }); + + it('toggle menu item hides the window when visible', () => { + const showWindow = vi.fn(); + const hideWindow = vi.fn(); + const mb = { + app: { quit: vi.fn() }, + window: { isVisible: () => true }, + showWindow, + hideWindow, + } as unknown as Menubar; + const builder = new MenuBuilder(mb); + builder.buildMenu(); + const template = (Menu.buildFromTemplate as Mock).mock.calls.slice( + -1, + )[0][0] as TemplateItem[]; + + const item = template.find( + (i) => i.label === `Toggle ${APPLICATION.NAME}`, + ); + item?.click?.(); + + expect(hideWindow).toHaveBeenCalled(); + expect(showWindow).not.toHaveBeenCalled(); + }); + + it('toggle menu item shows the window when no window exists yet', () => { + const showWindow = vi.fn(); + const hideWindow = vi.fn(); + const mb = { + app: { quit: vi.fn() }, + window: undefined, + showWindow, + hideWindow, + } as unknown as Menubar; + const builder = new MenuBuilder(mb); + builder.buildMenu(); + const template = (Menu.buildFromTemplate as Mock).mock.calls.slice( + -1, + )[0][0] as TemplateItem[]; + + const item = template.find( + (i) => i.label === `Toggle ${APPLICATION.NAME}`, + ); + item?.click?.(); + + expect(showWindow).toHaveBeenCalled(); + expect(hideWindow).not.toHaveBeenCalled(); + }); + it('developer submenu includes expected static accelerators', () => { const template = buildAndGetTemplate(); const devEntry = template.find( diff --git a/src/main/menu.ts b/src/main/menu.ts index 4aeca1cea..7f7084cd4 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -60,6 +60,17 @@ export default class MenuBuilder { */ buildMenu(): Menu { const contextMenu = Menu.buildFromTemplate([ + { + label: `Toggle ${APPLICATION.NAME}`, + click: () => { + if (this.menubar.window?.isVisible()) { + this.menubar.hideWindow(); + } else { + this.menubar.showWindow(); + } + }, + }, + { type: 'separator' }, this.checkForUpdatesMenuItem, this.noUpdateAvailableMenuItem, this.updateAvailableMenuItem,