From afd30e9f2afee4531ea26bd49f7570ff86df462f Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:31:38 -0400 Subject: [PATCH] fix(editor): stop Cmd/Ctrl+Shift+T reopening a phantom editor tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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:::` 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. --- src/renderer/src/store/slices/editor.test.ts | 43 ++++++++++++++++++++ src/renderer/src/store/slices/editor.ts | 6 ++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index d8a554a5197..cc9dc23fade 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -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) + 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', () => { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index c3c2d630710..988ef6ed6a1 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1677,8 +1677,10 @@ export const createEditorSlice: StateCreator = (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,