Fix Settings menu click being silently dropped after a slow cold rendere

Replace the 60s pendingOpenSettings TTL with an untimed intent, since a
cold renderer start can outrun any fixed timeout and cause the flag to
expire before the click is consumed. webContents-id scoping plus
consume-on-read still prevent the intent from leaking to an unrelated
renderer. Adds a test covering the queued-before-mount pull path.
This commit is contained in:
Jinjing
2026-07-16 12:19:08 -07:00
parent f137d963a9
commit 1cffa2c439
2 changed files with 128 additions and 3 deletions
+4 -3
View File
@@ -817,9 +817,10 @@ function openSettingsFromSystemMenu(): void {
// and leave a one-shot intent — a mounted renderer acts on the push, an
// unmounted one pulls the intent at mount; only one fires per renderer life.
targetWindow.webContents.send('ui:openSettings')
// Why: 60s outlives the default flag TTL, which a cold renderer start can
// exceed — an expired flag silently drops the Settings click.
pendingOpenSettings.mark(targetWindow.webContents.id, 60_000)
// Why: leave an untimed intent — any TTL can be outrun by a slow cold start and
// would silently drop the Settings click. webContents-id scoping plus consume-on-
// read still prevents the intent from leaking to a later, unrelated renderer.
pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY)
}
function quitFromSystemTray(): void {
+124
View File
@@ -1300,6 +1300,130 @@ describe('useIpcEvents updater integration', () => {
expect(removeSshCredentialRequest).toHaveBeenCalledWith('req-1')
})
it('opens Settings from a Settings intent queued before the listener attached', async () => {
const openSettingsPage = vi.fn()
let onOpenSettingsRegistered = false
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof ReactModule>('react')
return {
...actual,
useEffect: (effect: () => void | (() => void)) => {
effect()
}
}
})
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => ({
openSettingsPage,
fetchRepos: vi.fn(),
fetchWorktrees: vi.fn(),
setActiveView: vi.fn(),
activeModal: null,
closeModal: vi.fn(),
openModal: vi.fn(),
activeWorktreeId: 'wt-1',
activeView: 'terminal',
setActiveRepo: vi.fn(),
setActiveWorktree: vi.fn(),
revealWorktreeInSidebar: vi.fn(),
setIsFullScreen: vi.fn(),
setUpdateStatus: vi.fn(),
setRateLimitsFromPush: vi.fn(),
settings: { terminalFontSize: 13 }
})
}
}))
vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() }))
vi.doMock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn(),
ensureWorktreeHasInitialTerminal: vi.fn()
}))
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
getVisibleWorktreeIds: () => []
}))
vi.doMock('@/lib/editor-font-zoom', () => ({
nextEditorFontZoomLevel: vi.fn(() => 0),
computeEditorFontSize: vi.fn(() => 13)
}))
vi.doMock('@/components/settings/SettingsConstants', () => ({
zoomLevelToPercent: vi.fn(() => 100),
ZOOM_MIN: -3,
ZOOM_MAX: 3
}))
vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() }))
const makeEvents = (target: Record<string, unknown> = {}): Record<string, unknown> =>
new Proxy(target, {
get: (namespace, prop) =>
prop in namespace ? Reflect.get(namespace, prop) : () => () => {}
})
vi.stubGlobal('window', {
api: {
repos: makeEvents(),
worktrees: makeEvents(),
keybindings: makeEvents(),
settings: makeEvents(),
browser: makeEvents(),
updater: {
getStatus: () => Promise.resolve({ state: 'idle' }),
onStatus: () => () => {},
onClearDismissal: () => () => {}
},
rateLimits: {
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
onUpdate: () => () => {}
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
getTerminalDrivers: () => Promise.resolve([]),
getBrowserDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {},
onBrowserDriverChanged: () => () => {}
},
ssh: {
listTargets: () => Promise.resolve([]),
listPortForwards: () => Promise.resolve([]),
listDetectedPorts: () => Promise.resolve([]),
getState: () => Promise.resolve(null),
onStateChanged: () => () => {},
onCredentialRequest: () => () => {},
onCredentialResolved: () => () => {},
onPortForwardsChanged: () => () => {},
onDetectedPortsChanged: () => () => {}
},
agentStatus: { onSet: () => () => {} },
ui: makeEvents({
onOpenSettings: () => {
onOpenSettingsRegistered = true
return () => {}
},
// Why: exercise the positive branch — an intent queued before mount is
// pulled once the renderer's onOpenSettings listener is attached.
consumePendingOpenSettings: () => Promise.resolve(true),
getZoomLevel: () => 0,
set: vi.fn()
})
}
})
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
// Why: the pending-intent pull is a Promise; flush microtasks before asserting.
await Promise.resolve()
await Promise.resolve()
expect(onOpenSettingsRegistered).toBe(true)
expect(openSettingsPage).toHaveBeenCalledTimes(1)
})
it('clears stale remote PTYs when an SSH connection fully disconnects', async () => {
const clearTabPtyId = vi.fn()
const setSshConnectionState = vi.fn()