From 52e88e4469b298bf4208f1825355cf6b2d5a1bdd Mon Sep 17 00:00:00 2001 From: BearMett <45350001+BearMett@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:42:06 +0900 Subject: [PATCH] fix(editor): keep a pinned duplicate open on close and ignore setActiveFile for a missing file (#28120) * fix(editor): keep a pinned clean sibling open when closing its same-owner twin Closing a document also closes clean same-owner duplicate records; a record whose tab is pinned is now left open with its tab. * fix(editor): ignore setActiveFile for an id with no open file A stale tab or pane id could make a missing file active and persist it as the workspace's active file. --- .../editor-close-pinned-sibling.test.ts | 79 +++++++++++++++++++ .../editor-set-active-file-guard.test.ts | 59 ++++++++++++++ .../editor/actions/close-file-action.ts | 3 + .../editor/actions/open-file-mutations.ts | 3 + 4 files changed, 144 insertions(+) create mode 100644 src/renderer/src/store/slices/editor-close-pinned-sibling.test.ts create mode 100644 src/renderer/src/store/slices/editor-set-active-file-guard.test.ts diff --git a/src/renderer/src/store/slices/editor-close-pinned-sibling.test.ts b/src/renderer/src/store/slices/editor-close-pinned-sibling.test.ts new file mode 100644 index 00000000000..2ed780423ad --- /dev/null +++ b/src/renderer/src/store/slices/editor-close-pinned-sibling.test.ts @@ -0,0 +1,79 @@ +import type { StoreApi } from 'zustand/vanilla' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { OpenFile } from './editor' +import { createEditorTabsStore } from './editor-slice-test-harness' + +const live = vi.hoisted((): { store: StoreApi | null } => ({ store: null })) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => { + if (!live.store) { + throw new Error('No test store') + } + return live.store.getState() + } + } +})) + +let store: StoreApi +let file: OpenFile + +beforeEach(() => { + store = createEditorTabsStore() + live.store = store + store.getState().openFile({ + filePath: '/repo/note.md', + relativePath: 'note.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + const opened = store.getState().openFiles[0] + if (!opened) { + throw new Error('Document did not open') + } + file = opened +}) + +afterEach(() => { + live.store = null +}) + +// A clean same-owner twin of `file`, shown in its own tab. +function addPinnedSibling(): OpenFile { + const sibling = { ...file, id: 'duplicate-record' } + store.setState({ openFiles: [...store.getState().openFiles, sibling] }) + store.getState().createUnifiedTab(sibling.worktreeId, 'editor', { + id: 'duplicate-tab', + entityId: sibling.id, + label: 'note.md', + activate: false + }) + store.getState().pinTab('duplicate-tab') + return sibling +} + +describe('case 7: closing a document with a pinned clean sibling', () => { + it('premise: the sibling tab is pinned and both records are open', () => { + addPinnedSibling() + const tabs = store.getState().unifiedTabsByWorktree['wt-1'] + expect(tabs.find((tab) => tab.id === 'duplicate-tab')?.isPinned).toBe(true) + expect(store.getState().openFiles.map((open) => open.id)).toEqual([file.id, 'duplicate-record']) + }) + + it('keeps the pinned sibling record open', () => { + const sibling = addPinnedSibling() + store.getState().closeFile(file.id) + expect(store.getState().openFiles).toEqual([sibling]) + }) + + it('keeps the pinned sibling tab and closes only the named tab', () => { + addPinnedSibling() + store.getState().closeFile(file.id) + const tabs = store.getState().unifiedTabsByWorktree['wt-1'] ?? [] + expect(tabs.map((tab) => [tab.id, tab.entityId])).toEqual([ + ['duplicate-tab', 'duplicate-record'] + ]) + }) +}) diff --git a/src/renderer/src/store/slices/editor-set-active-file-guard.test.ts b/src/renderer/src/store/slices/editor-set-active-file-guard.test.ts new file mode 100644 index 00000000000..de7f90c7c11 --- /dev/null +++ b/src/renderer/src/store/slices/editor-set-active-file-guard.test.ts @@ -0,0 +1,59 @@ +import type { StoreApi } from 'zustand/vanilla' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import { createEditorTabsStore } from './editor-slice-test-harness' + +const live = vi.hoisted((): { store: StoreApi | null } => ({ store: null })) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => { + if (!live.store) { + throw new Error('No test store') + } + return live.store.getState() + } + } +})) + +const GHOST_ID = 'editor:ghost-pane' +let store: StoreApi +let fileId: string + +beforeEach(() => { + store = createEditorTabsStore() + live.store = store + store.getState().openFile({ + filePath: '/repo/note.md', + relativePath: 'note.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + const opened = store.getState().openFiles[0] + if (!opened) { + throw new Error('Document did not open') + } + fileId = opened.id +}) + +afterEach(() => { + live.store = null +}) + +describe('setActiveFile with an id that has no open file', () => { + it('premise: the open document is active', () => { + expect(store.getState().activeFileId).toBe(fileId) + expect(store.getState().activeFileIdByWorktree['wt-1']).toBe(fileId) + }) + + it('leaves the in-memory and per-workspace active file unchanged', () => { + store.getState().setActiveFile(GHOST_ID) + expect(store.getState().activeFileId).toBe(fileId) + expect(store.getState().activeFileIdByWorktree['wt-1']).toBe(fileId) + }) + + it('leaves the per-workspace active file, which is what persists, unchanged', () => { + store.getState().setActiveFile(GHOST_ID, 'wt-1') + expect(store.getState().activeFileIdByWorktree['wt-1']).toBe(fileId) + }) +}) diff --git a/src/renderer/src/store/slices/editor/actions/close-file-action.ts b/src/renderer/src/store/slices/editor/actions/close-file-action.ts index 329b45a291e..e7658d58dd9 100644 --- a/src/renderer/src/store/slices/editor/actions/close-file-action.ts +++ b/src/renderer/src/store/slices/editor/actions/close-file-action.ts @@ -88,6 +88,9 @@ export function createCloseFileAction( preClose.readOnly !== true && file.readOnly !== true && isSameOwnedDocument(file, preClose) && + !get().unifiedTabsByWorktree[file.worktreeId]?.some( + (tab) => tab.entityId === file.id && tab.isPinned + ) && !file.isDirty && !(file.id in get().editorDrafts)) ) diff --git a/src/renderer/src/store/slices/editor/actions/open-file-mutations.ts b/src/renderer/src/store/slices/editor/actions/open-file-mutations.ts index fb9bded1db0..e34889f60cc 100644 --- a/src/renderer/src/store/slices/editor/actions/open-file-mutations.ts +++ b/src/renderer/src/store/slices/editor/actions/open-file-mutations.ts @@ -21,6 +21,9 @@ export function createOpenFileMutations( > { return { setActiveFile: (fileId, targetWorktreeId) => { + if (!get().openFiles.some((file) => file.id === fileId)) { + return + } set((s) => { const file = s.openFiles.find((f) => f.id === fileId) const worktreeId = file?.worktreeId