From a17ef6ef45bf3a8787b52501d486252cd4fc4e1e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Thu, 30 Jul 2026 06:43:18 +0800 Subject: [PATCH 1/2] feat(pty): close terminal tabs on process exit --- .../src/renderer/src/shell/desktop-shell.tsx | 43 ++++++++++++------- .../src/shell/use-desktop-shell-shortcuts.ts | 23 ++++++++++ .../__tests__/session-registry.test.ts | 18 ++++++++ .../workbench/src/terminal/attached-panel.tsx | 14 +++++- .../client/workbench/src/terminal/panel.tsx | 12 +++++- .../src/terminal/session-registry.ts | 21 +++++++++ 6 files changed, 112 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 5c40b7551..3d2edf883 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -322,6 +322,8 @@ export function DesktopShell({ useDesktopShellShortcuts({ navigation, owner: shellRootRef, + closeBottomTerminalTab: closeTab, + closeRightTerminalTab, togglePanel, updateSidebarOpen, }); @@ -526,17 +528,23 @@ export function DesktopShell({ const items = rightPanel.terminal.tabs.map((tab) => ({ id: tab.id, active: activeIsTerminal && tab.id === rightPanel.terminal.activeTabId, - node: tab.id.startsWith('attach:') ? ( - - ) : ( - + node: ( +
+ {tab.id.startsWith('attach:') ? ( + closeRightTerminalTab(tab.id)} + /> + ) : ( + closeRightTerminalTab(tab.id)} + /> + )} +
), })); // Browser webviews live here permanently: unmounting or DOM-moving a webview @@ -559,11 +567,14 @@ export function DesktopShell({ active: tab.id === bottomPanel.activeTabId, node: tab.type === 'terminal' ? ( - +
+ closeTab(tab.id)} + /> +
) : ( ), diff --git a/apps/desktop/src/renderer/src/shell/use-desktop-shell-shortcuts.ts b/apps/desktop/src/renderer/src/shell/use-desktop-shell-shortcuts.ts index e5494d8d1..ca4dfe965 100644 --- a/apps/desktop/src/renderer/src/shell/use-desktop-shell-shortcuts.ts +++ b/apps/desktop/src/renderer/src/shell/use-desktop-shell-shortcuts.ts @@ -3,6 +3,7 @@ import type { PanelSide } from '@linkcode/ui/shell/panels'; import type { WorkbenchShellNavigation } from '@linkcode/workbench'; const TOGGLE_SIDEBAR_SHORTCUT = { code: 'KeyB', modifiers: ['primary'] } as const; +const CLOSE_TERMINAL_TAB_SHORTCUT = { code: 'KeyW', modifiers: ['primary'] } as const; const TOGGLE_BOTTOM_PANEL_SHORTCUT = { code: 'KeyJ', modifiers: ['primary'] } as const; const TOGGLE_RIGHT_PANEL_SHORTCUT = { code: 'KeyB', @@ -20,6 +21,8 @@ const GO_FORWARD_SHORTCUT = { interface UseDesktopShellShortcutsOptions { navigation: WorkbenchShellNavigation; owner: React.RefObject; + closeBottomTerminalTab: (id: string) => void; + closeRightTerminalTab: (id: string) => void; togglePanel: (side: PanelSide) => void; updateSidebarOpen: (updater: boolean | ((current: boolean) => boolean)) => void; } @@ -27,9 +30,29 @@ interface UseDesktopShellShortcutsOptions { export function useDesktopShellShortcuts({ navigation, owner, + closeBottomTerminalTab, + closeRightTerminalTab, togglePanel, updateSidebarOpen, }: UseDesktopShellShortcutsOptions): void { + useKeyboardShortcut({ + actionId: 'desktop.close-terminal-tab', + shortcut: CLOSE_TERMINAL_TAB_SHORTCUT, + owner, + handler(event) { + if (!(event.target instanceof Element)) return false; + const terminal = event.target.closest( + '[data-terminal-panel][data-terminal-tab]', + ); + if (!terminal) return false; + const id = terminal.dataset.terminalTab; + if (id === undefined) return false; + if (terminal.dataset.terminalPanel === 'right') closeRightTerminalTab(id); + else closeBottomTerminalTab(id); + return true; + }, + }); + useKeyboardShortcut({ actionId: 'desktop.toggle-sidebar', shortcut: TOGGLE_SIDEBAR_SHORTCUT, diff --git a/packages/client/workbench/src/terminal/__tests__/session-registry.test.ts b/packages/client/workbench/src/terminal/__tests__/session-registry.test.ts index 021cf18f8..f6bd1d79b 100644 --- a/packages/client/workbench/src/terminal/__tests__/session-registry.test.ts +++ b/packages/client/workbench/src/terminal/__tests__/session-registry.test.ts @@ -202,6 +202,24 @@ describe('terminal session registry', () => { await vi.advanceTimersByTimeAsync(1000); }); + it('delivers an exit once across a panel handoff', async () => { + const client = createFakeClient(); + const firstOwner = vi.fn(); + const secondOwner = vi.fn(); + const first = acquireTerminalSession(client, 'tab-1', dims, firstOwner); + await vi.advanceTimersByTimeAsync(0); + + first.release(); + client.exitCbs.get('term-1')?.(0); + const second = acquireTerminalSession(client, 'tab-1', dims, secondOwner); + client.exitCbs.get('term-1')?.(0); + + expect(firstOwner).not.toHaveBeenCalled(); + expect(secondOwner).toHaveBeenCalledOnce(); + second.release(); + await vi.advanceTimersByTimeAsync(1000); + }); + it('preserves a signal exit as distinct from a running terminal', async () => { const client = createFakeClient(); const lease = acquireTerminalSession(client, 'tab-1', dims); diff --git a/packages/client/workbench/src/terminal/attached-panel.tsx b/packages/client/workbench/src/terminal/attached-panel.tsx index 2b24d3653..5d10749ff 100644 --- a/packages/client/workbench/src/terminal/attached-panel.tsx +++ b/packages/client/workbench/src/terminal/attached-panel.tsx @@ -3,7 +3,7 @@ import { useLinkCodeClient } from '@linkcode/client-core'; import { LiveTerminal } from '@linkcode/ui/shell/terminal'; import { Button } from 'coss-ui/components/button'; import { useEffect } from 'foxact/use-abortable-effect'; -import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'; +import { useCallback, useEffectEvent, useMemo, useState, useSyncExternalStore } from 'react'; import { useTranslations } from 'use-intl'; import { useTerminalPrefsStore } from '../settings/terminal-prefs-store'; import { createTransportTerminalSession } from './transport-session'; @@ -16,9 +16,12 @@ import { createTransportTerminalSession } from './transport-session'; export function AttachedTerminalPanel({ terminalId, suspended, + onExit, }: { terminalId: string; suspended?: boolean; + /** Called when the attached shell process exits. */ + onExit?: () => void; }): React.ReactNode { const t = useTranslations('workbench.panel'); const client = useLinkCodeClient(); @@ -55,6 +58,7 @@ export function AttachedTerminalPanel({ const fontFamily = useTerminalPrefsStore((state) => state.fontFamily); const fontSize = useTerminalPrefsStore((state) => state.fontSize); const colorScheme = useTerminalPrefsStore((state) => state.colorScheme); + const handleExit = useEffectEvent(() => onExit?.()); useEffect( (signal) => { @@ -76,6 +80,14 @@ export function AttachedTerminalPanel({ [client, terminalId], ); + useEffect( + (signal) => + client.subscribeTerminalExit(terminalId, () => { + if (!signal.aborted) handleExit(); + }), + [client, terminalId], + ); + const current = attachment?.terminalId === terminalId ? attachment : null; if (!current || 'failed' in current) { return ( diff --git a/packages/client/workbench/src/terminal/panel.tsx b/packages/client/workbench/src/terminal/panel.tsx index 8fdf5b7f6..f7c7563bc 100644 --- a/packages/client/workbench/src/terminal/panel.tsx +++ b/packages/client/workbench/src/terminal/panel.tsx @@ -20,12 +20,15 @@ export function TerminalPanel({ sessionKey, cwd, suspended, + onExit, }: { sessionKey: string; /** Working directory for the shell, captured when the terminal first opens (host home if omitted). */ cwd?: string; /** Freeze the terminal's box while the host panel animates shut/open — see {@link LiveTerminal}. */ suspended?: boolean; + /** Called once when the shell process exits. */ + onExit?: () => void; }): React.ReactNode { const t = useTranslations('workbench.panel'); const client = useLinkCodeClient(); @@ -37,7 +40,12 @@ export function TerminalPanel({ const subscribe = useCallback( (onStoreChange: () => void) => { - const lease = acquireTerminalSession(client, sessionKey, { ...TERMINAL_INITIAL_SIZE, cwd }); + const lease = acquireTerminalSession( + client, + sessionKey, + { ...TERMINAL_INITIAL_SIZE, cwd }, + onExit, + ); leaseRef.current = lease; const unsubscribe = lease.subscribe(onStoreChange); return () => { @@ -46,7 +54,7 @@ export function TerminalPanel({ if (leaseRef.current === lease) leaseRef.current = null; }; }, - [client, sessionKey, cwd], + [client, sessionKey, cwd, onExit], ); const snapshot = useSyncExternalStore(subscribe, () => peekTerminalSnapshot(client, sessionKey)); diff --git a/packages/client/workbench/src/terminal/session-registry.ts b/packages/client/workbench/src/terminal/session-registry.ts index 1983b5f62..63aca29bb 100644 --- a/packages/client/workbench/src/terminal/session-registry.ts +++ b/packages/client/workbench/src/terminal/session-registry.ts @@ -43,6 +43,8 @@ interface RegistryEntry { unsubController: (() => void) | null; snapshot: TerminalSnapshot; listeners: Set<() => void>; + exitListeners: Map void>; + exitDelivered: boolean; } /** @@ -79,6 +81,14 @@ function notify(entry: RegistryEntry): void { for (const listener of entry.listeners) listener(); } +function deliverExit(entry: RegistryEntry): void { + if (entry.exitDelivered || entry.snapshot.exit === null) return; + const onExit = Array.from(entry.exitListeners.values()).at(-1); + if (!onExit) return; + entry.exitDelivered = true; + onExit(); +} + function startOpen( client: TerminalSessionClient, registry: Map, @@ -87,6 +97,7 @@ function startOpen( opts: TerminalOpenOptions, ): void { entry.attempt += 1; + entry.exitDelivered = false; const attempt = entry.attempt; const isCurrent = (): boolean => registry.get(key) === entry && entry.attempt === attempt; @@ -136,6 +147,7 @@ function startOpen( exit: { code: exitCode }, canControl: false, }; + deliverExit(entry); notify(entry); }); notify(entry); @@ -177,6 +189,7 @@ export function acquireTerminalSession( client: TerminalSessionClient, key: string, opts: TerminalOpenOptions, + onExit?: () => void, ): TerminalSessionLease { const registry = getRegistry(client); let entry = registry.get(key); @@ -197,6 +210,8 @@ export function acquireTerminalSession( unsubController: null, snapshot: OPENING_SNAPSHOT, listeners: new Set(), + exitListeners: new Map(), + exitDelivered: false, }; registry.set(key, created); entry = created; @@ -205,6 +220,11 @@ export function acquireTerminalSession( const leased = entry; let released = false; + const exitListenerId = Symbol('terminal-exit-listener'); + if (onExit) { + leased.exitListeners.set(exitListenerId, onExit); + deliverExit(leased); + } return { getSnapshot: () => leased.snapshot, @@ -222,6 +242,7 @@ export function acquireTerminalSession( release() { if (released) return; released = true; + leased.exitListeners.delete(exitListenerId); leased.refCount -= 1; if (leased.refCount > 0) return; leased.closeTimer = setTimeout(() => { From 479bc6abd057e13775bed5f730b9b1118da0eb7e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Thu, 30 Jul 2026 17:35:56 +0800 Subject: [PATCH 2/2] fix(desktop): preserve Cmd+W window fallback --- apps/desktop/src/main/__tests__/menu.test.ts | 56 +++++++++++++++++++ apps/desktop/src/main/menu.ts | 6 +- .../src/renderer/src/shell/desktop-shell.tsx | 1 + .../src/shell/use-desktop-shell-shortcuts.ts | 24 +++++--- 4 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/menu.test.ts diff --git a/apps/desktop/src/main/__tests__/menu.test.ts b/apps/desktop/src/main/__tests__/menu.test.ts new file mode 100644 index 000000000..251755a2e --- /dev/null +++ b/apps/desktop/src/main/__tests__/menu.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + close: vi.fn(), + template: [] as Electron.MenuItemConstructorOptions[], +})); + +vi.mock('electron', () => ({ + app: { + commandLine: { + getSwitchValue: () => '', + hasSwitch: () => false, + }, + isPackaged: false, + }, + BrowserWindow: { + getAllWindows: () => [], + getFocusedWindow: () => ({ close: mocks.close }), + }, + dialog: { showErrorBox: vi.fn() }, + Menu: { + buildFromTemplate(template: Electron.MenuItemConstructorOptions[]) { + mocks.template = template; + return {}; + }, + }, +})); + +beforeEach(() => { + vi.resetModules(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + mocks.close.mockReset(); + mocks.template = []; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('desktop app menu', () => { + it('leaves Cmd+W to the renderer', async () => { + const { buildAppMenu } = await import('../menu'); + + buildAppMenu(); + + const fileMenu = mocks.template.find((item) => item.label === 'File'); + const closeItem = Array.isArray(fileMenu?.submenu) ? fileMenu.submenu[0] : undefined; + expect(closeItem).toMatchObject({ label: 'Close Window' }); + expect(closeItem).not.toHaveProperty('accelerator'); + + if (typeof closeItem === 'object' && 'click' in closeItem && closeItem.click) { + Reflect.apply(closeItem.click, undefined, []); + } + expect(mocks.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index 1e7691cea..85f9f6227 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -13,6 +13,10 @@ function openSettings(): void { win?.webContents.send(SETTINGS_OPEN_CHANNEL); } +function closeWindow(): void { + BrowserWindow.getFocusedWindow()?.close(); +} + export function buildAppMenu(): Menu { const isMac = process.platform === 'darwin'; const settingsItem: MenuItemConstructorOptions = { @@ -45,7 +49,7 @@ export function buildAppMenu(): Menu { { label: 'File', submenu: isMac - ? [{ role: 'close' }] + ? [{ label: 'Close Window', click: closeWindow }] : [settingsItem, { type: 'separator' }, { role: 'quit' }], }, { role: 'editMenu' }, diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 3d2edf883..2f55a15a6 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -324,6 +324,7 @@ export function DesktopShell({ owner: shellRootRef, closeBottomTerminalTab: closeTab, closeRightTerminalTab, + closeWindow: () => systemBridge.window.close(), togglePanel, updateSidebarOpen, }); diff --git a/apps/desktop/src/renderer/src/shell/use-desktop-shell-shortcuts.ts b/apps/desktop/src/renderer/src/shell/use-desktop-shell-shortcuts.ts index ca4dfe965..c7996f9d9 100644 --- a/apps/desktop/src/renderer/src/shell/use-desktop-shell-shortcuts.ts +++ b/apps/desktop/src/renderer/src/shell/use-desktop-shell-shortcuts.ts @@ -23,6 +23,7 @@ interface UseDesktopShellShortcutsOptions { owner: React.RefObject; closeBottomTerminalTab: (id: string) => void; closeRightTerminalTab: (id: string) => void; + closeWindow: () => unknown; togglePanel: (side: PanelSide) => void; updateSidebarOpen: (updater: boolean | ((current: boolean) => boolean)) => void; } @@ -32,6 +33,7 @@ export function useDesktopShellShortcuts({ owner, closeBottomTerminalTab, closeRightTerminalTab, + closeWindow, togglePanel, updateSidebarOpen, }: UseDesktopShellShortcutsOptions): void { @@ -40,15 +42,19 @@ export function useDesktopShellShortcuts({ shortcut: CLOSE_TERMINAL_TAB_SHORTCUT, owner, handler(event) { - if (!(event.target instanceof Element)) return false; - const terminal = event.target.closest( - '[data-terminal-panel][data-terminal-tab]', - ); - if (!terminal) return false; - const id = terminal.dataset.terminalTab; - if (id === undefined) return false; - if (terminal.dataset.terminalPanel === 'right') closeRightTerminalTab(id); - else closeBottomTerminalTab(id); + const terminal = + event.target instanceof Element + ? event.target.closest('[data-terminal-panel][data-terminal-tab]') + : null; + if (terminal) { + const id = terminal.dataset.terminalTab; + if (id !== undefined) { + if (terminal.dataset.terminalPanel === 'right') closeRightTerminalTab(id); + else closeBottomTerminalTab(id); + } + } else { + closeWindow(); + } return true; }, });