fix(editor): stop Cmd/Ctrl+Shift+T reopening a phantom editor tab

`openFile` honored a recently-closed snapshot's `reopenId` whenever no
open file currently held that id, without checking whether the same
(path, mode, owner) was already open under a different id. Editor ids
are not symmetrical across worktrees: the first worktree to open an
absolute path gets the bare path as its id, and any other owner of the
same path gets a namespaced `editor:<wt>:<runtime>:<path>` id. So a
snapshot captured under one shape could be restored while the live tab
carried the other.

When that happened `openFile` took the `existing` branch and mapped over
`openFiles` looking for the stale id, matching nothing — no OpenFile was
created or updated — yet it still set `activeFileId`, the per-worktree
active id, the tab bar order and a unified tab entity to that stale id.
The user got a tab rendering nothing and a dangling active editor.

Resolve the id from the already-open file whenever one exists, so capture
and restore agree on canonicalization. This is a no-op for the non-reopen
path, where `resolveEditorFileIdForOwner` already returns `existing.id`
via the identical predicate.
This commit is contained in:
OrcaWin
2026-08-03 22:31:45 -04:00
parent a69ca6d5b7
commit afd30e9f2a
2 changed files with 47 additions and 2 deletions
@@ -1712,6 +1712,49 @@ describe('createEditorSlice recently closed editor tabs', () => {
expect(store.getState().openFiles[0]?.id).toBe(firstId)
expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([firstId])
})
it('reactivates the live tab when a stale reopen id targets an already-open file', () => {
const store = createEditorTabsStore()
store.setState({
worktreesByRepo: {
'repo-1': [
{ id: 'wt-1', repoId: 'repo-1', path: '/repo' },
{ id: 'wt-2', repoId: 'repo-1', path: '/repo-2' }
]
}
} as unknown as Partial<AppState>)
const sharedPath = '/home/me/.zshrc'
const openShared = (worktreeId: string): string =>
store.getState().openFile({
filePath: sharedPath,
relativePath: '.zshrc',
worktreeId,
language: 'shell',
mode: 'edit'
})
// Bare path id: nothing else owns this path yet.
const staleWt1Id = openShared('wt-1')
expect(staleWt1Id).toBe(sharedPath)
store.getState().closeFile(staleWt1Id)
// wt-2 claims the bare id, so wt-1's reopen gets a namespaced id instead.
const wt2Id = openShared('wt-2')
expect(wt2Id).toBe(sharedPath)
const liveWt1Id = openShared('wt-1')
expect(liveWt1Id).toBe(ownedEditorFileId(sharedPath, 'wt-1', null))
store.getState().closeFile(wt2Id)
expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true)
expect(store.getState().openFiles.map((file) => file.id)).toEqual([liveWt1Id])
expect(store.getState().activeFileId).toBe(liveWt1Id)
expect(store.getState().activeFileIdByWorktree['wt-1']).toBe(liveWt1Id)
expect(
(store.getState().unifiedTabsByWorktree['wt-1'] ?? []).map((tab) => tab.entityId)
).toEqual([liveWt1Id])
expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([liveWt1Id])
})
})
describe('createEditorSlice markdown view state', () => {
+4 -2
View File
@@ -1677,8 +1677,10 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
matchesEditorMode(f, reusableOpenFileModes) &&
isSameEditorOwner(f, worktreeId, runtimeEnvironmentId)
)
const id =
options?.reopenId && !s.openFiles.some((candidate) => candidate.id === options.reopenId)
// Why: a snapshot's reopenId can be a stale shape — the same path is bare in whichever worktree opened it first and namespaced elsewhere — so honoring it while this owner's tab is already open would strand activeFileId and the unified tab on an id no OpenFile has.
const id = existing
? existing.id
: options?.reopenId && !s.openFiles.some((candidate) => candidate.id === options.reopenId)
? options.reopenId
: resolveEditorFileIdForOwner(
s,