From 200e712874268e853c5543ddb3f1bf19b1f7bb7d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:21:36 -0700 Subject: [PATCH] fix(terminal): preserve floating park watchers --- src/renderer/src/components/Terminal.tsx | 7 +++- .../terminal-parked-tab-watchers.ts | 3 +- .../terminal-parked-watcher-registry.test.ts | 41 ++++++++++++++++++- .../terminal-parked-watcher-registry.ts | 6 +++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 94c16a0cb3e..561dd4e8c40 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -113,7 +113,8 @@ import { disposeAllParkedTerminalWatchers, pruneParkedTerminalWatchers, shouldDeferParkedPtyExitTabClose, - syncParkedTerminalTabWatchers + syncParkedTerminalTabWatchers, + terminalWatcherLiveWorkspaceIds } from './terminal-pane/terminal-parked-tab-watchers' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' @@ -1294,7 +1295,9 @@ function Terminal(): React.JSX.Element | null { ) // Why: legacy (non-split) host owns watcher reconciliation; split mode's overlay layers own theirs, so only dispose worktrees with no overlay layer. useEffect(() => { - pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id))) + pruneParkedTerminalWatchers( + terminalWatcherLiveWorkspaceIds(workspaceSurfaces.map((workspace) => workspace.id)) + ) for (const workspace of workspaceSurfaces) { if ( anyMountedWorktreeHasLayout && diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 04456bd9f5b..59d1b90db3e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -35,7 +35,8 @@ export { disposeParkedTerminalWatchersForPtyIds, disposeParkedTerminalWatchersForWorktree, getParkedTerminalWatcherTabIds, - pruneParkedTerminalWatchers + pruneParkedTerminalWatchers, + terminalWatcherLiveWorkspaceIds } from './terminal-parked-watcher-registry' export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' export { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts index 8af5ca19207..eb436c8e127 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts @@ -8,16 +8,24 @@ import { hasPreHandlerPtyExit } from './pty-pre-handler-buffer' import { + capturedPanesByTabId, parkedWatchersByTabId, - pruneParkedTerminalWatchers + pruneParkedTerminalWatchers, + terminalWatcherLiveWorkspaceIds } from './terminal-parked-watcher-registry' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' const TAB_ID = 'removed-parked-tab' const PTY_ID = 'removed-worktree@@parked-pty' +const FLOATING_TAB_ID = 'floating-parked-tab' +const FLOATING_PTY_ID = `${FLOATING_TERMINAL_WORKTREE_ID}@@parked-pty` describe('terminal parked watcher registry removal', () => { afterEach(() => { parkedWatchersByTabId.delete(TAB_ID) + parkedWatchersByTabId.delete(FLOATING_TAB_ID) + capturedPanesByTabId.delete(TAB_ID) + capturedPanesByTabId.delete(FLOATING_TAB_ID) clearPreHandlerPtyState(PTY_ID) }) @@ -51,4 +59,35 @@ describe('terminal parked watcher registry removal', () => { expect(exit).not.toHaveBeenCalled() expect(delayedData).not.toHaveBeenCalled() }) + + it('keeps the floating workspace while pruning a removed workspace', () => { + const removedDispose = vi.fn() + const floatingDispose = vi.fn() + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: 'removed-worktree', + tabPtyId: PTY_ID, + paneIdByPtyId: new Map([[PTY_ID, 1]]), + disposersByPtyId: new Map([[PTY_ID, removedDispose]]) + }) + parkedWatchersByTabId.set(FLOATING_TAB_ID, { + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + tabPtyId: FLOATING_PTY_ID, + paneIdByPtyId: new Map([[FLOATING_PTY_ID, 1]]), + disposersByPtyId: new Map([[FLOATING_PTY_ID, floatingDispose]]) + }) + capturedPanesByTabId.set(TAB_ID, { worktreeId: 'removed-worktree', panes: [] }) + capturedPanesByTabId.set(FLOATING_TAB_ID, { + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + panes: [] + }) + + pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds([])) + + expect(parkedWatchersByTabId.has(TAB_ID)).toBe(false) + expect(capturedPanesByTabId.has(TAB_ID)).toBe(false) + expect(removedDispose).toHaveBeenCalledOnce() + expect(parkedWatchersByTabId.has(FLOATING_TAB_ID)).toBe(true) + expect(capturedPanesByTabId.has(FLOATING_TAB_ID)).toBe(true) + expect(floatingDispose).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index fd34398824a..1f4b522040a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -8,6 +8,7 @@ * import cycle-free, mirroring how pty-dispatcher exports its handler maps. */ import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' export type ParkedTerminalPaneCapture = { ptyId: string | null @@ -51,6 +52,11 @@ export function getParkedTerminalWatcherTabIds(): string[] { return Array.from(parkedWatchersByTabId.keys()) } +// Why: the floating workspace is synthetic, so repo/folder surface lists never include it. +export function terminalWatcherLiveWorkspaceIds(workspaceIds: Iterable): Set { + return new Set([...workspaceIds, FLOATING_TERMINAL_WORKTREE_ID]) +} + /** * Whether this tab is parked right now — the reveal remount's own mount effect * runs before the host effect that disposes the watcher (child effects first),