fix(tabs): restore Cmd/Ctrl+Shift+T tab positions (#12236)
This commit is contained in:
parent
d48cac7d0f
commit
18dbcf001f
|
|
@ -24,7 +24,12 @@ 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 {
|
||||
getRecentlyClosedTabPosition,
|
||||
restoreRecentlyClosedTabPosition,
|
||||
pushRecentlyClosedTabKind
|
||||
} from './recently-closed-tabs'
|
||||
import type { RecentlyClosedTabPosition } from './recently-closed-tabs'
|
||||
import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
import type {
|
||||
|
|
@ -84,6 +89,7 @@ type BrowserTabPageState = {
|
|||
type ClosedBrowserWorkspaceSnapshot = {
|
||||
workspace: BrowserWorkspace
|
||||
pages: BrowserPage[]
|
||||
position?: RecentlyClosedTabPosition
|
||||
}
|
||||
|
||||
function sanitizeBrowserPageAnnotation(annotation: BrowserPageAnnotation): BrowserPageAnnotation {
|
||||
|
|
@ -771,8 +777,13 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
|
||||
const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree }
|
||||
const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? []
|
||||
const position = getRecentlyClosedTabPosition(s, owningWorktreeId, tabId)
|
||||
nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [
|
||||
{ workspace: closedWorkspace, pages: closedPages },
|
||||
{
|
||||
workspace: closedWorkspace,
|
||||
pages: closedPages,
|
||||
...(position ? { position } : {})
|
||||
},
|
||||
...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id)
|
||||
].slice(0, 10)
|
||||
const nextRecentlyClosedTabKindsByWorktree = pushRecentlyClosedTabKind(
|
||||
|
|
@ -890,8 +901,10 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
title: snap.title,
|
||||
activate: true,
|
||||
sessionProfileId,
|
||||
sessionPartition
|
||||
sessionPartition,
|
||||
targetGroupId: entryToRestore.position?.groupId
|
||||
})
|
||||
restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position)
|
||||
return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null
|
||||
}
|
||||
|
||||
|
|
@ -902,6 +915,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
activate: true,
|
||||
sessionProfileId,
|
||||
sessionPartition,
|
||||
targetGroupId: entryToRestore.position?.groupId,
|
||||
browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId
|
||||
})
|
||||
|
||||
|
|
@ -924,6 +938,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
}
|
||||
}
|
||||
|
||||
restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position)
|
||||
|
||||
return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ function createEditorTabsStore(): StoreApi<AppState> {
|
|||
browserTabsByWorktree: {},
|
||||
activeBrowserTabId: null,
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabId: null,
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
setTabBarOrder: (worktreeId: string, order: string[]) =>
|
||||
args[0]((state: AppState) => ({
|
||||
tabBarOrderByWorktree: { ...state.tabBarOrderByWorktree, [worktreeId]: order }
|
||||
})),
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] },
|
||||
folderWorkspaces: [],
|
||||
|
|
@ -663,7 +670,7 @@ describe('createEditorSlice openDiff', () => {
|
|||
it('bumps fileContentReloadNonce when re-opening an existing clean file with reload requested', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
const openFileWithReloadRequest = (): void =>
|
||||
const openFileWithReloadRequest = (): void => {
|
||||
store.getState().openFile(
|
||||
{
|
||||
filePath: '/repo/file.ts',
|
||||
|
|
@ -674,6 +681,7 @@ describe('createEditorSlice openDiff', () => {
|
|||
},
|
||||
{ forceContentReload: true }
|
||||
)
|
||||
}
|
||||
|
||||
openFileWithReloadRequest()
|
||||
expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBeUndefined()
|
||||
|
|
@ -869,7 +877,7 @@ describe('createEditorSlice openDiff', () => {
|
|||
it('keeps an existing preview replaceable when it is opened as preview again', () => {
|
||||
const store = createEditorTabsStore()
|
||||
|
||||
const openPreviewFile = (): void =>
|
||||
const openPreviewFile = (): void => {
|
||||
store.getState().openFile(
|
||||
{
|
||||
filePath: '/repo/a.ts',
|
||||
|
|
@ -880,6 +888,7 @@ describe('createEditorSlice openDiff', () => {
|
|||
},
|
||||
{ preview: true }
|
||||
)
|
||||
}
|
||||
|
||||
openPreviewFile()
|
||||
openPreviewFile()
|
||||
|
|
@ -1624,6 +1633,85 @@ describe('createEditorSlice recently closed editor tabs', () => {
|
|||
expect(store.getState().openFiles.at(-1)).toMatchObject({ filePath: '/repo/notes.md' })
|
||||
expect(store.getState().openFiles.at(-1)).not.toHaveProperty('mirroredFromRuntimeSession')
|
||||
})
|
||||
|
||||
it('restores the exact same-path owner instead of moving the local editor', () => {
|
||||
const store = createEditorTabsStore()
|
||||
const localId = store.getState().openFile({
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
const remoteId = store.getState().openFile({
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().setTabBarOrder('wt-1', [localId, remoteId])
|
||||
|
||||
store.getState().closeFile(remoteId)
|
||||
expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true)
|
||||
|
||||
const openIds = store.getState().openFiles.map((file) => file.id)
|
||||
expect(openIds).toEqual([localId, remoteId])
|
||||
expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([localId, remoteId])
|
||||
})
|
||||
|
||||
it('keeps same-path edit and diff tabs as separate entities on reopen', () => {
|
||||
const store = createEditorTabsStore()
|
||||
const editId = store.getState().openFile({
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().openDiff('wt-1', '/repo/notes.md', 'notes.md', 'markdown', false)
|
||||
const diffId = 'wt-1::diff::unstaged::notes.md'
|
||||
store.getState().setTabBarOrder('wt-1', [editId, diffId])
|
||||
|
||||
store.getState().closeFile(diffId)
|
||||
expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true)
|
||||
|
||||
expect(store.getState().openFiles.map((file) => file.id)).toEqual([editId, diffId])
|
||||
expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([editId, diffId])
|
||||
})
|
||||
|
||||
it('keeps close-all editor snapshots positioned for reopen', () => {
|
||||
const store = createEditorTabsStore()
|
||||
const firstId = store.getState().openFile({
|
||||
filePath: '/repo/first.md',
|
||||
relativePath: 'first.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
const middleId = store.getState().openFile({
|
||||
filePath: '/repo/middle.md',
|
||||
relativePath: 'middle.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
const lastId = store.getState().openFile({
|
||||
filePath: '/repo/last.md',
|
||||
relativePath: 'last.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
store.getState().setTabBarOrder('wt-1', [firstId, middleId, lastId])
|
||||
|
||||
store.getState().closeAllFiles()
|
||||
expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true)
|
||||
|
||||
expect(store.getState().openFiles[0]?.id).toBe(firstId)
|
||||
expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([firstId])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEditorSlice markdown view state', () => {
|
||||
|
|
@ -4941,7 +5029,7 @@ describe('closeFile host mirroring', () => {
|
|||
describe('read-only editor tabs (AI Vault View Log)', () => {
|
||||
const LOG_PATH = '/home/user/.claude/sessions/log.jsonl'
|
||||
|
||||
const openReadOnlyLog = (store: StoreApi<AppState>): void =>
|
||||
const openReadOnlyLog = (store: StoreApi<AppState>): void => {
|
||||
store.getState().openFile(
|
||||
{
|
||||
filePath: LOG_PATH,
|
||||
|
|
@ -4955,6 +5043,7 @@ describe('read-only editor tabs (AI Vault View Log)', () => {
|
|||
},
|
||||
{ preview: false, forceContentReload: true, suppressActiveRuntimeFallback: true }
|
||||
)
|
||||
}
|
||||
|
||||
it('creates a permanent read-only edit tab', () => {
|
||||
const store = createEditorStore()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
/* eslint-disable max-lines */
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import { pushRecentlyClosedTabKind } from './recently-closed-tabs'
|
||||
import {
|
||||
getRecentlyClosedTabPosition,
|
||||
restoreRecentlyClosedTabPosition,
|
||||
pushRecentlyClosedTabKind
|
||||
} from './recently-closed-tabs'
|
||||
import type { RecentlyClosedTabPosition } from './recently-closed-tabs'
|
||||
import { joinPath } from '@/lib/path'
|
||||
import { toast } from 'sonner'
|
||||
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
|
||||
|
|
@ -276,12 +281,15 @@ export type MarkdownViewMode = 'source' | 'rich' | 'preview'
|
|||
// Why: orthogonal to MarkdownViewMode; 'changes' renders diff-vs-HEAD in place of the editor without a separate tab. See reviews/changes-view-mode-plan.md.
|
||||
export type EditorViewMode = 'edit' | 'changes'
|
||||
|
||||
/** Enough state to restore a tab via `openFile` after `closeFile` (id is always filePath). */
|
||||
/** Enough state to restore a tab via `openFile` after `closeFile`. */
|
||||
// Why: omit mirroredFromRuntimeSession so a user-reopened tab isn't treated as host-owned and culled by the next web session sync.
|
||||
export type ClosedEditorTabSnapshot = Omit<
|
||||
OpenFile,
|
||||
'id' | 'isDirty' | 'mirroredFromRuntimeSession'
|
||||
>
|
||||
> & {
|
||||
reopenId?: string
|
||||
position?: RecentlyClosedTabPosition
|
||||
}
|
||||
|
||||
const MAX_RECENT_CLOSED_EDITOR_TABS = 10
|
||||
|
||||
|
|
@ -464,8 +472,9 @@ export type EditorSlice = {
|
|||
suppressActiveRuntimeFallback?: boolean
|
||||
forceContentReload?: boolean
|
||||
focusEditor?: boolean
|
||||
reopenId?: string
|
||||
}
|
||||
) => void
|
||||
) => string
|
||||
openNewMarkdownInActiveWorkspace: (groupId: string) => Promise<void>
|
||||
// Why: sequences openFile/setMarkdownViewMode/reveal around an async Monaco remount. See docs/markdown-internal-link-opening-design.md.
|
||||
activateMarkdownLink: (
|
||||
|
|
@ -1668,13 +1677,16 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
matchesEditorMode(f, reusableOpenFileModes) &&
|
||||
isSameEditorOwner(f, worktreeId, runtimeEnvironmentId)
|
||||
)
|
||||
const id = resolveEditorFileIdForOwner(
|
||||
s,
|
||||
file.filePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId,
|
||||
reusableOpenFileModes
|
||||
)
|
||||
const id =
|
||||
options?.reopenId && !s.openFiles.some((candidate) => candidate.id === options.reopenId)
|
||||
? options.reopenId
|
||||
: resolveEditorFileIdForOwner(
|
||||
s,
|
||||
file.filePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId,
|
||||
reusableOpenFileModes
|
||||
)
|
||||
editorItemFileId = id
|
||||
const isPreview = options?.preview ?? false
|
||||
const recordReplacedPreview = options?.recordReplacedPreview ?? false
|
||||
|
|
@ -1799,12 +1811,17 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
...snap
|
||||
} = replacedPreview
|
||||
const stack = s.recentlyClosedEditorTabsByWorktree[worktreeId] ?? []
|
||||
const position = getRecentlyClosedTabPosition(s, worktreeId, replacedPreview.id)
|
||||
nextRecentlyClosed = {
|
||||
...s.recentlyClosedEditorTabsByWorktree,
|
||||
[worktreeId]: [snap as ClosedEditorTabSnapshot, ...stack].slice(
|
||||
0,
|
||||
MAX_RECENT_CLOSED_EDITOR_TABS
|
||||
)
|
||||
[worktreeId]: [
|
||||
{
|
||||
...(snap as ClosedEditorTabSnapshot),
|
||||
reopenId: replacedPreview.id,
|
||||
...(position ? { position } : {})
|
||||
},
|
||||
...stack
|
||||
].slice(0, MAX_RECENT_CLOSED_EDITOR_TABS)
|
||||
}
|
||||
nextRecentlyClosedKinds = pushRecentlyClosedTabKind(
|
||||
s.recentlyClosedTabKindsByWorktree,
|
||||
|
|
@ -1886,6 +1903,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
}
|
||||
})
|
||||
}
|
||||
return editorItemFileId
|
||||
},
|
||||
|
||||
openNewMarkdownInActiveWorkspace: async (groupId) => {
|
||||
|
|
@ -2188,12 +2206,17 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
...snap
|
||||
} = closedFile
|
||||
const stack = s.recentlyClosedEditorTabsByWorktree[wtRecent] ?? []
|
||||
const position = getRecentlyClosedTabPosition(s, wtRecent, fileId)
|
||||
nextRecentlyClosed = {
|
||||
...s.recentlyClosedEditorTabsByWorktree,
|
||||
[wtRecent]: [snap as ClosedEditorTabSnapshot, ...stack].slice(
|
||||
0,
|
||||
MAX_RECENT_CLOSED_EDITOR_TABS
|
||||
)
|
||||
[wtRecent]: [
|
||||
{
|
||||
...(snap as ClosedEditorTabSnapshot),
|
||||
reopenId: fileId,
|
||||
...(position ? { position } : {})
|
||||
},
|
||||
...stack
|
||||
].slice(0, MAX_RECENT_CLOSED_EDITOR_TABS)
|
||||
}
|
||||
nextRecentlyClosedKinds = pushRecentlyClosedTabKind(
|
||||
s.recentlyClosedTabKindsByWorktree,
|
||||
|
|
@ -2264,7 +2287,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
[worktreeId]: (s.recentlyClosedEditorTabsByWorktree[worktreeId] ?? []).slice(1)
|
||||
}
|
||||
}))
|
||||
get().openFile(next)
|
||||
const { position, reopenId, ...file } = next
|
||||
const restoredFileId = get().openFile(file, {
|
||||
targetGroupId: position?.groupId,
|
||||
reopenId
|
||||
})
|
||||
restoreRecentlyClosedTabPosition(get, worktreeId, restoredFileId, position)
|
||||
return true
|
||||
},
|
||||
|
||||
|
|
@ -2375,10 +2403,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
continue
|
||||
}
|
||||
const { id: _id, isDirty: _dirty, mirroredFromRuntimeSession: _mirrored, ...snap } = f
|
||||
nextRecentClosed = [snap as ClosedEditorTabSnapshot, ...nextRecentClosed].slice(
|
||||
0,
|
||||
MAX_RECENT_CLOSED_EDITOR_TABS
|
||||
)
|
||||
const position = getRecentlyClosedTabPosition(s, activeWorktreeId, f.id)
|
||||
nextRecentClosed = [
|
||||
{
|
||||
...(snap as ClosedEditorTabSnapshot),
|
||||
reopenId: f.id,
|
||||
...(position ? { position } : {})
|
||||
},
|
||||
...nextRecentClosed
|
||||
].slice(0, MAX_RECENT_CLOSED_EDITOR_TABS)
|
||||
capturedCloseCount += 1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,10 +34,18 @@ const mockApi = {
|
|||
// @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'
|
||||
import {
|
||||
createTestStore,
|
||||
seedStore,
|
||||
makeWorktree,
|
||||
makeOpenFile,
|
||||
makeTabGroup,
|
||||
makeUnifiedTab
|
||||
} from './store-test-helpers'
|
||||
import {
|
||||
pushRecentlyClosedTabKind,
|
||||
remapClosedTerminalTabSnapshotCwds
|
||||
remapClosedTerminalTabSnapshotCwds,
|
||||
restoreRecentlyClosedTabPosition
|
||||
} from './recently-closed-tabs'
|
||||
|
||||
const WT = 'repo1::/path/wt1'
|
||||
|
|
@ -69,7 +77,12 @@ describe('terminal recently-closed capture', () => {
|
|||
store.getState().closeTab(tab.id)
|
||||
|
||||
expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toEqual([
|
||||
{ startupCwd: '/path/wt1/packages/app', customTitle: 'build shell', color: '#ff0000' }
|
||||
expect.objectContaining({
|
||||
startupCwd: '/path/wt1/packages/app',
|
||||
customTitle: 'build shell',
|
||||
color: '#ff0000',
|
||||
position: expect.objectContaining({ groupIndex: 0 })
|
||||
})
|
||||
])
|
||||
expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual(['terminal'])
|
||||
})
|
||||
|
|
@ -140,6 +153,30 @@ describe('terminal snapshot cwd remapping', () => {
|
|||
})
|
||||
|
||||
describe('reopenClosedTerminalTab', () => {
|
||||
it('restores the tab bar and group position of a closed middle tab', () => {
|
||||
const store = makeSeededStore()
|
||||
const first = store.getState().createTab(WT)
|
||||
const middle = store.getState().createTab(WT)
|
||||
const last = store.getState().createTab(WT)
|
||||
store.getState().setTabBarOrder(WT, [first.id, middle.id, last.id])
|
||||
|
||||
store.getState().closeTab(middle.id)
|
||||
expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true)
|
||||
|
||||
const restored = store
|
||||
.getState()
|
||||
.tabsByWorktree[WT]?.find((tab) => tab.id !== first.id && tab.id !== last.id)
|
||||
expect(restored).toBeDefined()
|
||||
expect(store.getState().tabBarOrderByWorktree[WT]).toEqual([first.id, restored!.id, last.id])
|
||||
|
||||
const group = store.getState().groupsByWorktree[WT]?.[0]
|
||||
expect(
|
||||
group?.tabOrder.map(
|
||||
(id) => store.getState().unifiedTabsByWorktree[WT]?.find((tab) => tab.id === id)?.entityId
|
||||
)
|
||||
).toEqual([first.id, restored!.id, last.id])
|
||||
})
|
||||
|
||||
it('recreates a fresh terminal with the snapshot cwd, shell, title, and color', () => {
|
||||
const store = makeSeededStore()
|
||||
const tab = store
|
||||
|
|
@ -232,6 +269,98 @@ describe('reopenClosedTerminalTab', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('restoreRecentlyClosedTabPosition', () => {
|
||||
it('selects the unified tab in the captured group when an entity is shared', () => {
|
||||
const reordered = vi.fn()
|
||||
const otherTab = makeUnifiedTab({
|
||||
id: 'other-tab',
|
||||
entityId: 'shared-file',
|
||||
groupId: 'other-group',
|
||||
worktreeId: WT,
|
||||
contentType: 'editor'
|
||||
})
|
||||
const capturedTab = makeUnifiedTab({
|
||||
id: 'captured-tab',
|
||||
entityId: 'shared-file',
|
||||
groupId: 'captured-group',
|
||||
worktreeId: WT,
|
||||
contentType: 'editor'
|
||||
})
|
||||
const capturedSibling = makeUnifiedTab({
|
||||
id: 'captured-sibling',
|
||||
entityId: 'sibling-file',
|
||||
groupId: 'captured-group',
|
||||
worktreeId: WT,
|
||||
contentType: 'editor'
|
||||
})
|
||||
const state = {
|
||||
tabBarOrderByWorktree: { [WT]: ['shared-file'] },
|
||||
groupsByWorktree: {
|
||||
[WT]: [
|
||||
makeTabGroup({
|
||||
id: 'other-group',
|
||||
worktreeId: WT,
|
||||
activeTabId: 'other-tab',
|
||||
tabOrder: ['other-tab']
|
||||
}),
|
||||
makeTabGroup({
|
||||
id: 'captured-group',
|
||||
worktreeId: WT,
|
||||
activeTabId: 'captured-sibling',
|
||||
tabOrder: ['captured-sibling', 'captured-tab']
|
||||
})
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: { [WT]: [otherTab, capturedTab, capturedSibling] }
|
||||
}
|
||||
|
||||
restoreRecentlyClosedTabPosition(
|
||||
() => ({
|
||||
...state,
|
||||
setTabBarOrder: vi.fn(),
|
||||
reorderUnifiedTabs: reordered
|
||||
}),
|
||||
WT,
|
||||
'shared-file',
|
||||
{ groupId: 'captured-group', groupIndex: 0 }
|
||||
)
|
||||
|
||||
expect(reordered).toHaveBeenCalledWith('captured-group', ['captured-tab', 'captured-sibling'], {
|
||||
recordInteraction: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reuse a stale flat index after group drag reordering', () => {
|
||||
const store = makeSeededStore()
|
||||
const first = store.getState().createTab(WT)
|
||||
const middle = store.getState().createTab(WT)
|
||||
const last = store.getState().createTab(WT)
|
||||
const groupId = store.getState().groupsByWorktree[WT]?.[0]?.id
|
||||
if (!groupId) {
|
||||
throw new Error('Expected a root tab group')
|
||||
}
|
||||
store.getState().setTabBarOrder(WT, [first.id, middle.id, last.id])
|
||||
store.getState().reorderUnifiedTabs(groupId, [first.id, last.id, middle.id])
|
||||
|
||||
store.getState().closeTab(middle.id)
|
||||
expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]?.[0]?.position).toEqual({
|
||||
groupId,
|
||||
groupIndex: 2
|
||||
})
|
||||
|
||||
expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true)
|
||||
const restored = store
|
||||
.getState()
|
||||
.tabsByWorktree[WT]?.find((tab) => tab.id !== first.id && tab.id !== last.id)
|
||||
expect(store.getState().tabBarOrderByWorktree[WT]).toEqual([first.id, last.id, restored?.id])
|
||||
expect(store.getState().groupsByWorktree[WT]?.[0]?.tabOrder).toEqual([
|
||||
first.id,
|
||||
last.id,
|
||||
restored?.id
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reopenClosedTab cross-type MRU', () => {
|
||||
it('reopens closed tabs of mixed kinds in most-recent-first order', () => {
|
||||
const store = makeSeededStore()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,114 @@ import {
|
|||
relativePathInsideRoot
|
||||
} from '../../../../shared/cross-platform-path'
|
||||
|
||||
export type RecentlyClosedTabPosition = {
|
||||
tabBarIndex?: number
|
||||
groupId?: string
|
||||
groupIndex?: number
|
||||
}
|
||||
|
||||
export function getRecentlyClosedTabPosition(
|
||||
state: Pick<AppState, 'tabBarOrderByWorktree' | 'groupsByWorktree' | 'unifiedTabsByWorktree'>,
|
||||
worktreeId: string,
|
||||
entityId: string
|
||||
): RecentlyClosedTabPosition | undefined {
|
||||
const tabBarOrder = state.tabBarOrderByWorktree?.[worktreeId]
|
||||
const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? []
|
||||
const tabBarIndex = tabBarOrder?.indexOf(entityId) ?? -1
|
||||
const unifiedTab = unifiedTabs.find((tab) => tab.entityId === entityId)
|
||||
const group = unifiedTab
|
||||
? (state.groupsByWorktree?.[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === unifiedTab.groupId
|
||||
)
|
||||
: undefined
|
||||
const groupIndex = group?.tabOrder.indexOf(unifiedTab?.id ?? '') ?? -1
|
||||
const groupTabEntityIds = group
|
||||
? group.tabOrder.map((tabId) => unifiedTabs.find((tab) => tab.id === tabId)?.entityId)
|
||||
: []
|
||||
const tabBarGroupEntityIds = group
|
||||
? (tabBarOrder ?? [])
|
||||
.map((tabId) => unifiedTabs.find((tab) => tab.entityId === tabId))
|
||||
.filter((tab) => tab?.groupId === group.id)
|
||||
.map((tab) => tab?.entityId)
|
||||
: []
|
||||
const groupOrderMatchesTabBar =
|
||||
!group ||
|
||||
(groupTabEntityIds.length === tabBarGroupEntityIds.length &&
|
||||
groupTabEntityIds.every((entityId, index) => entityId === tabBarGroupEntityIds[index]))
|
||||
if (tabBarIndex < 0 && (!group || groupIndex < 0)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...(tabBarIndex >= 0 && groupOrderMatchesTabBar ? { tabBarIndex } : {}),
|
||||
...(group && groupIndex >= 0 ? { groupId: group.id, groupIndex } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function insertTabAtRecentlyClosedPosition(
|
||||
order: readonly string[],
|
||||
tabId: string,
|
||||
position?: RecentlyClosedTabPosition
|
||||
): string[] {
|
||||
const nextOrder = order.filter((id) => id !== tabId)
|
||||
const index = position?.tabBarIndex
|
||||
if (index === undefined) {
|
||||
return [...nextOrder, tabId]
|
||||
}
|
||||
nextOrder.splice(Math.min(Math.max(index, 0), nextOrder.length), 0, tabId)
|
||||
return nextOrder
|
||||
}
|
||||
|
||||
export function restoreRecentlyClosedTabPosition(
|
||||
getState: () => Pick<
|
||||
AppState,
|
||||
| 'tabBarOrderByWorktree'
|
||||
| 'groupsByWorktree'
|
||||
| 'unifiedTabsByWorktree'
|
||||
| 'setTabBarOrder'
|
||||
| 'reorderUnifiedTabs'
|
||||
>,
|
||||
worktreeId: string,
|
||||
entityId: string,
|
||||
position?: RecentlyClosedTabPosition
|
||||
): void {
|
||||
if (!position) {
|
||||
return
|
||||
}
|
||||
const state = getState()
|
||||
const order = state.tabBarOrderByWorktree?.[worktreeId]
|
||||
if (order && typeof state.setTabBarOrder === 'function') {
|
||||
state.setTabBarOrder(worktreeId, insertTabAtRecentlyClosedPosition(order, entityId, position))
|
||||
}
|
||||
|
||||
if (position?.groupIndex === undefined) {
|
||||
return
|
||||
}
|
||||
const unifiedTab = (getState().unifiedTabsByWorktree?.[worktreeId] ?? []).find(
|
||||
(candidate) =>
|
||||
candidate.entityId === entityId &&
|
||||
(position.groupId === undefined || candidate.groupId === position.groupId)
|
||||
)
|
||||
if (!unifiedTab) {
|
||||
return
|
||||
}
|
||||
const group = (getState().groupsByWorktree?.[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === unifiedTab.groupId
|
||||
)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
if (typeof getState().reorderUnifiedTabs === 'function') {
|
||||
getState().reorderUnifiedTabs(
|
||||
group.id,
|
||||
insertTabAtRecentlyClosedPosition(group.tabOrder, unifiedTab.id, {
|
||||
tabBarIndex: position.groupIndex
|
||||
}),
|
||||
{ recordInteraction: false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
|
|
@ -14,6 +122,7 @@ export type ClosedTerminalTabSnapshot = {
|
|||
shellOverride?: string
|
||||
customTitle?: string
|
||||
color?: string
|
||||
position?: RecentlyClosedTabPosition
|
||||
}
|
||||
|
||||
export type RecentlyClosedTabKind = 'terminal' | 'browser' | 'editor'
|
||||
|
|
@ -135,7 +244,7 @@ export const createRecentlyClosedTabsSlice: StateCreator<
|
|||
return false
|
||||
}
|
||||
|
||||
const tab = get().createTab(worktreeId, undefined, snapshot.shellOverride, {
|
||||
const tab = get().createTab(worktreeId, snapshot.position?.groupId, snapshot.shellOverride, {
|
||||
...(snapshot.startupCwd ? { startupCwd: snapshot.startupCwd } : {}),
|
||||
activate: true
|
||||
})
|
||||
|
|
@ -146,13 +255,7 @@ export const createRecentlyClosedTabsSlice: StateCreator<
|
|||
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])
|
||||
}
|
||||
restoreRecentlyClosedTabPosition(get, worktreeId, tab.id, snapshot.position)
|
||||
return true
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,11 @@ import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-
|
|||
import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs'
|
||||
import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards'
|
||||
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
|
||||
import { pushClosedTerminalTabSnapshot, pushRecentlyClosedTabKind } from './recently-closed-tabs'
|
||||
import {
|
||||
getRecentlyClosedTabPosition,
|
||||
pushClosedTerminalTabSnapshot,
|
||||
pushRecentlyClosedTabKind
|
||||
} from './recently-closed-tabs'
|
||||
import { isClaudeAgent } from '@/lib/agent-status'
|
||||
import { recordTerminalInputActivity } from '@/lib/terminal-input-activity-coalescing'
|
||||
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
|
||||
|
|
@ -1651,6 +1655,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
}
|
||||
}
|
||||
// Why: only explicit user closes feed the Cmd+Shift+T reopen stack; cleanup/PTY-exit closes must not pollute undo history.
|
||||
const closedPosition =
|
||||
closedWorktreeId && closedTab
|
||||
? getRecentlyClosedTabPosition(s, closedWorktreeId, closedTab.id)
|
||||
: undefined
|
||||
const capturedSnapshot =
|
||||
closeReason === 'user' &&
|
||||
opts?.captureRecentlyClosed !== false &&
|
||||
|
|
@ -1660,7 +1668,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
...(closedTab.startupCwd ? { startupCwd: closedTab.startupCwd } : {}),
|
||||
...(closedTab.shellOverride ? { shellOverride: closedTab.shellOverride } : {}),
|
||||
...(closedTab.customTitle ? { customTitle: closedTab.customTitle } : {}),
|
||||
...(closedTab.color ? { color: closedTab.color } : {})
|
||||
...(closedTab.color ? { color: closedTab.color } : {}),
|
||||
...(closedPosition ? { position: closedPosition } : {})
|
||||
}
|
||||
: null
|
||||
const nextExpanded = { ...s.expandedPaneByTabId }
|
||||
|
|
|
|||
Loading…
Reference in New Issue