From 5ccbf2deefb761ce93830f1f66c6c46634d810d8 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Sun, 5 Jul 2026 11:48:06 -0700 Subject: [PATCH] Reopen closed tabs with Cmd+Shift+T, including terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd/Ctrl+Shift+T now reopens the most recently closed tab of any kind (terminal, browser, or editor) in true recency order, walking back through close history on repeated presses — Chrome/Ghostty semantics. Terminal tabs previously had no reopen at all, and the shortcut picked browser-vs-editor by focused surface instead of recency. Terminal closes capture a snapshot (startupCwd, shellOverride, customTitle, color) on explicit user closes only; PTY exits, launch cleanup, onboarding, and floating-panel closes are excluded. Reopen spawns a fresh shell through the same createTab path as the "+" button. A per-worktree cross-type kind list (new recently-closed-tabs slice) orders the three existing per-type stacks; both new maps are purged on worktree removal and re-keyed on rename with startupCwd prefix remap. createNewTerminalTab moved to terminal-tab-create.ts to keep terminal-tab-actions.ts under the max-lines budget. Requested via Discord feedback; STA-1287. Co-authored-by: Orca --- src/renderer/src/components/Terminal.tsx | 22 +- .../FloatingTerminalPanel.tsx | 4 +- .../OnboardingInlineCommandTerminal.tsx | 8 +- .../components/right-sidebar/FileExplorer.tsx | 2 +- .../TerminalPaneOverlayLayer.tsx | 4 +- .../terminal/terminal-tab-actions.test.ts | 48 ++++- .../terminal/terminal-tab-actions.ts | 77 ++----- .../terminal/terminal-tab-create.ts | 57 ++++++ .../lib/launch-agent-background-session.ts | 2 +- .../src/lib/launch-agent-in-new-tab.ts | 2 +- .../launch-worktree-background-terminals.ts | 2 +- src/renderer/src/store/index.ts | 4 +- src/renderer/src/store/slices/browser.ts | 7 + .../src/store/slices/diffComments.test.ts | 4 +- src/renderer/src/store/slices/editor.ts | 27 ++- .../store/slices/recently-closed-tabs.test.ts | 192 ++++++++++++++++++ .../src/store/slices/recently-closed-tabs.ts | 150 ++++++++++++++ .../src/store/slices/store-test-helpers.ts | 4 +- src/renderer/src/store/slices/terminals.ts | 43 +++- .../slices/worktree-removal-maps-leak.test.ts | 13 ++ .../src/store/slices/worktrees.test.ts | 13 ++ src/renderer/src/store/slices/worktrees.ts | 43 +++- src/renderer/src/store/types.ts | 4 +- 23 files changed, 638 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/components/terminal/terminal-tab-create.ts create mode 100644 src/renderer/src/store/slices/recently-closed-tabs.test.ts create mode 100644 src/renderer/src/store/slices/recently-closed-tabs.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 335d5f15308..98ecd4a8930 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1068,9 +1068,12 @@ function Terminal(): React.JSX.Element | null { if (consumeSuppressedPtyExit(ptyId)) { return } - handleCloseTab(tabId) + // Why: a pty exiting on its own is not a user close — it must not join + // the Cmd+Shift+T reopen stack (mirrors TerminalPaneOverlayLayer's + // pty-exit/user-close split). + closeTerminalTab(tabId, { captureRecentlyClosed: false }) }, - [consumeSuppressedPtyExit, handleCloseTab] + [consumeSuppressedPtyExit] ) const handleCloseOthers = useCallback( @@ -1357,20 +1360,13 @@ function Terminal(): React.JSX.Element | null { } } - // Cmd/Ctrl+Shift+T — reopen closed browser tab when browser is active, - // otherwise reopen the most recently closed editor tab. + // Cmd/Ctrl+Shift+T — reopen the most recently closed tab of any kind + // (terminal, browser, or editor), Chrome/Ghostty-style. Repeated presses + // walk back through the close history. if (!e.repeat && matchShortcut('tab.reopenClosed')) { e.preventDefault() notifyTerminalCapture('tab.reopenClosed') - const state = useAppStore.getState() - if (state.activeTabType === 'browser') { - const restored = state.reopenClosedBrowserTab(activeWorktreeId) - if (restored === null) { - state.reopenClosedEditorTab(activeWorktreeId) - } - } else { - state.reopenClosedEditorTab(activeWorktreeId) - } + useAppStore.getState().reopenClosedTab(activeWorktreeId) return } diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 8fa5e6a5258..112e06ab188 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -766,7 +766,7 @@ export function FloatingTerminalPanel({ const dirtyEditorFileIds: string[] = [] for (const item of items) { if (item.contentType === 'terminal') { - closeTab(item.entityId) + closeTab(item.entityId, { captureRecentlyClosed: false }) } else if (item.contentType === 'browser') { destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) @@ -1537,7 +1537,7 @@ export function FloatingTerminalPanel({ // atlas to corrupt) while hidden, and the resume on // reopen rebuilds the renderer from scratch. isVisible={isActive && open} - onPtyExit={() => closeTab(tab.id)} + onPtyExit={() => closeTab(tab.id, { captureRecentlyClosed: false })} onCloseTab={() => closeFloatingItem(tab.id)} /> diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 9f7c8da1398..6639381685c 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -103,7 +103,7 @@ export function OnboardingInlineCommandTerminal({ return () => { // Why: inline setup panels can disappear after detection succeeds; close // the backing tab so installer shells do not keep running invisibly. - closeTab(tab.id, { recordInteraction: false }) + closeTab(tab.id, { recordInteraction: false, captureRecentlyClosed: false }) } }, [ closeTab, @@ -296,9 +296,11 @@ export function OnboardingInlineCommandTerminal({ isVisible onPtyExit={() => { onTerminalExit?.() - closeTab(tabId, { recordInteraction: false }) + closeTab(tabId, { recordInteraction: false, captureRecentlyClosed: false }) }} - onCloseTab={() => closeTab(tabId, { recordInteraction: false })} + onCloseTab={() => + closeTab(tabId, { recordInteraction: false, captureRecentlyClosed: false }) + } /> ) : (
diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 83defe0e715..0dcbe538f8b 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -54,7 +54,7 @@ import { translate } from '@/i18n/i18n' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab' import type { RightSidebarExplorerView } from '../../../../shared/types' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { createNewTerminalTab } from '@/components/terminal/terminal-tab-actions' +import { createNewTerminalTab } from '@/components/terminal/terminal-tab-create' function FileExplorerFiles(): React.JSX.Element { const explorerView = useAppStore((s) => s.rightSidebarExplorerView) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 0e68af9126f..3b18f333ed0 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -58,7 +58,7 @@ type TerminalOverlaySlotProps = { activityTerminalPortal: ActivityTerminalPortalTarget | null onFocusOwningGroup: ((groupId: string) => void) | undefined consumeSuppressedPtyExit: (ptyId: string) => boolean - closeTab: (tabId: string) => void + closeTab: (tabId: string, opts?: { captureRecentlyClosed?: boolean }) => void leaveWorktreeIfEmpty: () => void } @@ -237,7 +237,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ if (consumeSuppressedPtyExit(ptyId)) { return } - closeTab(terminalTabId) + closeTab(terminalTabId, { captureRecentlyClosed: false }) leaveWorktreeIfEmpty() }} onCloseTab={() => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index a070cdc9331..288f3d7788c 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -42,9 +42,9 @@ vi.mock('@/runtime/web-session-tabs-sync', () => ({ import { closeOtherTerminalTabs, closeTerminalTab, - closeTerminalTabsToRight, - createNewTerminalTab + closeTerminalTabsToRight } from './terminal-tab-actions' +import { createNewTerminalTab } from './terminal-tab-create' describe('createNewTerminalTab', () => { beforeEach(() => { @@ -439,6 +439,50 @@ describe('closeTerminalTab', () => { expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled() expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1') }) + + it('threads captureRecentlyClosed:false through to closeTab for pty-exit closes', () => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1' }, { id: 'tab-2' }] + }, + unifiedTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab, + setActiveTab: vi.fn() + }) + + // Why: the legacy no-layout surface routes pty exits through + // closeTerminalTab; a self-exited shell must not join the reopen stack. + closeTerminalTab('tab-1', { captureRecentlyClosed: false }) + + expect(closeTab).toHaveBeenCalledWith('tab-1', { captureRecentlyClosed: false }) + }) + + it('keeps the plain user-close call shape when no capture option is given', () => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1' }, { id: 'tab-2' }] + }, + unifiedTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('tab-1') + + expect(closeTab).toHaveBeenCalledWith('tab-1') + }) }) describe('closeOtherTerminalTabs', () => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 9991b7648ea..d6e0932548f 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -5,7 +5,6 @@ import { reconcileTabOrder } from '../tab-bar/reconcile-order' import { activateWebRuntimeSessionTab, closeWebRuntimeSessionTab, - createWebRuntimeSessionTerminal, isWebRuntimeSessionActive, toHostSessionTabId } from '@/runtime/web-runtime-session' @@ -65,12 +64,19 @@ function getWorktreeTerminalTabIds(state: TerminalTabActionState, worktreeId: st return [...ids] } -function closeLocalTerminalTabState(terminalTabId: string): void { +function closeLocalTerminalTabState( + terminalTabId: string, + opts?: { captureRecentlyClosed?: boolean } +): void { const state = useAppStore.getState() if ( Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === terminalTabId)) ) { - state.closeTab(terminalTabId) + if (opts) { + state.closeTab(terminalTabId, opts) + } else { + state.closeTab(terminalTabId) + } return } @@ -99,63 +105,20 @@ function isPinnedVisibleTab( ) } -export function createNewTerminalTab( - activeWorktreeId: string | null, - shellOverride?: string, - options?: { startupCwd?: string } +export function closeTerminalTab( + tabId: string, + options?: { force?: boolean; captureRecentlyClosed?: boolean } ): void { - if (!activeWorktreeId) { - return - } - const state = useAppStore.getState() - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { - // Why: paired web clients receive host-owned terminal tabs through - // session.tabs. Creating a local tab first races the host snapshot and can - // leave stale remote handles in the web store. - void createWebRuntimeSessionTerminal({ - worktreeId: activeWorktreeId, - environmentId: runtimeEnvironmentId, - command: shellOverride, - ...(options?.startupCwd ? { cwd: options.startupCwd } : {}), - activate: true - }) - return - } - const newTab = state.createTab( - activeWorktreeId, - undefined, - shellOverride, - options?.startupCwd ? { startupCwd: options.startupCwd } : undefined - ) - state.setActiveTabType('terminal') - // Why: persist the tab bar order with the new terminal at the end of the - // current visual order. Without this, reconcileTabOrder falls back to - // terminals-first when tabBarOrderByWorktree is unset, causing a new - // terminal to jump to index 0 instead of appending after editor tabs. - const freshState = useAppStore.getState() - const termIds = (freshState.tabsByWorktree[activeWorktreeId] ?? []).map((t) => t.id) - const editorIds = freshState.openFiles - .filter((f) => f.worktreeId === activeWorktreeId) - .map((f) => f.id) - const base = reconcileTabOrder( - freshState.tabBarOrderByWorktree[activeWorktreeId], - termIds, - editorIds - ) - // The new tab is already in base via termIds; move it to the end - const order = base.filter((id) => id !== newTab.id) - order.push(newTab.id) - state.setTabBarOrder(activeWorktreeId, order) -} - -export function closeTerminalTab(tabId: string, options?: { force?: boolean }): void { const state = useAppStore.getState() const target = resolveCloseTerminalTabTarget(state, tabId) if (!target) { return } const { worktreeId: owningWorktreeId, terminalTabId } = target + const captureOpts = + options?.captureRecentlyClosed === undefined + ? undefined + : { captureRecentlyClosed: options.captureRecentlyClosed } // Why: a pinned tab routes through the confirmation guard instead of closing // outright. `force` is the post-confirmation re-entry, which skips the guard. @@ -163,7 +126,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }): guardPinnedTabClose({ isPinned: true, tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), - onClose: () => closeTerminalTab(tabId, { force: true }) + onClose: () => closeTerminalTab(tabId, { ...captureOpts, force: true }) }) return } @@ -187,7 +150,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }): }) ?? toHostSessionTabId(terminalTabId) // Why: prune local mirrors immediately so close feels responsive while the // host session snapshot catches up. - closeLocalTerminalTabState(terminalTabId) + closeLocalTerminalTabState(terminalTabId, captureOpts) void closeWebRuntimeSessionTab({ worktreeId: owningWorktreeId, tabId: hostBackedTabId, @@ -198,7 +161,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }): const currentTerminalTabIds = getWorktreeTerminalTabIds(state, owningWorktreeId) if (currentTerminalTabIds.length <= 1) { - closeLocalTerminalTabState(terminalTabId) + closeLocalTerminalTabState(terminalTabId, captureOpts) if (state.activeWorktreeId === owningWorktreeId) { // Why: only deactivate the worktree when no tabs of any kind remain. // Editor files are a separate tab type; closing the last terminal tab @@ -229,7 +192,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }): } } - closeLocalTerminalTabState(terminalTabId) + closeLocalTerminalTabState(terminalTabId, captureOpts) } export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | null): void { diff --git a/src/renderer/src/components/terminal/terminal-tab-create.ts b/src/renderer/src/components/terminal/terminal-tab-create.ts new file mode 100644 index 00000000000..ed54255b3fc --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-create.ts @@ -0,0 +1,57 @@ +import { useAppStore } from '@/store' +import { reconcileTabOrder } from '../tab-bar/reconcile-order' +import { + createWebRuntimeSessionTerminal, + isWebRuntimeSessionActive +} from '@/runtime/web-runtime-session' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function createNewTerminalTab( + activeWorktreeId: string | null, + shellOverride?: string, + options?: { startupCwd?: string } +): void { + if (!activeWorktreeId) { + return + } + const state = useAppStore.getState() + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId) + if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { + // Why: paired web clients receive host-owned terminal tabs through + // session.tabs. Creating a local tab first races the host snapshot and can + // leave stale remote handles in the web store. + void createWebRuntimeSessionTerminal({ + worktreeId: activeWorktreeId, + environmentId: runtimeEnvironmentId, + command: shellOverride, + ...(options?.startupCwd ? { cwd: options.startupCwd } : {}), + activate: true + }) + return + } + const newTab = state.createTab( + activeWorktreeId, + undefined, + shellOverride, + options?.startupCwd ? { startupCwd: options.startupCwd } : undefined + ) + state.setActiveTabType('terminal') + // Why: persist the tab bar order with the new terminal at the end of the + // current visual order. Without this, reconcileTabOrder falls back to + // terminals-first when tabBarOrderByWorktree is unset, causing a new + // terminal to jump to index 0 instead of appending after editor tabs. + const freshState = useAppStore.getState() + const termIds = (freshState.tabsByWorktree[activeWorktreeId] ?? []).map((t) => t.id) + const editorIds = freshState.openFiles + .filter((f) => f.worktreeId === activeWorktreeId) + .map((f) => f.id) + const base = reconcileTabOrder( + freshState.tabBarOrderByWorktree[activeWorktreeId], + termIds, + editorIds + ) + // The new tab is already in base via termIds; move it to the end + const order = base.filter((id) => id !== newTab.id) + order.push(newTab.id) + state.setTabBarOrder(activeWorktreeId, order) +} diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 389c6972cf1..600c9a9ce30 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -221,7 +221,7 @@ export async function launchAgentBackgroundSession( } } } catch (error) { - store.closeTab(tab.id, { recordInteraction: false }) + store.closeTab(tab.id, { recordInteraction: false, captureRecentlyClosed: false }) throw error } store.updateTabPtyId(tab.id, ptyId) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 41ded040d48..0b73ed67a37 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -59,7 +59,7 @@ function removeStaleLocalAgentTabsForWebHostLaunch(worktreeId: string): void { const state = useAppStore.getState() for (const tab of state.tabsByWorktree[worktreeId] ?? []) { if (tab.launchAgent && !isWebTerminalSurfaceTabId(tab.id)) { - state.closeTab(tab.id) + state.closeTab(tab.id, { captureRecentlyClosed: false }) } } } diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts index 896da11f8e5..cb6c3cbf797 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -177,7 +177,7 @@ async function createBackgroundTab(args: { env: args.launch.env }) } catch (error) { - store.closeTab(tab.id, { recordInteraction: false }) + store.closeTab(tab.id, { recordInteraction: false, captureRecentlyClosed: false }) throw error } store.updateTabPtyId(tab.id, ptyId) diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index df81ab4f36e..d5d59a1ae2f 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -33,6 +33,7 @@ import { createRuntimeStatusSlice } from './slices/runtime-status' import { createPullRequestGenerationSlice } from './slices/pull-request-generation' import { createCommitMessageGenerationSlice } from './slices/commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm' +import { createRecentlyClosedTabsSlice } from './slices/recently-closed-tabs' import { e2eConfig } from '@/lib/e2e-config' import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' @@ -69,7 +70,8 @@ export const useAppStore = create()((...a) => ({ ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), - ...createPinnedTabCloseConfirmSlice(...a) + ...createPinnedTabCloseConfirmSlice(...a), + ...createRecentlyClosedTabsSlice(...a) })) registerHttpLinkStoreAccessor(() => useAppStore.getState()) diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 53d07f58127..ccb007d2f56 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -23,6 +23,7 @@ import { } from '../../../../shared/workspace-session-browser-history' import { pickNeighbor } from './tab-group-state' import { destroyWorkspaceWebviews } from './browser-webview-cleanup' +import { pushRecentlyClosedTabKind } from './recently-closed-tabs' import { callRuntimeRpc, getActiveRuntimeTarget, @@ -719,6 +720,11 @@ export const createBrowserSlice: StateCreator = { workspace: closedWorkspace, pages: closedPages }, ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) ].slice(0, 10) + const nextRecentlyClosedTabKindsByWorktree = pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + owningWorktreeId, + 'browser' + ) const nextRecentlyClosedBrowserPagesByWorkspace = { ...s.recentlyClosedBrowserPagesByWorkspace @@ -750,6 +756,7 @@ export const createBrowserSlice: StateCreator = pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, activeTabTypeByWorktree: nextActiveTabTypeByWorktree, recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, + recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, browserAnnotationsByPageId: nextBrowserAnnotationsByPageId diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index dbb200d89a0..1b6cc5e1ec7 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -140,6 +140,7 @@ import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm' +import { createRecentlyClosedTabsSlice } from './recently-closed-tabs' function createTestStore() { return create()((...a) => ({ @@ -175,7 +176,8 @@ function createTestStore() { ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), - ...createPinnedTabCloseConfirmSlice(...a) + ...createPinnedTabCloseConfirmSlice(...a), + ...createRecentlyClosedTabsSlice(...a) })) } diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 54013b30a1f..9f25302ff66 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' +import { pushRecentlyClosedTabKind } from './recently-closed-tabs' import { joinPath } from '@/lib/path' import { toast } from 'sonner' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' @@ -1698,6 +1699,7 @@ export const createEditorSlice: StateCreator = (s // recordReplacedPreview so file-explorer single-click (which // semantically *wants* silent eviction) is unaffected. let nextRecentlyClosed = s.recentlyClosedEditorTabsByWorktree + let nextRecentlyClosedKinds = s.recentlyClosedTabKindsByWorktree if (recordReplacedPreview && replacedPreview.id !== id) { const { id: _rid, @@ -1713,6 +1715,11 @@ export const createEditorSlice: StateCreator = (s MAX_RECENT_CLOSED_EDITOR_TABS ) } + nextRecentlyClosedKinds = pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + worktreeId, + 'editor' + ) } return { openFiles: newFiles, @@ -1722,6 +1729,7 @@ export const createEditorSlice: StateCreator = (s editorViewMode: nextEditorViewMode, markdownFrontmatterVisible: nextMarkdownFrontmatterVisible, recentlyClosedEditorTabsByWorktree: nextRecentlyClosed, + recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds, ...previewTabBarUpdate, ...activeResult } @@ -2064,6 +2072,7 @@ export const createEditorSlice: StateCreator = (s : s.tabBarOrderByWorktree let nextRecentlyClosed = s.recentlyClosedEditorTabsByWorktree + let nextRecentlyClosedKinds = s.recentlyClosedTabKindsByWorktree const wtRecent = closedFile?.worktreeId // Why: untitled files that were never edited will be deleted from disk // after close. Adding them to the reopen stack would let Cmd+Shift+T @@ -2089,6 +2098,11 @@ export const createEditorSlice: StateCreator = (s MAX_RECENT_CLOSED_EDITOR_TABS ) } + nextRecentlyClosedKinds = pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + wtRecent, + 'editor' + ) } return { @@ -2114,7 +2128,8 @@ export const createEditorSlice: StateCreator = (s markdownFrontmatterVisible: newMarkdownFrontmatterVisible, tabBarOrderByWorktree: nextTabBarOrderByWorktree, pendingEditorReveal: null, - recentlyClosedEditorTabsByWorktree: nextRecentlyClosed + recentlyClosedEditorTabsByWorktree: nextRecentlyClosed, + recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds } }) @@ -2258,6 +2273,7 @@ export const createEditorSlice: StateCreator = (s const closingFiles = s.openFiles.filter((f) => f.worktreeId === activeWorktreeId) let nextRecentClosed = s.recentlyClosedEditorTabsByWorktree[activeWorktreeId] ?? [] + let capturedCloseCount = 0 for (const f of [...closingFiles].toReversed()) { // Why: untitled non-dirty files are deleted from disk after close — // skip them so the reopen stack doesn't reference vanished paths. @@ -2273,6 +2289,7 @@ export const createEditorSlice: StateCreator = (s 0, MAX_RECENT_CLOSED_EDITOR_TABS ) + capturedCloseCount += 1 } return { @@ -2306,7 +2323,13 @@ export const createEditorSlice: StateCreator = (s recentlyClosedEditorTabsByWorktree: { ...s.recentlyClosedEditorTabsByWorktree, [activeWorktreeId]: nextRecentClosed - } + }, + recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + activeWorktreeId, + 'editor', + capturedCloseCount + ) } }) if (typeof window !== 'undefined') { diff --git a/src/renderer/src/store/slices/recently-closed-tabs.test.ts b/src/renderer/src/store/slices/recently-closed-tabs.test.ts new file mode 100644 index 00000000000..ac76ca0a6a7 --- /dev/null +++ b/src/renderer/src/store/slices/recently-closed-tabs.test.ts @@ -0,0 +1,192 @@ +/** + * Cmd/Ctrl+Shift+T reopen behavior: terminal tabs join the recently-closed + * stacks, and the unified dispatcher pops the most recently closed tab of any + * kind (terminal/browser/editor) in true cross-type MRU order. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { + list: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + updateMeta: vi.fn().mockResolvedValue({}) + }, + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +import { createTestStore, seedStore, makeWorktree, makeOpenFile } from './store-test-helpers' + +const WT = 'repo1::/path/wt1' + +function makeSeededStore(): ReturnType { + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: WT + }) + return store +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('terminal recently-closed capture', () => { + it('captures a snapshot and kind entry on user close', () => { + const store = makeSeededStore() + const tab = store + .getState() + .createTab(WT, undefined, undefined, { startupCwd: '/path/wt1/packages/app' }) + store.getState().setTabCustomTitle(tab.id, 'build shell') + store.getState().setTabColor(tab.id, '#ff0000') + + store.getState().closeTab(tab.id) + + expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toEqual([ + { startupCwd: '/path/wt1/packages/app', customTitle: 'build shell', color: '#ff0000' } + ]) + expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual(['terminal']) + }) + + it('skips capture when captureRecentlyClosed is false (system closes)', () => { + const store = makeSeededStore() + const tab = store.getState().createTab(WT) + + store.getState().closeTab(tab.id, { captureRecentlyClosed: false }) + + expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toBeUndefined() + expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toBeUndefined() + }) + + it('caps the terminal stack at 10 snapshots, newest first', () => { + const store = makeSeededStore() + for (let i = 0; i < 12; i++) { + const tab = store + .getState() + .createTab(WT, undefined, undefined, { startupCwd: `/path/wt1/dir-${i}` }) + store.getState().closeTab(tab.id) + } + + const stack = store.getState().recentlyClosedTerminalTabsByWorktree[WT] + expect(stack).toHaveLength(10) + expect(stack[0]?.startupCwd).toBe('/path/wt1/dir-11') + expect(stack[9]?.startupCwd).toBe('/path/wt1/dir-2') + }) +}) + +describe('reopenClosedTerminalTab', () => { + it('recreates a fresh terminal with the snapshot cwd, shell, title, and color', () => { + const store = makeSeededStore() + const tab = store + .getState() + .createTab(WT, undefined, 'zsh', { startupCwd: '/path/wt1/packages/app' }) + store.getState().setTabCustomTitle(tab.id, 'build shell') + store.getState().setTabColor(tab.id, '#ff0000') + store.getState().closeTab(tab.id) + expect(store.getState().tabsByWorktree[WT]).toHaveLength(0) + + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true) + + const restored = store.getState().tabsByWorktree[WT] + expect(restored).toHaveLength(1) + expect(restored[0]).toMatchObject({ + startupCwd: '/path/wt1/packages/app', + shellOverride: 'zsh', + customTitle: 'build shell', + color: '#ff0000' + }) + // Why: a reopened tab is a fresh surface — never the old PTY. + expect(restored[0].id).not.toBe(tab.id) + expect(restored[0].ptyId).toBeNull() + expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toHaveLength(0) + }) + + it('returns false when the stack is empty (no double-restore)', () => { + const store = makeSeededStore() + const tab = store.getState().createTab(WT) + store.getState().closeTab(tab.id) + + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true) + expect(store.getState().reopenClosedTerminalTab(WT)).toBe(false) + expect(store.getState().tabsByWorktree[WT]).toHaveLength(1) + }) +}) + +describe('reopenClosedTab cross-type MRU', () => { + it('reopens closed tabs of mixed kinds in most-recent-first order', () => { + const store = makeSeededStore() + + // Close order: editor → terminal → browser. + store.setState({ openFiles: [makeOpenFile({ id: '/repo/a.ts', worktreeId: WT })] }) + store.getState().closeFile('/repo/a.ts') + const terminal = store + .getState() + .createTab(WT, undefined, undefined, { startupCwd: '/path/wt1/api' }) + store.getState().closeTab(terminal.id) + const browser = store.getState().createBrowserTab(WT, 'https://example.com', { title: 'Ex' }) + store.getState().closeBrowserTab(browser.id) + + expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual([ + 'browser', + 'terminal', + 'editor' + ]) + + // First press: browser comes back. + expect(store.getState().reopenClosedTab(WT)).toBe(true) + expect(store.getState().browserTabsByWorktree[WT]?.[0]?.url).toBe('https://example.com') + expect(store.getState().tabsByWorktree[WT] ?? []).toHaveLength(0) + + // Second press: terminal comes back. + expect(store.getState().reopenClosedTab(WT)).toBe(true) + expect(store.getState().tabsByWorktree[WT]?.[0]?.startupCwd).toBe('/path/wt1/api') + expect(store.getState().openFiles).toHaveLength(0) + + // Third press: editor comes back. + expect(store.getState().reopenClosedTab(WT)).toBe(true) + expect(store.getState().openFiles[0]?.filePath).toBe('/repo/a.ts') + + // Nothing left. + expect(store.getState().reopenClosedTab(WT)).toBe(false) + }) + + it('skips kind entries whose per-type snapshot has drained', () => { + const store = makeSeededStore() + store.setState({ + recentlyClosedTabKindsByWorktree: { [WT]: ['browser', 'terminal'] }, + recentlyClosedTerminalTabsByWorktree: { [WT]: [{ startupCwd: '/path/wt1' }] }, + recentlyClosedBrowserTabsByWorktree: { [WT]: [] } + }) + + expect(store.getState().reopenClosedTab(WT)).toBe(true) + + expect(store.getState().tabsByWorktree[WT]?.[0]?.startupCwd).toBe('/path/wt1') + expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual([]) + }) + + it('returns false when no tab was ever closed', () => { + const store = makeSeededStore() + expect(store.getState().reopenClosedTab(WT)).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/recently-closed-tabs.ts b/src/renderer/src/store/slices/recently-closed-tabs.ts new file mode 100644 index 00000000000..d861207830d --- /dev/null +++ b/src/renderer/src/store/slices/recently-closed-tabs.ts @@ -0,0 +1,150 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' + +/** Snapshot of a terminal tab captured at user-initiated close time. Reopen + * recreates a fresh shell in the same startup directory (Ghostty semantics) — + * never the old PTY, scrollback, or a relaunched agent session. */ +export type ClosedTerminalTabSnapshot = { + startupCwd?: string + shellOverride?: string + customTitle?: string + color?: string +} + +export type RecentlyClosedTabKind = 'terminal' | 'browser' | 'editor' + +const MAX_RECENT_CLOSED_TERMINAL_TABS = 10 +// Why: wider than the per-type stacks (10) so cross-type ordering survives a +// full per-type stack; kind entries whose snapshot aged out are skipped on pop. +const MAX_RECENT_CLOSED_TAB_KINDS = 30 + +// Why: the map params tolerate undefined because several test harnesses build +// partial stores (single-slice spreads) that lack this slice's state. +export function pushClosedTerminalTabSnapshot( + map: Record | undefined, + worktreeId: string, + snapshot: ClosedTerminalTabSnapshot +): Record { + return { + ...map, + [worktreeId]: [snapshot, ...(map?.[worktreeId] ?? [])].slice(0, MAX_RECENT_CLOSED_TERMINAL_TABS) + } +} + +export function pushRecentlyClosedTabKind( + map: Record | undefined, + worktreeId: string, + kind: RecentlyClosedTabKind, + count = 1 +): Record { + // Why: preserve the original reference on no-op pushes so unrelated + // subscribers don't re-evaluate (mirrors the closeTab unread-map pattern). + if (count <= 0) { + return map ?? {} + } + return { + ...map, + [worktreeId]: [ + ...(Array(count).fill(kind) as RecentlyClosedTabKind[]), + ...(map?.[worktreeId] ?? []) + ].slice(0, MAX_RECENT_CLOSED_TAB_KINDS) + } +} + +export type RecentlyClosedTabsSlice = { + /** Newest-first snapshots of user-closed terminal tabs, per worktree. */ + recentlyClosedTerminalTabsByWorktree: Record + /** Newest-first close order across the terminal/browser/editor reopen stacks + * so Cmd+Shift+T pops true cross-type MRU (Chrome/Ghostty semantics). */ + recentlyClosedTabKindsByWorktree: Record + reopenClosedTerminalTab: (worktreeId: string) => boolean + reopenClosedTab: (worktreeId: string) => boolean +} + +export const createRecentlyClosedTabsSlice: StateCreator< + AppState, + [], + [], + RecentlyClosedTabsSlice +> = (set, get) => ({ + recentlyClosedTerminalTabsByWorktree: {}, + recentlyClosedTabKindsByWorktree: {}, + + reopenClosedTerminalTab: (worktreeId) => { + // Why: read and pop atomically inside set() to prevent a TOCTOU race where + // two rapid Cmd+Shift+T presses both restore the same entry (mirrors + // reopenClosedBrowserTab). + let snapshot: ClosedTerminalTabSnapshot | undefined + set((s) => { + const stack = s.recentlyClosedTerminalTabsByWorktree[worktreeId] ?? [] + snapshot = stack[0] + if (!snapshot) { + return s + } + return { + recentlyClosedTerminalTabsByWorktree: { + ...s.recentlyClosedTerminalTabsByWorktree, + [worktreeId]: stack.slice(1) + } + } + }) + if (!snapshot) { + return false + } + + const tab = get().createTab(worktreeId, undefined, snapshot.shellOverride, { + ...(snapshot.startupCwd ? { startupCwd: snapshot.startupCwd } : {}), + activate: true + }) + if (snapshot.customTitle) { + get().setTabCustomTitle(tab.id, snapshot.customTitle) + } + if (snapshot.color) { + get().setTabColor(tab.id, snapshot.color) + } + get().setActiveTabType('terminal') + // Why: with a stored order the strip appends unknown ids last already, but + // an explicit append keeps the reopened tab at the end even after future + // reorders write the stored order back. + const order = get().tabBarOrderByWorktree[worktreeId] + if (order && !order.includes(tab.id)) { + get().setTabBarOrder(worktreeId, [...order, tab.id]) + } + return true + }, + + reopenClosedTab: (worktreeId) => { + // Why: a kind entry can outlive its snapshot (per-type caps are tighter, + // and the browser stack dedupes by workspace id), so skip drained kinds + // instead of giving up. Each iteration shifts one entry, so the loop is + // bounded by the kind list length. + for (;;) { + let kind: RecentlyClosedTabKind | undefined + set((s) => { + const kinds = s.recentlyClosedTabKindsByWorktree[worktreeId] ?? [] + kind = kinds[0] + if (!kind) { + return s + } + return { + recentlyClosedTabKindsByWorktree: { + ...s.recentlyClosedTabKindsByWorktree, + [worktreeId]: kinds.slice(1) + } + } + }) + if (!kind) { + return false + } + const reopened = + kind === 'terminal' + ? get().reopenClosedTerminalTab(worktreeId) + : kind === 'browser' + ? get().reopenClosedBrowserTab(worktreeId) !== null + : get().reopenClosedEditorTab(worktreeId) + if (reopened) { + return true + } + } + } +}) diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 9045837ae3f..e5533e5a05d 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -41,6 +41,7 @@ import { createRuntimeStatusSlice } from './runtime-status' import { createPullRequestGenerationSlice } from './pull-request-generation' import { createCommitMessageGenerationSlice } from './commit-message-generation' import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm' +import { createRecentlyClosedTabsSlice } from './recently-closed-tabs' import { translate } from '@/i18n/i18n' export const TEST_REPO = { @@ -85,7 +86,8 @@ export function createTestStore() { ...createRuntimeStatusSlice(...a), ...createPullRequestGenerationSlice(...a), ...createCommitMessageGenerationSlice(...a), - ...createPinnedTabCloseConfirmSlice(...a) + ...createPinnedTabCloseConfirmSlice(...a), + ...createRecentlyClosedTabsSlice(...a) })) } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index c4a91508fca..bab08f0d194 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -35,6 +35,7 @@ import type { AgentStartedTelemetry } from '../../lib/worktree-activation' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' +import { pushClosedTerminalTabSnapshot, pushRecentlyClosedTabKind } from './recently-closed-tabs' import { isClaudeAgent, detectAgentStatusFromTitle } from '@/lib/agent-status' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' import { @@ -474,7 +475,10 @@ export type TerminalSlice = { } ) => TerminalTab openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise - closeTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void + closeTab: ( + tabId: string, + opts?: { recordInteraction?: boolean; captureRecentlyClosed?: boolean } + ) => void reorderTabs: (worktreeId: string, tabIds: string[]) => void setTabBarOrder: (worktreeId: string, order: string[]) => void setActiveTab: (tabId: string) => void @@ -1032,16 +1036,33 @@ export const createTerminalSlice: StateCreator set((s) => { const next = { ...s.tabsByWorktree } let closingPtyId: string | null = null + let closedTab: TerminalTab | null = null + let closedWorktreeId: string | null = null for (const wId of Object.keys(next)) { const before = next[wId] - if (!closingPtyId) { - closingPtyId = before.find((t) => t.id === tabId)?.ptyId ?? null + const closing = before.find((t) => t.id === tabId) + if (closing && !closedTab) { + closedTab = closing + closedWorktreeId = wId + closingPtyId = closing.ptyId ?? null } const after = before.filter((t) => t.id !== tabId) if (after.length !== before.length) { next[wId] = after } } + // Why: only explicit user closes feed the Cmd+Shift+T reopen stack. + // System closes (pty exit, launch cleanup, onboarding, floating panel) + // pass captureRecentlyClosed: false so they can't pollute it. + const capturedSnapshot = + opts?.captureRecentlyClosed !== false && closedTab && closedWorktreeId + ? { + ...(closedTab.startupCwd ? { startupCwd: closedTab.startupCwd } : {}), + ...(closedTab.shellOverride ? { shellOverride: closedTab.shellOverride } : {}), + ...(closedTab.customTitle ? { customTitle: closedTab.customTitle } : {}), + ...(closedTab.color ? { color: closedTab.color } : {}) + } + : null const nextExpanded = { ...s.expandedPaneByTabId } delete nextExpanded[tabId] const nextCanExpand = { ...s.canExpandPaneByTabId } @@ -1174,7 +1195,21 @@ export const createTerminalSlice: StateCreator cacheTimerByKey: nextCacheTimer, tabBarOrderByWorktree: nextTabBarOrderByWorktree, pendingSnapshotByPtyId: nextSnapshots, - pendingColdRestoreByPtyId: nextColdRestores + pendingColdRestoreByPtyId: nextColdRestores, + ...(capturedSnapshot && closedWorktreeId + ? { + recentlyClosedTerminalTabsByWorktree: pushClosedTerminalTabSnapshot( + s.recentlyClosedTerminalTabsByWorktree, + closedWorktreeId, + capturedSnapshot + ), + recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + closedWorktreeId, + 'terminal' + ) + } + : {}) } }) // Why: sweep live AND retained agent-status entries for this tab — closing diff --git a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts index e603ae94740..b1a9f028be7 100644 --- a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts @@ -121,6 +121,11 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously [WT1]: [makeOpenFile({ id: '/path/wt1/closed.ts', worktreeId: WT1 })], [WT2]: [makeOpenFile({ id: '/path/wt2/closed.ts', worktreeId: WT2 })] }, + recentlyClosedTerminalTabsByWorktree: { + [WT1]: [{ startupCwd: '/path/wt1' }], + [WT2]: [{ startupCwd: '/path/wt2' }] + }, + recentlyClosedTabKindsByWorktree: { [WT1]: ['terminal'], [WT2]: ['terminal'] }, defaultTerminalTabsAppliedByWorktreeId: { [WT1]: true, [WT2]: true } }) } @@ -135,10 +140,14 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously // Evicted for the removed worktree. expect(s.remoteStatusesByWorktree[WT1]).toBeUndefined() expect(s.recentlyClosedEditorTabsByWorktree[WT1]).toBeUndefined() + expect(s.recentlyClosedTerminalTabsByWorktree[WT1]).toBeUndefined() + expect(s.recentlyClosedTabKindsByWorktree[WT1]).toBeUndefined() expect(s.defaultTerminalTabsAppliedByWorktreeId[WT1]).toBeUndefined() // Retained for the surviving worktree (guard over-eviction). expect(s.remoteStatusesByWorktree[WT2]).toBeDefined() expect(s.recentlyClosedEditorTabsByWorktree[WT2]).toBeDefined() + expect(s.recentlyClosedTerminalTabsByWorktree[WT2]).toBeDefined() + expect(s.recentlyClosedTabKindsByWorktree[WT2]).toBeDefined() expect(s.defaultTerminalTabsAppliedByWorktreeId[WT2]).toBe(true) }) @@ -152,9 +161,13 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously const s = store.getState() expect(s.remoteStatusesByWorktree[WT1]).toBeUndefined() expect(s.recentlyClosedEditorTabsByWorktree[WT1]).toBeUndefined() + expect(s.recentlyClosedTerminalTabsByWorktree[WT1]).toBeUndefined() + expect(s.recentlyClosedTabKindsByWorktree[WT1]).toBeUndefined() expect(s.defaultTerminalTabsAppliedByWorktreeId[WT1]).toBeUndefined() expect(s.remoteStatusesByWorktree[WT2]).toBeDefined() expect(s.recentlyClosedEditorTabsByWorktree[WT2]).toBeDefined() + expect(s.recentlyClosedTerminalTabsByWorktree[WT2]).toBeDefined() + expect(s.recentlyClosedTabKindsByWorktree[WT2]).toBeDefined() expect(s.defaultTerminalTabsAppliedByWorktreeId[WT2]).toBe(true) }) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index ce3ba3176b5..a68f117e674 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -6330,6 +6330,10 @@ describe('migrateWorktreeIdentity', () => { lastVisitedAtByWorktreeId: { [OLD]: 123 }, defaultTerminalTabsAppliedByWorktreeId: { [OLD]: true }, recentlyClosedEditorTabsByWorktree: { [OLD]: [{ id: 'f1', worktreeId: OLD }] }, + recentlyClosedTerminalTabsByWorktree: { + [OLD]: [{ startupCwd: '/ws/cunner/packages/app' }, { startupCwd: '/elsewhere/dir' }] + }, + recentlyClosedTabKindsByWorktree: { [OLD]: ['terminal', 'editor'] }, remoteStatusesByWorktree: { [OLD]: { ahead: 1 } }, everActivatedWorktreeIds: new Set([OLD]), openFiles: [{ id: 'f1', worktreeId: OLD }], @@ -6376,6 +6380,15 @@ describe('migrateWorktreeIdentity', () => { expect(s.defaultTerminalTabsAppliedByWorktreeId[NEW]).toBe(true) // The two maps absent from the purge list are still re-keyed. expect(s.recentlyClosedEditorTabsByWorktree[NEW]).toEqual([{ id: 'f1', worktreeId: NEW }]) + // Terminal reopen snapshots re-key AND remap startupCwd under the old + // worktree path; paths outside the renamed folder stay as-is. + expect(s.recentlyClosedTerminalTabsByWorktree[OLD]).toBeUndefined() + expect(s.recentlyClosedTerminalTabsByWorktree[NEW]).toEqual([ + { startupCwd: '/ws/worktree-creation-spinner/packages/app' }, + { startupCwd: '/elsewhere/dir' } + ]) + expect(s.recentlyClosedTabKindsByWorktree[OLD]).toBeUndefined() + expect(s.recentlyClosedTabKindsByWorktree[NEW]).toEqual(['terminal', 'editor']) expect(s.remoteStatusesByWorktree[NEW]).toEqual({ ahead: 1 }) expect(s.everActivatedWorktreeIds.has(NEW)).toBe(true) expect(s.everActivatedWorktreeIds.has(OLD)).toBe(false) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2ddc8100aca..3abc262a8ea 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -25,6 +25,8 @@ import { getRepoIdFromWorktreeId, type WorktreeSlice } from './worktree-helpers' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import type { ClosedTerminalTabSnapshot } from './recently-closed-tabs' import { findRepoForHost } from './repo-host-identity' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' @@ -1667,7 +1669,8 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [ 'showDotfilesByWorktree', 'expandedDirs', 'lastVisitedAtByWorktreeId', - 'defaultTerminalTabsAppliedByWorktreeId' + 'defaultTerminalTabsAppliedByWorktreeId', + 'recentlyClosedTabKindsByWorktree' ] as const satisfies readonly (keyof AppState)[] /** @@ -1728,6 +1731,29 @@ function buildWorktreeRenameState( renameKey('recentlyClosedEditorTabsByWorktree', (files: { worktreeId: string }[]) => files.map(withNewWorktreeId) ) + // Why: terminal reopen snapshots carry absolute startupCwd paths under the + // old worktree folder; remap them or Cmd+Shift+T respawns into a directory + // that no longer exists after the rename. Paths outside the renamed folder + // are untouched by the move and stay valid as-is. + const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath + const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath + renameKey('recentlyClosedTerminalTabsByWorktree', (snapshots: ClosedTerminalTabSnapshot[]) => + oldWorktreePath && newWorktreePath + ? snapshots.map((snapshot) => { + const cwd = snapshot.startupCwd + if (!cwd) { + return snapshot + } + if (cwd === oldWorktreePath) { + return { ...snapshot, startupCwd: newWorktreePath } + } + if (cwd.startsWith(`${oldWorktreePath}/`) || cwd.startsWith(`${oldWorktreePath}\\`)) { + return { ...snapshot, startupCwd: newWorktreePath + cwd.slice(oldWorktreePath.length) } + } + return snapshot + }) + : snapshots + ) renameKey('remoteStatusesByWorktree') const openFiles = s.openFiles?.some((f) => f.worktreeId === oldWorktreeId) @@ -2038,6 +2064,8 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial delete next[worktreeId] return next })(), + recentlyClosedTerminalTabsByWorktree: (() => { + const next = { ...s.recentlyClosedTerminalTabsByWorktree } + delete next[worktreeId] + return next + })(), + // Why: shutdownWorktreeBrowsers/Terminals above already pushed kind + // entries for this worktree's tabs; a deleted worktree's tabs can + // never be reopened, so purge symmetrically with the snapshot stacks. + recentlyClosedTabKindsByWorktree: (() => { + const next = { ...s.recentlyClosedTabKindsByWorktree } + delete next[worktreeId] + return next + })(), defaultTerminalTabsAppliedByWorktreeId: (() => { const next = { ...s.defaultTerminalTabsAppliedByWorktreeId } delete next[worktreeId] diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index ad1a7089d8c..768b125f0bc 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -31,6 +31,7 @@ import type { RuntimeStatusSlice } from './slices/runtime-status' import type { PullRequestGenerationSlice } from './slices/pull-request-generation' import type { CommitMessageGenerationSlice } from './slices/commit-message-generation' import type { PinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confirm' +import type { RecentlyClosedTabsSlice } from './slices/recently-closed-tabs' export type AppState = RepoSlice & SparsePresetsSlice & @@ -64,4 +65,5 @@ export type AppState = RepoSlice & RuntimeStatusSlice & PullRequestGenerationSlice & CommitMessageGenerationSlice & - PinnedTabCloseConfirmSlice + PinnedTabCloseConfirmSlice & + RecentlyClosedTabsSlice