mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
Reopen closed tabs with Cmd+Shift+T, including terminals
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 <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<AppState>()((...a) => ({
|
||||
...createRuntimeStatusSlice(...a),
|
||||
...createPullRequestGenerationSlice(...a),
|
||||
...createCommitMessageGenerationSlice(...a),
|
||||
...createPinnedTabCloseConfirmSlice(...a)
|
||||
...createPinnedTabCloseConfirmSlice(...a),
|
||||
...createRecentlyClosedTabsSlice(...a)
|
||||
}))
|
||||
|
||||
registerHttpLinkStoreAccessor(() => useAppStore.getState())
|
||||
|
||||
@@ -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<AppState, [], [], BrowserSlice> =
|
||||
{ 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<AppState, [], [], BrowserSlice> =
|
||||
pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId,
|
||||
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
|
||||
recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree,
|
||||
recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree,
|
||||
recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace,
|
||||
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
|
||||
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId
|
||||
|
||||
@@ -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<AppState>()((...a) => ({
|
||||
@@ -175,7 +176,8 @@ function createTestStore() {
|
||||
...createRuntimeStatusSlice(...a),
|
||||
...createPullRequestGenerationSlice(...a),
|
||||
...createCommitMessageGenerationSlice(...a),
|
||||
...createPinnedTabCloseConfirmSlice(...a)
|
||||
...createPinnedTabCloseConfirmSlice(...a),
|
||||
...createRecentlyClosedTabsSlice(...a)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
|
||||
MAX_RECENT_CLOSED_EDITOR_TABS
|
||||
)
|
||||
}
|
||||
nextRecentlyClosedKinds = pushRecentlyClosedTabKind(
|
||||
s.recentlyClosedTabKindsByWorktree,
|
||||
worktreeId,
|
||||
'editor'
|
||||
)
|
||||
}
|
||||
return {
|
||||
openFiles: newFiles,
|
||||
@@ -1722,6 +1729,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
editorViewMode: nextEditorViewMode,
|
||||
markdownFrontmatterVisible: nextMarkdownFrontmatterVisible,
|
||||
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed,
|
||||
recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds,
|
||||
...previewTabBarUpdate,
|
||||
...activeResult
|
||||
}
|
||||
@@ -2064,6 +2072,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
|
||||
MAX_RECENT_CLOSED_EDITOR_TABS
|
||||
)
|
||||
}
|
||||
nextRecentlyClosedKinds = pushRecentlyClosedTabKind(
|
||||
s.recentlyClosedTabKindsByWorktree,
|
||||
wtRecent,
|
||||
'editor'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -2114,7 +2128,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
markdownFrontmatterVisible: newMarkdownFrontmatterVisible,
|
||||
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
|
||||
pendingEditorReveal: null,
|
||||
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed
|
||||
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed,
|
||||
recentlyClosedTabKindsByWorktree: nextRecentlyClosedKinds
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2258,6 +2273,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
|
||||
0,
|
||||
MAX_RECENT_CLOSED_EDITOR_TABS
|
||||
)
|
||||
capturedCloseCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -2306,7 +2323,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
recentlyClosedEditorTabsByWorktree: {
|
||||
...s.recentlyClosedEditorTabsByWorktree,
|
||||
[activeWorktreeId]: nextRecentClosed
|
||||
}
|
||||
},
|
||||
recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind(
|
||||
s.recentlyClosedTabKindsByWorktree,
|
||||
activeWorktreeId,
|
||||
'editor',
|
||||
capturedCloseCount
|
||||
)
|
||||
}
|
||||
})
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -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<typeof AgentStatusModule>()
|
||||
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<typeof createTestStore> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, ClosedTerminalTabSnapshot[]> | undefined,
|
||||
worktreeId: string,
|
||||
snapshot: ClosedTerminalTabSnapshot
|
||||
): Record<string, ClosedTerminalTabSnapshot[]> {
|
||||
return {
|
||||
...map,
|
||||
[worktreeId]: [snapshot, ...(map?.[worktreeId] ?? [])].slice(0, MAX_RECENT_CLOSED_TERMINAL_TABS)
|
||||
}
|
||||
}
|
||||
|
||||
export function pushRecentlyClosedTabKind(
|
||||
map: Record<string, RecentlyClosedTabKind[]> | undefined,
|
||||
worktreeId: string,
|
||||
kind: RecentlyClosedTabKind,
|
||||
count = 1
|
||||
): Record<string, RecentlyClosedTabKind[]> {
|
||||
// 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<string, ClosedTerminalTabSnapshot[]>
|
||||
/** 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<string, RecentlyClosedTabKind[]>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>
|
||||
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<AppState, [], [], TerminalSlice>
|
||||
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<AppState, [], [], TerminalSlice>
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Ap
|
||||
// Why: keyed by worktreeId; re-keyed on rename but missed by both removal
|
||||
// paths, leaking the per-worktree editor-undo (Cmd/Ctrl+Shift+T) snapshots.
|
||||
recentlyClosedEditorTabsByWorktree: omitByWorktree(s.recentlyClosedEditorTabsByWorktree),
|
||||
recentlyClosedTerminalTabsByWorktree: omitByWorktree(s.recentlyClosedTerminalTabsByWorktree),
|
||||
recentlyClosedTabKindsByWorktree: omitByWorktree(s.recentlyClosedTabKindsByWorktree),
|
||||
// Top-level actives
|
||||
openFiles: nextOpenFiles,
|
||||
everActivatedWorktreeIds: nextEverActivatedWorktreeIds,
|
||||
@@ -3237,6 +3265,19 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user