From a261ac6d033620cb75594ccaed1a2fedbc6e6430 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 20 Oct 2026 15:28:12 -0400 Subject: [PATCH 1/6] fix(editor): name a server's file by its owned id from the first open, so it no longer reloads after a restart --- .../editor/editor-autosave-controller.test.ts | 12 +- ...restored-tab-conflict-scan.recheck.test.ts | 30 +-- .../editor-save-disk-precondition.test.ts | 12 +- .../runtime/server-layout-frame-ids.test.ts | 3 +- .../src/runtime/server-layout-frame-ids.ts | 15 +- ...session-tabs-sync-editor-ownership.test.ts | 4 +- .../web-session-tabs-sync-editor-tabs.test.ts | 24 +- ...b-session-tabs-sync-snapshot-batch.test.ts | 8 +- ...on-tabs-sync-visibility-collision.test.tsx | 3 +- .../slices/editor-close-file-cleanup.test.ts | 24 +- .../editor-recently-closed-tabs.test.ts | 12 +- .../slices/editor/file-ids/editor-file-ids.ts | 4 + ...erver-editor-file-restart-identity.test.ts | 213 ++++++++++++++++++ 13 files changed, 298 insertions(+), 66 deletions(-) create mode 100644 src/renderer/src/store/slices/server-editor-file-restart-identity.test.ts diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index 5c4d127570f3..4fc2e0bd1d81 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -324,15 +324,15 @@ describe('attachEditorAutosaveController', () => { } as never ] }) - store.getState().openFile({ + const fileId = store.getState().openFile({ filePath: '/runtime/folder/src/file.ts', relativePath: 'src/file.ts', worktreeId: workspaceKey, language: 'typescript', mode: 'edit' }) - store.getState().setEditorDraft('/runtime/folder/src/file.ts', 'edited') - store.getState().markFileDirty('/runtime/folder/src/file.ts', true) + store.getState().setEditorDraft(fileId, 'edited') + store.getState().markFileDirty(fileId, true) const cleanup = attachEditorAutosaveController(withLoadedBaselines(store)) try { @@ -397,7 +397,7 @@ describe('attachEditorAutosaveController', () => { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' }] as never } }) - store.getState().openFile({ + const fileId = store.getState().openFile({ filePath: '/remote/repo/file.ts', relativePath: 'file.ts', worktreeId: 'wt-1', @@ -405,8 +405,8 @@ describe('attachEditorAutosaveController', () => { language: 'typescript', mode: 'edit' }) - store.getState().setEditorDraft('/remote/repo/file.ts', 'edited') - store.getState().markFileDirty('/remote/repo/file.ts', true) + store.getState().setEditorDraft(fileId, 'edited') + store.getState().markFileDirty(fileId, true) const cleanup = attachEditorAutosaveController(withLoadedBaselines(store)) try { diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts index 478ec1e33d73..f852f793b1c6 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts @@ -33,13 +33,15 @@ const FILE = '/repo/a.ts' let store: ReturnType let detach: () => void -const tab = () => store.getState().openFiles.find((file) => file.id === FILE)! +// A server's file is named by its owned id, not its path. +let fileId: string +const tab = () => store.getState().openFiles.find((file) => file.id === fileId)! beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() store = createTestStore() - store.getState().openFile({ + fileId = store.getState().openFile({ filePath: FILE, relativePath: 'a.ts', worktreeId: 'wt-1', @@ -47,9 +49,9 @@ beforeEach(() => { language: 'typescript', mode: 'edit' }) - store.getState().setEditorDraft(FILE, 'unsaved') - store.getState().markFileDirty(FILE, true) - store.getState().setLastKnownDiskSignature(FILE, getDiskBaselineSignature('v1')) + store.getState().setEditorDraft(fileId, 'unsaved') + store.getState().markFileDirty(fileId, true) + store.getState().setLastKnownDiskSignature(fileId, getDiskBaselineSignature('v1')) }) afterEach(() => { @@ -62,14 +64,14 @@ it('reads once more when a new check is requested while a read is in flight', as mocks.readRuntimeFileContent.mockImplementation( () => new Promise((resolve) => replies.push((content) => resolve({ content, isBinary: false }))) ) - store.getState().setPendingDiskBaselineVerification(FILE, true) + store.getState().setPendingDiskBaselineVerification(fileId, true) detach = attachRestoredTabConflictScan(store) await vi.advanceTimersByTimeAsync(0) expect(replies).toHaveLength(1) // A second catch-up lands mid-read, after an external write. - requestRestoredTabReverification(FILE) - requestRestoredTabReverification(FILE) + requestRestoredTabReverification(fileId) + requestRestoredTabReverification(fileId) replies[0]('v1') await vi.advanceTimersByTimeAsync(0) expect(tab().pendingDiskBaselineVerification).toBe(true) @@ -85,7 +87,7 @@ it('reads once more when a new check is requested while a read is in flight', as it('marks a dirty runtime-owned tab deleted when its host says the file is gone', async () => { mocks.readRuntimeFileContent.mockRejectedValue(new Error('read failed')) mocks.runtimePathsExist.mockResolvedValue([{ exists: false }]) - store.getState().setPendingDiskBaselineVerification(FILE, true) + store.getState().setPendingDiskBaselineVerification(fileId, true) detach = attachRestoredTabConflictScan(store) await vi.advanceTimersByTimeAsync(0) @@ -102,7 +104,7 @@ it('marks a dirty runtime-owned tab deleted when its host says the file is gone' it('stops retrying and shows the conflict banner when the disk can never be checked', async () => { mocks.readRuntimeFileContent.mockRejectedValue(new Error('host unreachable')) mocks.runtimePathsExist.mockResolvedValue([{ error: 'unverifiable' }]) - store.getState().setPendingDiskBaselineVerification(FILE, true) + store.getState().setPendingDiskBaselineVerification(fileId, true) detach = attachRestoredTabConflictScan(store) await vi.advanceTimersByTimeAsync(10 * 60_000) @@ -117,12 +119,12 @@ it('does not let an absence answer from before a newer check resolve that check' mocks.runtimePathsExist.mockImplementationOnce( () => new Promise((resolve) => (answerProbe = resolve)) ) - store.getState().setPendingDiskBaselineVerification(FILE, true) + store.getState().setPendingDiskBaselineVerification(fileId, true) detach = attachRestoredTabConflictScan(store) await vi.advanceTimersByTimeAsync(0) // The file comes back and a new catch-up asks again while the old probe is pending. - requestRestoredTabReverification(FILE) + requestRestoredTabReverification(fileId) mocks.readRuntimeFileContent.mockResolvedValueOnce({ content: 'v2 recreated', isBinary: false }) answerProbe([{ exists: false }]) await vi.advanceTimersByTimeAsync(0) @@ -134,9 +136,9 @@ it('does not let an absence answer from before a newer check resolve that check' it("forgets a tab's check requests once the tab closes", async () => { mocks.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false }) detach = attachRestoredTabConflictScan(store) - requestRestoredTabReverification(FILE) + requestRestoredTabReverification(fileId) expect(getRestoredTabReverificationRequestCountForTest()).toBeGreaterThan(0) - store.getState().closeFile(FILE) + store.getState().closeFile(fileId) expect(getRestoredTabReverificationRequestCountForTest()).toBe(0) }) diff --git a/src/renderer/src/components/editor/editor-save-disk-precondition.test.ts b/src/renderer/src/components/editor/editor-save-disk-precondition.test.ts index 26c7d0d41740..2a465d05d654 100644 --- a/src/renderer/src/components/editor/editor-save-disk-precondition.test.ts +++ b/src/renderer/src/components/editor/editor-save-disk-precondition.test.ts @@ -519,7 +519,7 @@ describe('editor saves through a remote host without the guarded write (client c 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' })] } }) - store.getState().openFile({ + const fileId = store.getState().openFile({ filePath: '/remote/repo/file.ts', relativePath: 'file.ts', worktreeId: 'wt-1', @@ -527,15 +527,13 @@ describe('editor saves through a remote host without the guarded write (client c language: 'typescript', mode: 'edit' }) - store - .getState() - .setLastKnownDiskSignature('/remote/repo/file.ts', getDiskBaselineSignature('original')) - store.getState().setEditorDraft('/remote/repo/file.ts', 'edited') - store.getState().markFileDirty('/remote/repo/file.ts', true) + store.getState().setLastKnownDiskSignature(fileId, getDiskBaselineSignature('original')) + store.getState().setEditorDraft(fileId, 'edited') + store.getState().markFileDirty(fileId, true) const cleanup = attachEditorAutosaveController(store) try { - const save = requestEditorFileSave({ fileId: '/remote/repo/file.ts' }) + const save = requestEditorFileSave({ fileId }) await (written ? save : expect(save).rejects.toThrow(/changed on disk/)) const methods = runtimeCall.mock.calls.map(([args]) => args.method) expect(mocks.readRuntimeFileContent).toHaveBeenCalled() diff --git a/src/renderer/src/runtime/server-layout-frame-ids.test.ts b/src/renderer/src/runtime/server-layout-frame-ids.test.ts index 42c9e6a56fc7..2b35485dbc4f 100644 --- a/src/renderer/src/runtime/server-layout-frame-ids.test.ts +++ b/src/renderer/src/runtime/server-layout-frame-ids.test.ts @@ -90,7 +90,8 @@ describe('a server layout under desktop ids', () => { expect(desktop.tabs.map((entry) => [entry.id, entry.entityId])).toEqual([ [toWebTerminalSurfaceTabId('tab-1'), toWebTerminalSurfaceTabId('tab-1')], [toWebTerminalSurfaceTabId('term-2'), toWebTerminalSurfaceTabId('term-2')], - ['/srv/app/a.ts', '/srv/app/a.ts'], + // The mirror names every server file by its owned id, as a restart restores it. + ['/srv/app/a.ts', ownedEditorFileId('/srv/app/a.ts', WT, ENV)], ['ed-2', ownedEditorFileId('/srv/app/b.ts', WT, ENV)], ['br-1', 'browser-1'], ['chat-1', 'session-1'] diff --git a/src/renderer/src/runtime/server-layout-frame-ids.ts b/src/renderer/src/runtime/server-layout-frame-ids.ts index 4751782142f9..9735f9a483a0 100644 --- a/src/renderer/src/runtime/server-layout-frame-ids.ts +++ b/src/renderer/src/runtime/server-layout-frame-ids.ts @@ -59,17 +59,18 @@ function tabIdRule(kind: string): 'terminal' | 'kept' { return rule } -/** An editor tab names its file by path, or by the owned id of the server's own (local) file. */ +/** A server editor tab names its file by path or by owned id; the desktop always uses the owned id. */ function desktopEditorEntityId( entityId: string, layout: PublishedWorkspaceLayout, environmentId: string ): string { - const file = layout.editorFiles.find( - (candidate) => - ownedEditorFileId(candidate.filePath, layout.worktreeId, candidate.runtimeEnvironmentId) === - entityId - ) + const file = + layout.editorFiles.find( + (candidate) => + ownedEditorFileId(candidate.filePath, layout.worktreeId, candidate.runtimeEnvironmentId) === + entityId + ) ?? layout.editorFiles.find((candidate) => candidate.filePath === entityId) return file ? ownedEditorFileId(file.filePath, layout.worktreeId, environmentId) : entityId } @@ -161,7 +162,7 @@ export function serverTabIdOf(desktopTabId: string): string { return isWebTerminalSurfaceTabId(desktopTabId) ? toHostSessionTabId(desktopTabId) : desktopTabId } -/** A desktop editor tab's file id as the server knows it: its own file, owned by no environment. */ +/** A desktop editor tab's file id as the server knows it: its own file's owned id, even if the server named it by path. */ function serverEditorEntityId(entityId: string, layout: PublishedWorkspaceLayout): string { const file = layout.editorFiles.find( (candidate) => diff --git a/src/renderer/src/runtime/web-session-tabs-sync-editor-ownership.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-editor-ownership.test.ts index e7b00e7c34ab..fe2db67f5a7d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-editor-ownership.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-editor-ownership.test.ts @@ -272,8 +272,8 @@ describe('host editor file ownership', () => { expect(patch?.openFiles).toHaveLength(1) expect(patch?.unifiedTabsByWorktree?.[WT]).toHaveLength(2) expect(patch?.unifiedTabsByWorktree?.[WT]?.map((tab) => tab.entityId)).toEqual([ - filePath, - filePath + buildOwnedEditorFileId(filePath, WT, ENV), + buildOwnedEditorFileId(filePath, WT, ENV) ]) }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts index e645fe2b5853..5937ac5b14ef 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts @@ -3,6 +3,7 @@ import { posix as pathPosix } from 'node:path' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' import type { Tab } from '../../../shared/tab-types' import type { OpenFile } from '../store/slices/editor' +import { buildOwnedEditorFileId } from '../store/slices/editor/file-ids/editor-file-ids' import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' import type { WebSessionTabsSyncState } from './web-session-tabs-sync/state' import { @@ -16,6 +17,9 @@ import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness' +// Why: a server's file is named by its owned id from the first open, as restore names it. +const README_ID = buildOwnedEditorFileId('/repo/README.md', WT, ENV) + vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() @@ -67,7 +71,7 @@ describe('applyWebSessionTabsSnapshot', () => { const terminalId = patch.tabsByWorktree?.[WT]?.[0]?.id expect(patch.openFiles).toMatchObject([ { - id: '/repo/README.md', + id: README_ID, filePath: '/repo/README.md', relativePath: 'README.md', worktreeId: WT, @@ -81,7 +85,7 @@ describe('applyWebSessionTabsSnapshot', () => { expect.arrayContaining([ expect.objectContaining({ id: 'host-readme-unified', - entityId: '/repo/README.md', + entityId: README_ID, contentType: 'editor', label: 'README.md', color: '#16a34a', @@ -93,8 +97,8 @@ describe('applyWebSessionTabsSnapshot', () => { activeTabId: 'host-readme-unified', tabOrder: [terminalId, 'host-readme-unified'] }) - expect(patch.activeFileId).toBe('/repo/README.md') - expect(patch.activeFileIdByWorktree?.[WT]).toBe('/repo/README.md') + expect(patch.activeFileId).toBe(README_ID) + expect(patch.activeFileIdByWorktree?.[WT]).toBe(README_ID) expect(patch.activeTabType).toBe('editor') expect(patch.activeTabTypeByWorktree?.[WT]).toBe('editor') }) @@ -279,20 +283,20 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.openFiles).toMatchObject([ { - id: 'markdown-preview::/repo/README.md', + id: `markdown-preview::${README_ID}`, filePath: '/repo/README.md', - markdownPreviewSourceFileId: '/repo/README.md', + markdownPreviewSourceFileId: README_ID, mode: 'markdown-preview' } ]) expect(patch.unifiedTabsByWorktree?.[WT]).toMatchObject([ { id: 'host-preview-unified', - entityId: 'markdown-preview::/repo/README.md', + entityId: `markdown-preview::${README_ID}`, contentType: 'editor' } ]) - expect(patch.activeFileId).toBe('markdown-preview::/repo/README.md') + expect(patch.activeFileId).toBe(`markdown-preview::${README_ID}`) }) it('removes mirrored editor tabs when the host closes the file', () => { @@ -324,12 +328,12 @@ describe('applyWebSessionTabsSnapshot', () => { const hydratedState = { ...makeState(), ...hydratedPatch } as WebSessionTabsSyncState expect(hydratedState.openFiles[0]).toMatchObject({ - id: '/repo/README.md', + id: README_ID, mirroredFromRuntimeSession: true }) expect(hydratedState.unifiedTabsByWorktree[WT]?.[0]).toMatchObject({ id: 'host-readme-unified', - entityId: '/repo/README.md' + entityId: README_ID }) const patch = applyWebSessionTabsSnapshot( diff --git a/src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts index efdd5e3c90a7..3f47fa20da30 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts @@ -3,6 +3,7 @@ import { posix as pathPosix } from 'node:path' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { OpenFile } from '../store/slices/editor' +import { buildOwnedEditorFileId } from '../store/slices/editor/file-ids/editor-file-ids' import { applyWebSessionTabsSnapshot, applyWebSessionTabsSnapshots @@ -337,7 +338,8 @@ describe('applyWebSessionTabsSnapshot', () => { '/repo/a.ts', '/repo/bystander-2.ts', '/repo/a.ts', - '/repo/b.ts' + // A file the host opens now takes its owned id; a.ts keeps the id it already had. + buildOwnedEditorFileId('/repo/b.ts', WT, ENV) ]) expect( batched.openFiles.find( @@ -511,7 +513,9 @@ describe('applyWebSessionTabsSnapshot', () => { const batched = { ...state, ...applyWebSessionTabsSnapshots(state, snapshots, ENV, NOW) } expect(batched.openFiles).toEqual(sequential.openFiles) - expect(batched.openFiles.map((file) => file.id)).toEqual(['/repo/a.ts']) + expect(batched.openFiles.map((file) => file.id)).toEqual([ + buildOwnedEditorFileId('/repo/a.ts', WT, ENV) + ]) expect(batched.openFiles[0]?.lastKnownDiskSignature).toBeUndefined() }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx index 19b1ae5ea012..79be5c15a77c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx @@ -37,6 +37,7 @@ vi.mock('./web-runtime-terminal-creation', async (importOriginal) => { }) import { useAppStore } from '@/store' +import { buildOwnedEditorFileId } from '@/store/slices/editor/file-ids/editor-file-ids' import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' import type { AppState } from '@/store/types' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' @@ -391,7 +392,7 @@ describe('useWebSessionTabsSync visibility collision recovery', () => { }) const state = useAppStore.getState() - const fileId = '/repo-b/README.md' + const fileId = buildOwnedEditorFileId('/repo-b/README.md', WORKTREE, ENV_B) expect( state.openFiles .filter((file) => file.worktreeId === WORKTREE) diff --git a/src/renderer/src/store/slices/editor-close-file-cleanup.test.ts b/src/renderer/src/store/slices/editor-close-file-cleanup.test.ts index 3c35b1204e2b..8d38813dae4a 100644 --- a/src/renderer/src/store/slices/editor-close-file-cleanup.test.ts +++ b/src/renderer/src/store/slices/editor-close-file-cleanup.test.ts @@ -123,7 +123,7 @@ describe('createEditorSlice untitled cleanup routing', () => { it('closeFile deletes untouched remote untitled files through runtime file RPC', async () => { const store = createEditorStore() seedRemoteWorktree(store) - store.getState().openFile({ + const fileId = store.getState().openFile({ filePath: '/remote/wt/untitled.md', relativePath: 'untitled.md', worktreeId: 'wt-1', @@ -132,7 +132,7 @@ describe('createEditorSlice untitled cleanup routing', () => { mode: 'edit' }) - store.getState().closeFile('/remote/wt/untitled.md') + store.getState().closeFile(fileId) await vi.waitFor(() => { expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ @@ -186,7 +186,7 @@ describe('createEditorSlice untitled cleanup routing', () => { remoteUntitledFileSize = 42 const store = createEditorStore() seedRemoteWorktree(store) - store.getState().openFile({ + const fileId = store.getState().openFile({ filePath: '/remote/wt/untitled.md', relativePath: 'untitled.md', worktreeId: 'wt-1', @@ -195,7 +195,7 @@ describe('createEditorSlice untitled cleanup routing', () => { mode: 'edit' }) - store.getState().closeFile('/remote/wt/untitled.md') + store.getState().closeFile(fileId) await vi.waitFor(() => expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith( @@ -217,7 +217,7 @@ describe('createEditorSlice untitled cleanup routing', () => { repos: [], worktreesByRepo: {} } as Partial) - store.getState().openFile({ + const fileId = store.getState().openFile({ filePath: '/remote/wt/untitled.md', relativePath: 'untitled.md', worktreeId: 'wt-1', @@ -226,7 +226,7 @@ describe('createEditorSlice untitled cleanup routing', () => { mode: 'edit' }) - store.getState().closeFile('/remote/wt/untitled.md') + store.getState().closeFile(fileId) await flushAsyncRemoteRefresh() @@ -237,7 +237,7 @@ describe('createEditorSlice untitled cleanup routing', () => { it('closeFile deletes untouched remote untitled files in their owning runtime after switching local', async () => { const store = createEditorStore() seedRemoteWorktree(store) - store.getState().openFile({ + const fileId = store.getState().openFile({ filePath: '/remote/wt/untitled.md', relativePath: 'untitled.md', worktreeId: 'wt-1', @@ -247,7 +247,7 @@ describe('createEditorSlice untitled cleanup routing', () => { }) store.setState({ settings: { activeRuntimeEnvironmentId: null } as never }) - store.getState().closeFile('/remote/wt/untitled.md') + store.getState().closeFile(fileId) await vi.waitFor(() => { expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ @@ -269,7 +269,7 @@ describe('createEditorSlice untitled cleanup routing', () => { it('closeFile deletes untouched remote untitled files in their owning runtime after switching environments', async () => { const store = createEditorStore() seedRemoteWorktree(store) - store.getState().openFile({ + const fileId = store.getState().openFile({ filePath: '/remote/wt/untitled.md', relativePath: 'untitled.md', worktreeId: 'wt-1', @@ -279,7 +279,7 @@ describe('createEditorSlice untitled cleanup routing', () => { }) store.setState({ settings: { activeRuntimeEnvironmentId: 'env-2' } as never }) - store.getState().closeFile('/remote/wt/untitled.md') + store.getState().closeFile(fileId) await vi.waitFor(() => { expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ @@ -301,7 +301,7 @@ describe('createEditorSlice untitled cleanup routing', () => { it('closeFile keeps untouched templated untitled files on disk', async () => { const store = createEditorStore() seedRemoteWorktree(store) - store.getState().openFile({ + const fileId = store.getState().openFile({ filePath: '/remote/wt/untitled.md', relativePath: 'untitled.md', worktreeId: 'wt-1', @@ -311,7 +311,7 @@ describe('createEditorSlice untitled cleanup routing', () => { mode: 'edit' }) - store.getState().closeFile('/remote/wt/untitled.md') + store.getState().closeFile(fileId) await flushAsyncRemoteRefresh() expect(runtimeEnvironmentCallMock).not.toHaveBeenCalled() diff --git a/src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts b/src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts index 1a6168bd5c84..bd16d2c0147a 100644 --- a/src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts +++ b/src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts @@ -41,8 +41,12 @@ function withCountedEntityIdReads(tabs: readonly Tab[], onRead: () => void): Tab } describe('createEditorSlice recently closed editor tabs', () => { - function openMirroredEditor(store: StoreApi, filePath: string, preview = false): void { - store.getState().openFile( + function openMirroredEditor( + store: StoreApi, + filePath: string, + preview = false + ): string { + return store.getState().openFile( { filePath, relativePath: filePath.replace('/repo/', ''), @@ -58,9 +62,9 @@ describe('createEditorSlice recently closed editor tabs', () => { it('reopens a closed mirrored editor tab as a local tab', () => { const store = createEditorStore() - openMirroredEditor(store, '/repo/notes.md') + const fileId = openMirroredEditor(store, '/repo/notes.md') - store.getState().closeFile('/repo/notes.md') + store.getState().closeFile(fileId) const recent = store.getState().recentlyClosedEditorTabsByWorktree['wt-1']?.[0] expect(recent).toMatchObject({ filePath: '/repo/notes.md' }) diff --git a/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts b/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts index a16b94319a21..3bcaaf0bd270 100644 --- a/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts +++ b/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts @@ -145,6 +145,10 @@ export function resolveEditorFileIdForOwner( if (existing) { return existing.id } + // Why: restore always names a server's file by its owned id; minting it on first open avoids a rename (and remount) at restart. + if (runtimeOwnerKey(runtimeEnvironmentId) !== null) { + return buildOwnedEditorFileId(filePath, worktreeId, runtimeEnvironmentId) + } // Why: every frame restores floating files under their owned id; minting it here keeps main's tab on the same id. if (ORCA_LAYOUT_OWNER_IS_MAIN && worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { return buildOwnedEditorFileId(filePath, worktreeId, runtimeEnvironmentId) diff --git a/src/renderer/src/store/slices/server-editor-file-restart-identity.test.ts b/src/renderer/src/store/slices/server-editor-file-restart-identity.test.ts new file mode 100644 index 000000000000..e8de28a71743 --- /dev/null +++ b/src/renderer/src/store/slices/server-editor-file-restart-identity.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { RuntimeMobileSessionFileTab } from '../../../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { buildWorkspaceSessionPayload } from '../../lib/workspace-session' +import { createMirroredEditorFileResolver } from '../../runtime/web-session-tabs-sync/mirrored-editor-file-identity' +import { buildHydratedEditorSessionState } from './editor/actions/hydrated-editor-session-state' +import { buildOwnedEditorFileId } from './editor/file-ids/editor-file-ids' +import type { OpenFile } from './editor/types/open-file' +import { createStoreSessionMockApi } from './store-session-test-harness' +import { createTestStore, makeWorktree } from './store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +createStoreSessionMockApi() + +const ENV = 'env-1' +const WT_A = 'repo-1::/srv/a' +const WT_B = 'repo-1::/srv/b' +const FILE_PATH = '/srv/shared/notes.ts' + +function prepareStore() { + const store = createTestStore() + store.setState({ + repos: [{ id: 'repo-1', path: '/srv/a', displayName: 'Repo', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { + 'repo-1': [ + makeWorktree({ id: WT_A, repoId: 'repo-1', path: '/srv/a' }), + makeWorktree({ id: WT_B, repoId: 'repo-1', path: '/srv/b' }) + ] + }, + activeWorktreeId: WT_A + }) + return store +} + +type TestStore = ReturnType + +function openServerFile(store: TestStore, worktreeId: string, runtimeEnvironmentId?: string) { + return store.getState().openFile({ + filePath: FILE_PATH, + relativePath: 'notes.ts', + worktreeId, + language: 'typescript', + mode: 'edit', + ...(runtimeEnvironmentId ? { runtimeEnvironmentId } : {}) + }) +} + +function restart(store: TestStore, session = buildWorkspaceSessionPayload(store.getState())) { + const restarted = prepareStore() + restarted.getState().hydrateTabsSession(session) + restarted.getState().hydrateEditorSession(session) + return restarted.getState() +} + +function editorEntityIds(state: ReturnType, worktreeId: string) { + return (state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => tab.entityId) +} + +describe('a server file keeps its id across a restart', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('names a server file by its owned id on first open, and a restart keeps that id', () => { + const store = prepareStore() + const fileId = openServerFile(store, WT_A, ENV) + expect(fileId).toBe(buildOwnedEditorFileId(FILE_PATH, WT_A, ENV)) + const tabIds = (store.getState().unifiedTabsByWorktree[WT_A] ?? []).map((tab) => tab.id) + + const session = buildWorkspaceSessionPayload(store.getState()) + // Restore renames nothing the saved tabs name, so no tab remounts. + const restored = buildHydratedEditorSessionState(session, new Set([WT_A, WT_B]), WT_A) + expect(restored.state.openFiles.map((file) => file.id)).toEqual([fileId]) + expect(restored.editorFileIdMigrationsByWorktree[WT_A]?.has(fileId)).not.toBe(true) + + const restarted = restart(store, session) + expect(restarted.openFiles.map((file) => file.id)).toEqual([fileId]) + expect(editorEntityIds(restarted, WT_A)).toEqual([fileId]) + expect((restarted.unifiedTabsByWorktree[WT_A] ?? []).map((tab) => tab.id)).toEqual(tabIds) + expect(restarted.activeFileIdByWorktree[WT_A]).toBe(fileId) + }) + + it('names a file the live mirror opens the way a restart restores it', () => { + const hostTab: RuntimeMobileSessionFileTab = { + type: 'file', + id: 'host-tab-1', + title: 'notes.ts', + filePath: FILE_PATH, + relativePath: 'notes.ts', + language: 'typescript', + isDirty: false, + isActive: true + } + const { fileId } = createMirroredEditorFileResolver([], WT_A, ENV)(hostTab, null) + expect(fileId).toBe(buildOwnedEditorFileId(FILE_PATH, WT_A, ENV)) + + const mirrored: OpenFile = { + id: fileId, + filePath: FILE_PATH, + relativePath: 'notes.ts', + worktreeId: WT_A, + language: 'typescript', + isDirty: false, + runtimeEnvironmentId: ENV, + mode: 'edit', + mirroredFromRuntimeSession: true + } + const session = buildWorkspaceSessionPayload({ + ...prepareStore().getState(), + openFiles: [mirrored] + }) + const restored = buildHydratedEditorSessionState(session, new Set([WT_A]), WT_A) + expect(restored.state.openFiles.map((file) => file.id)).toEqual([fileId]) + expect(restored.editorFileIdMigrationsByWorktree[WT_A]?.has(fileId)).not.toBe(true) + }) + + it('keeps an unsaved draft of a server file through a restart', () => { + const store = prepareStore() + const fileId = openServerFile(store, WT_A, ENV) + store.getState().setEditorDraft(fileId, 'unsaved edit') + store.getState().markFileDirty(fileId, true) + + const restarted = restart(store) + expect(restarted.editorDrafts[fileId]).toBe('unsaved edit') + expect(restarted.openFiles.find((file) => file.id === fileId)?.isDirty).toBe(true) + }) + + it('still names a local file by its path, also after a server file with the same path', () => { + const store = prepareStore() + const serverId = openServerFile(store, WT_A, ENV) + const localId = openServerFile(store, WT_A) + expect(localId).toBe(FILE_PATH) + expect(serverId).not.toBe(localId) + + const restarted = restart(store) + expect(restarted.openFiles.map((file) => file.id).sort()).toEqual([localId, serverId].sort()) + }) + + it('keeps the same server path in two workspaces apart, before and after a restart', () => { + const store = prepareStore() + const idA = openServerFile(store, WT_A, ENV) + const idB = openServerFile(store, WT_B, ENV) + expect(idA).toBe(buildOwnedEditorFileId(FILE_PATH, WT_A, ENV)) + expect(idB).toBe(buildOwnedEditorFileId(FILE_PATH, WT_B, ENV)) + store.getState().setEditorDraft(idA, 'draft a') + store.getState().markFileDirty(idA, true) + store.getState().setEditorDraft(idB, 'draft b') + store.getState().markFileDirty(idB, true) + + const restarted = restart(store) + expect(editorEntityIds(restarted, WT_A)).toEqual([idA]) + expect(editorEntityIds(restarted, WT_B)).toEqual([idB]) + expect(restarted.editorDrafts[idA]).toBe('draft a') + expect(restarted.editorDrafts[idB]).toBe('draft b') + }) + + it('still moves an older session that saved a server file under its path', () => { + const ownedId = buildOwnedEditorFileId(FILE_PATH, WT_A, ENV) + const session: WorkspaceSessionState = { + activeRepoId: 'repo-1', + activeWorktreeId: WT_A, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { + [WT_A]: [ + { + filePath: FILE_PATH, + relativePath: 'notes.ts', + worktreeId: WT_A, + language: 'typescript', + runtimeEnvironmentId: ENV, + dirtyDraftContent: 'old draft' + } + ] + }, + activeFileIdByWorktree: { [WT_A]: FILE_PATH }, + activeTabTypeByWorktree: { [WT_A]: 'editor' }, + unifiedTabs: { + [WT_A]: [ + { + id: 'tab-1', + entityId: FILE_PATH, + groupId: 'group-1', + worktreeId: WT_A, + contentType: 'editor', + label: 'notes.ts', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + tabGroups: { + [WT_A]: [{ id: 'group-1', worktreeId: WT_A, activeTabId: 'tab-1', tabOrder: ['tab-1'] }] + }, + activeGroupIdByWorktree: { [WT_A]: 'group-1' } + } + + const restarted = restart(prepareStore(), session) + expect(restarted.openFiles.map((file) => file.id)).toEqual([ownedId]) + expect(editorEntityIds(restarted, WT_A)).toEqual([ownedId]) + expect(restarted.activeFileIdByWorktree[WT_A]).toBe(ownedId) + expect(restarted.editorDrafts[ownedId]).toBe('old draft') + }) +}) From 9714e794f0c558358b0e7cf18f52826d34cd3559 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 20 Oct 2026 15:32:44 -0400 Subject: [PATCH 2/6] refactor(mirror): a server file's mirror id no longer needs the bare-path collision lookup --- .../src/runtime/server-layout-frame-ids.ts | 7 ++++--- .../mirrored-editor-file-identity.ts | 21 ++++--------------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/runtime/server-layout-frame-ids.ts b/src/renderer/src/runtime/server-layout-frame-ids.ts index 9735f9a483a0..9998c7c6b222 100644 --- a/src/renderer/src/runtime/server-layout-frame-ids.ts +++ b/src/renderer/src/runtime/server-layout-frame-ids.ts @@ -162,7 +162,7 @@ export function serverTabIdOf(desktopTabId: string): string { return isWebTerminalSurfaceTabId(desktopTabId) ? toHostSessionTabId(desktopTabId) : desktopTabId } -/** A desktop editor tab's file id as the server knows it: its own file's owned id, even if the server named it by path. */ +/** A desktop editor tab's file id as the server knows it: its own file, owned by no environment. */ function serverEditorEntityId(entityId: string, layout: PublishedWorkspaceLayout): string { const file = layout.editorFiles.find( (candidate) => @@ -173,8 +173,9 @@ function serverEditorEntityId(entityId: string, layout: PublishedWorkspaceLayout } /** - * A desktop-shaped workspace under the server's ids: the inverse of `serverLayoutForDesktop` for - * every id it maps. Panes keep no terminal: the desktop binds handles, which the server never takes. + * A desktop-shaped workspace under the server's ids: the inverse of `serverLayoutForDesktop`, except + * that an editor tab the server named by path comes back under the server's owned id for that file. + * Panes keep no terminal: the desktop binds handles, which the server never takes. */ export function desktopLayoutForServer(layout: PublishedWorkspaceLayout): PublishedWorkspaceLayout { return { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-editor-file-identity.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-editor-file-identity.ts index 9c9bd142f61e..27ff5d6e2781 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-editor-file-identity.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-editor-file-identity.ts @@ -1,7 +1,7 @@ import type { Tab } from '../../../../shared/tab-types' import type { OpenFile } from '../../store/slices/editor' import { - resolveEditorFileIdForOwner, + buildOwnedEditorFileId, runtimeOwnerKey } from '../../store/slices/editor/file-ids/editor-file-ids' import type { ReadyEditorSurface } from './state' @@ -9,7 +9,6 @@ import type { ReadyEditorSurface } from './state' type FileIndex = { byId: Map byOwner: Map - reservedSourceIds: Map } const indexes = new WeakMap() @@ -27,7 +26,7 @@ function fileIndex(files: readonly OpenFile[]): FileIndex { if (cached) { return cached } - const index: FileIndex = { byId: new Map(), byOwner: new Map(), reservedSourceIds: new Map() } + const index: FileIndex = { byId: new Map(), byOwner: new Map() } for (const file of files) { if (!index.byId.has(file.id)) { index.byId.set(file.id, file) @@ -36,12 +35,6 @@ function fileIndex(files: readonly OpenFile[]): FileIndex { if (!index.byOwner.has(key)) { index.byOwner.set(key, file) } - if ( - file.markdownPreviewSourceFileId && - !index.reservedSourceIds.has(file.markdownPreviewSourceFileId) - ) { - index.reservedSourceIds.set(file.markdownPreviewSourceFileId, file) - } } indexes.set(files, index) return index @@ -65,14 +58,8 @@ export function createMirroredEditorFileResolver( if (previewSourceId) { return previewSourceId } - const reserved = index.byId.get(filePath) ?? index.reservedSourceIds.get(filePath) - return resolveEditorFileIdForOwner( - { openFiles: reserved ? [reserved] : [] }, - filePath, - worktreeId, - environmentId, - ['edit'] - ) + // A server's file never takes its bare path, so no other owner's id can collide with it. + return buildOwnedEditorFileId(filePath, worktreeId, environmentId) } return (tab: ReadyEditorSurface, existingTab: Tab | null) => { const mode = tab.type === 'markdown' ? tab.mode : 'edit' From aec012bb3a9f2b9792c9b9f81cb659b72b326f03 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 20 Oct 2026 15:47:24 -0400 Subject: [PATCH 3/6] fix(editor): conflict review reuses an open server file under its owned id instead of opening a second copy --- .../runtime/server-layout-frame-ids.test.ts | 20 ++++ .../src/runtime/server-layout-frame-ids.ts | 10 +- .../editor-git-status-reconciliation.test.ts | 111 ++++++++++++++++++ .../editor/actions/open-conflict-review.ts | 39 ++++-- 4 files changed, 170 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/runtime/server-layout-frame-ids.test.ts b/src/renderer/src/runtime/server-layout-frame-ids.test.ts index 2b35485dbc4f..b475ecb3aa31 100644 --- a/src/renderer/src/runtime/server-layout-frame-ids.test.ts +++ b/src/renderer/src/runtime/server-layout-frame-ids.test.ts @@ -116,6 +116,26 @@ describe('a server layout under desktop ids', () => { expect(desktop.browserTabs).toEqual(serverLayout().browserTabs) }) + it('names a bare-path tab by its path when the server holds that path under two owners', () => { + const layout = serverLayout() + const both: PublishedWorkspaceLayout = { + ...layout, + editorFiles: [ + { + filePath: '/srv/app/a.ts', + relativePath: 'a.ts', + language: 'typescript', + runtimeEnvironmentId: 'other-env' + }, + ...layout.editorFiles + ] + } + const mapped = serverLayoutForDesktop(both, ENV, handles) + expect(mapped.tabs.find((entry) => entry.id === '/srv/app/a.ts')?.entityId).toBe( + ownedEditorFileId('/srv/app/a.ts', WT, ENV) + ) + }) + it('binds a pane to its session.tabs handle, and a bound pane without one stays pending', () => { const panes = desktop.tabs[0]!.terminal!.panes expect(panes[0]).toEqual({ diff --git a/src/renderer/src/runtime/server-layout-frame-ids.ts b/src/renderer/src/runtime/server-layout-frame-ids.ts index 9998c7c6b222..a2a411a19cff 100644 --- a/src/renderer/src/runtime/server-layout-frame-ids.ts +++ b/src/renderer/src/runtime/server-layout-frame-ids.ts @@ -65,13 +65,17 @@ function desktopEditorEntityId( layout: PublishedWorkspaceLayout, environmentId: string ): string { - const file = + const filePath = layout.editorFiles.find( (candidate) => ownedEditorFileId(candidate.filePath, layout.worktreeId, candidate.runtimeEnvironmentId) === entityId - ) ?? layout.editorFiles.find((candidate) => candidate.filePath === entityId) - return file ? ownedEditorFileId(file.filePath, layout.worktreeId, environmentId) : entityId + )?.filePath ?? + // Why only the path: every desktop record of a server file is owned by that server, whichever server record a bare path names. + (layout.editorFiles.some((candidate) => candidate.filePath === entityId) ? entityId : null) + return filePath === null + ? entityId + : ownedEditorFileId(filePath, layout.worktreeId, environmentId) } /** The handle session.tabs gives a server terminal tab's pane; null while it has none. */ diff --git a/src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts b/src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts index 72d071e75412..ab6db100e088 100644 --- a/src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts +++ b/src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { createEditorStore } from './editor-slice-test-harness' const { toastErrorMock } = vi.hoisted(() => ({ @@ -502,6 +503,116 @@ describe('createEditorSlice conflict status reconciliation', () => { ) }) + describe('selecting a conflict whose file is already open', () => { + const conflictEntry = { + path: 'src/conflict.ts', + status: 'modified', + area: 'unstaged', + conflictKind: 'both_modified', + conflictStatus: 'unresolved', + conflictStatusSource: 'git' + } as const + const SERVER_WORKSPACE = folderWorkspaceKey('server-folder') + const FILE = '/repo/src/conflict.ts' + + /** A folder workspace on paired server env-1, or a plain local worktree. */ + function storeFor(onServer: boolean) { + const store = createEditorStore() + if (onServer) { + store.setState({ + folderWorkspaces: [ + { + id: 'server-folder', + projectGroupId: 'server-group', + folderPath: '/repo', + connectionId: null + } as never + ], + projectGroups: [ + { id: 'server-group', connectionId: null, executionHostId: 'runtime:env-1' } as never + ] + }) + } + return { store, worktreeId: onServer ? SERVER_WORKSPACE : 'wt-1' } + } + + function selectConflict(store: ReturnType, worktreeId: string) { + store + .getState() + .openConflictReview( + worktreeId, + '/repo', + [{ path: 'src/conflict.ts', conflictKind: 'both_modified' }], + 'live-summary' + ) + store + .getState() + .openConflictReviewFile( + `${worktreeId}::conflict-review`, + worktreeId, + '/repo', + conflictEntry, + 'typescript' + ) + } + + function openConflictFile(store: ReturnType, worktreeId: string) { + return store.getState().openFile({ + filePath: FILE, + relativePath: 'src/conflict.ts', + worktreeId, + language: 'typescript', + mode: 'edit' + }) + } + + const copiesOf = (store: ReturnType) => + store + .getState() + .openFiles.filter((file) => file.filePath === FILE) + .map((file) => [file.id, file.runtimeEnvironmentId ?? null]) + + it('reuses an open server file instead of opening a second copy', () => { + const { store, worktreeId } = storeFor(true) + const fileId = openConflictFile(store, worktreeId) + expect(fileId).not.toBe(FILE) + + selectConflict(store, worktreeId) + + expect(copiesOf(store)).toEqual([[fileId, 'env-1']]) + const state = store.getState() + expect(state.openFiles.find((file) => file.id === fileId)?.conflict).toMatchObject({ + conflictStatus: 'unresolved' + }) + expect( + state.openFiles.find((file) => file.id === `${worktreeId}::conflict-review`)?.conflictReview + ?.selectedFileId + ).toBe(fileId) + }) + + it('names a server file it opens itself so a later open reuses it', () => { + const { store, worktreeId } = storeFor(true) + selectConflict(store, worktreeId) + const fileId = openConflictFile(store, worktreeId) + + expect(copiesOf(store)).toEqual([[fileId, 'env-1']]) + }) + + it('still reuses an open local file under its path', () => { + const { store, worktreeId } = storeFor(false) + const fileId = openConflictFile(store, worktreeId) + expect(fileId).toBe(FILE) + + selectConflict(store, worktreeId) + + expect(copiesOf(store)).toEqual([[FILE, null]]) + expect( + store.getState().openFiles.find((file) => file.id === 'wt-1::conflict-review') + ?.conflictReview?.selectedFileId + ).toBe(FILE) + }) + }) + it('marks tracked conflicts as resolved locally after live conflict state disappears', () => { const store = createEditorStore() diff --git a/src/renderer/src/store/slices/editor/actions/open-conflict-review.ts b/src/renderer/src/store/slices/editor/actions/open-conflict-review.ts index d87480687341..e2a1463bcd14 100644 --- a/src/renderer/src/store/slices/editor/actions/open-conflict-review.ts +++ b/src/renderer/src/store/slices/editor/actions/open-conflict-review.ts @@ -5,6 +5,19 @@ import type { OpenFile } from '../types/open-file' import type { ConflictReviewState } from '../../../../../../shared/editor-tab-view-types' import { toOpenConflictMetadata } from '../git/git-status-reconciliation' import { openWorkspaceEditorItem } from '../tabs/workspace-editor-item' +import { resolveEditorFileIdForOwner, runtimeOwnerKey } from '../file-ids/editor-file-ids' +import { captureEditorFileOperationProvenance } from '@/lib/editor-file-operation-owner' +import type { AppState } from '../../../types' + +/** The runtime owner `openFile` would give a file opened in this workspace. */ +function conflictFileRuntimeOwner(s: AppState, worktreeId: string): string | null { + try { + return captureEditorFileOperationProvenance(s, worktreeId, undefined, false).generation.route + .runtimeEnvironmentId + } catch { + return runtimeOwnerKey(s.settings?.activeRuntimeEnvironmentId) + } +} export function createOpenConflictReview( set: EditorSet, @@ -17,9 +30,20 @@ export function createOpenConflictReview( (tab) => tab.entityId === reviewFileId && tab.contentType === 'conflict-review' ) let openedConflictFile = true + let conflictFileId = absolutePath set((s) => { const conflict = toOpenConflictMetadata(entry) - const existing = s.openFiles.find((f) => f.id === absolutePath) + const runtimeEnvironmentId = conflictFileRuntimeOwner(s, worktreeId) + // Why: a server's file is open under its owned id; looking up the bare path would open a second copy. + const fileId = resolveEditorFileIdForOwner( + s, + absolutePath, + worktreeId, + runtimeEnvironmentId, + ['edit'] + ) + conflictFileId = fileId + const existing = s.openFiles.find((f) => f.id === fileId) const nextTracked = entry.conflictStatus === 'unresolved' && entry.conflictKind ? { @@ -35,7 +59,7 @@ export function createOpenConflictReview( const nextOpenFiles = existing ? s.openFiles.map((f) => - f.id === absolutePath + f.id === fileId ? { ...f, mode: 'edit' as const, @@ -52,7 +76,7 @@ export function createOpenConflictReview( ...f, conflictReview: { ...f.conflictReview, - selectedFileId: absolutePath + selectedFileId: fileId } } : f @@ -64,20 +88,21 @@ export function createOpenConflictReview( ...f, conflictReview: { ...f.conflictReview, - selectedFileId: absolutePath + selectedFileId: fileId } } : f ), { - id: absolutePath, + id: fileId, filePath: absolutePath, relativePath: entry.path, worktreeId, language, isDirty: false, mode: 'edit' as const, - conflict + conflict, + ...(runtimeEnvironmentId ? { runtimeEnvironmentId } : {}) } ] @@ -101,7 +126,7 @@ export function createOpenConflictReview( // Why: the conflict file needs a normal editor backing tab for save/close, but selecting from Conflict Review must keep the review tab visible; restore focus after. void openWorkspaceEditorItem( get(), - absolutePath, + conflictFileId, worktreeId, entry.path, 'editor', From 725deb54ba39f2cf477d2017ea0ed03fcb293ff3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 20 Oct 2026 15:53:14 -0400 Subject: [PATCH 4/6] fix(editor): conflict review overview shows an open server file's unsaved edits under its owned id --- .../editor/EditorConflictReviewSurface.tsx | 3 +- .../editor-panel-draft-selector.test.ts | 41 ++++++++++++++--- .../editor/editor-panel-draft-selector.ts | 45 ++++++++++++------- .../useEditorPanelActiveTabContentLoad.ts | 8 +++- .../editor/useEditorPanelContentState.ts | 1 + .../slices/editor/file-ids/editor-file-ids.ts | 13 ++++++ 6 files changed, 84 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx b/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx index fbff9caf22e1..26ac94af6896 100644 --- a/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx +++ b/src/renderer/src/components/editor/EditorConflictReviewSurface.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { conflictOverviewFileId } from '@/store/slices/editor/file-ids/editor-file-ids' import { detectLanguage } from '@/lib/language-detect' import { joinPath } from '@/lib/path' import { useAppStore } from '@/store' @@ -82,7 +83,7 @@ export function EditorConflictReviewSurface({ : undefined return { - id: absolutePath, + id: conflictOverviewFileId(openFiles, activeFile.worktreeId, absolutePath), filePath: absolutePath, relativePath: entry.path, worktreeId: activeFile.worktreeId, diff --git a/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts b/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts index d8b4747a9b55..96d9f0fdf7de 100644 --- a/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts +++ b/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts @@ -23,7 +23,9 @@ describe('createEditorPanelDraftSelector', () => { let scopedInvalidations = 0 let unrelatedPanelInvalidations = 0 const selectors = files.map(createEditorPanelDraftSelector) - const previousSelections = selectors.map((selector) => selector({ editorDrafts })) + const previousSelections = selectors.map((selector) => + selector({ editorDrafts, openFiles: [] }) + ) for (let edit = 0; edit < 200; edit += 1) { const previousDrafts = editorDrafts @@ -32,7 +34,7 @@ describe('createEditorPanelDraftSelector', () => { if (previousDrafts !== editorDrafts) { wholeMapInvalidations += 1 } - const nextSelection = selectors[panelIndex]({ editorDrafts }) + const nextSelection = selectors[panelIndex]({ editorDrafts, openFiles: [] }) if (previousSelections[panelIndex] !== nextSelection) { scopedInvalidations += 1 if (panelIndex !== 0) { @@ -65,15 +67,15 @@ describe('createEditorPanelDraftSelector', () => { const selectConflictDrafts = createEditorPanelDraftSelector(conflictReview) const selectNoDrafts = createEditorPanelDraftSelector(null) - expect(selectPreviewDrafts({ editorDrafts })).toEqual({ + expect(selectPreviewDrafts({ editorDrafts, openFiles: [] })).toEqual({ preview: 'preview draft', source: '' }) - expect(selectConflictDrafts({ editorDrafts })).toEqual({ + expect(selectConflictDrafts({ editorDrafts, openFiles: [] })).toEqual({ review: 'review draft', selected: 'selected draft' }) - expect(selectNoDrafts({ editorDrafts })).toEqual({}) + expect(selectNoDrafts({ editorDrafts, openFiles: [] })).toEqual({}) }) it('includes overview conflict drafts and ignores unrelated draft replacements', () => { @@ -96,14 +98,39 @@ describe('createEditorPanelDraftSelector', () => { unrelated: 'other draft' } - const selection = selectDrafts({ editorDrafts }) + const selection = selectDrafts({ editorDrafts, openFiles: [] }) expect(selection).toEqual({ 'C:\\repo\\src\\a.ts': 'draft a', 'C:\\repo\\src\\b.ts': '' }) expect( - selectDrafts({ editorDrafts: { ...editorDrafts, unrelated: 'changed elsewhere' } }) + selectDrafts({ + editorDrafts: { ...editorDrafts, unrelated: 'changed elsewhere' }, + openFiles: [] + }) ).toBe(selection) }) + + it("shows an overview file open in a server tab under that tab's id, with its unsaved edits", () => { + const conflictReview = makeFile('review', { + filePath: '/repo', + mode: 'conflict-review', + conflictReview: { + source: 'live-summary', + snapshotTimestamp: 1, + entries: [{ path: 'src/a.ts', conflictKind: 'both_modified' }] + } + }) + const serverFile = makeFile('editor:worktree-1:env-1:%2Frepo%2Fsrc%2Fa.ts', { + filePath: '/repo/src/a.ts', + relativePath: 'src/a.ts', + runtimeEnvironmentId: 'env-1' + }) + const selectDrafts = createEditorPanelDraftSelector(conflictReview) + + expect( + selectDrafts({ editorDrafts: { [serverFile.id]: 'unsaved' }, openFiles: [serverFile] }) + ).toEqual({ [serverFile.id]: 'unsaved' }) + }) }) diff --git a/src/renderer/src/components/editor/editor-panel-draft-selector.ts b/src/renderer/src/components/editor/editor-panel-draft-selector.ts index b455af4e56fe..f298e29351d5 100644 --- a/src/renderer/src/components/editor/editor-panel-draft-selector.ts +++ b/src/renderer/src/components/editor/editor-panel-draft-selector.ts @@ -1,8 +1,9 @@ import type { AppState } from '@/store' import { joinPath } from '@/lib/path' import type { OpenFile } from '@/store/slices/editor' +import { conflictOverviewFileId } from '@/store/slices/editor/file-ids/editor-file-ids' -type EditorDraftState = Pick +type EditorDraftState = Pick type EditorPanelDraftSelector = (state: EditorDraftState) => Record const EMPTY_EDITOR_PANEL_DRAFTS = Object.freeze({}) as Record @@ -13,21 +14,30 @@ export function createEditorPanelDraftSelector( // Why: previews and conflict review can render a related file, but drafts // from every other panel must not wake this editor on each keystroke. const fileIds = activeFile - ? Array.from( - new Set( - [ - activeFile.id, - activeFile.markdownPreviewSourceFileId, - activeFile.conflictReview?.selectedFileId, - ...(activeFile.mode === 'conflict-review' && !activeFile.conflictReview?.selectedFileId - ? (activeFile.conflictReview?.entries ?? []).map((entry) => - joinPath(activeFile.filePath, entry.path) - ) - : []) - ].filter((fileId): fileId is string => Boolean(fileId)) - ) - ) + ? [ + activeFile.id, + activeFile.markdownPreviewSourceFileId, + activeFile.conflictReview?.selectedFileId + ].filter((fileId): fileId is string => Boolean(fileId)) : [] + const overviewPaths = + activeFile?.mode === 'conflict-review' && !activeFile.conflictReview?.selectedFileId + ? (activeFile.conflictReview?.entries ?? []).map((entry) => + joinPath(activeFile.filePath, entry.path) + ) + : [] + // Why: an overview file open in a tab shows under that tab's id (a server file's is not its path). + const selectedFileIds = (state: EditorDraftState): string[] => + overviewPaths.length === 0 || !activeFile + ? fileIds + : Array.from( + new Set([ + ...fileIds, + ...overviewPaths.map((path) => + conflictOverviewFileId(state.openFiles, activeFile.worktreeId, path) + ) + ]) + ) let previousDrafts: AppState['editorDrafts'] | null = null let previousSelection = EMPTY_EDITOR_PANEL_DRAFTS @@ -39,7 +49,8 @@ export function createEditorPanelDraftSelector( } previousDrafts = state.editorDrafts - const changed = fileIds.some((fileId) => { + const ids = selectedFileIds(state) + const changed = ids.some((fileId) => { const draft = state.editorDrafts[fileId] return ( draft !== previousSelection[fileId] || @@ -51,7 +62,7 @@ export function createEditorPanelDraftSelector( } const nextSelection: Record = {} - for (const fileId of fileIds) { + for (const fileId of ids) { const draft = state.editorDrafts[fileId] if (draft !== undefined) { nextSelection[fileId] = draft diff --git a/src/renderer/src/components/editor/useEditorPanelActiveTabContentLoad.ts b/src/renderer/src/components/editor/useEditorPanelActiveTabContentLoad.ts index 6fd68a984151..6a739b65707b 100644 --- a/src/renderer/src/components/editor/useEditorPanelActiveTabContentLoad.ts +++ b/src/renderer/src/components/editor/useEditorPanelActiveTabContentLoad.ts @@ -1,4 +1,5 @@ import { useEffect, type MutableRefObject } from 'react' +import { conflictOverviewFileId } from '@/store/slices/editor/file-ids/editor-file-ids' import type { OpenFile } from '@/store/slices/editor' import { joinPath } from '@/lib/path' import type { useAppStore } from '@/store' @@ -13,6 +14,7 @@ type GitStatusByWorktree = ReturnType['gitStatusByW type UseEditorPanelActiveTabContentLoadParams = { activeFile: OpenFile | null selectedConflictReviewFile: OpenFile | null + openFiles: OpenFile[] isVisible: boolean isChangesMode: boolean gitStatusEntries: GitStatusByWorktree[string] | undefined @@ -41,6 +43,7 @@ function hasLiveRead( export function useEditorPanelActiveTabContentLoad({ activeFile, selectedConflictReviewFile, + openFiles, isVisible, isChangesMode, gitStatusEntries, @@ -96,8 +99,9 @@ export function useEditorPanelActiveTabContentLoad({ } const absolutePath = joinPath(activeFile.filePath, entry.path) - if (needsFileRead(absolutePath)) { - void loadFileContent(absolutePath, absolutePath, activeFile.worktreeId, entry.path) + const fileId = conflictOverviewFileId(openFiles, activeFile.worktreeId, absolutePath) + if (needsFileRead(fileId)) { + void loadFileContent(absolutePath, fileId, activeFile.worktreeId, entry.path) } } return diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index ec1a0fc6b1b7..2e2c144a53d4 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -175,6 +175,7 @@ export function useEditorPanelContentState({ useEditorPanelActiveTabContentLoad({ activeFile, selectedConflictReviewFile, + openFiles, isVisible, isChangesMode, gitStatusEntries, diff --git a/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts b/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts index 3bcaaf0bd270..71c5125d9197 100644 --- a/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts +++ b/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts @@ -183,3 +183,16 @@ export function getOpenedEditFileIdAfterOpen( )?.id ?? filePath ) } + +/** The id a conflict-review overview shows a file under: an open edit tab's, so its unsaved edits show. */ +export function conflictOverviewFileId( + openFiles: OpenFile[], + worktreeId: string, + filePath: string +): string { + return getOpenedEditFileIdAfterOpen( + { openFiles, activeFileIdByWorktree: {} }, + filePath, + worktreeId + ) +} From d0d9374a06355a8e2f549105fe96c4ce64e9b56d Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 20 Oct 2026 15:56:02 -0400 Subject: [PATCH 5/6] fix(editor): route conflict files by their workspace owner only; keep the overview lookup a plain read --- config/renderer-layout-writer-baseline.txt | 1 - .../useEditorPanelActiveTabContentLoad.ts | 2 +- .../editor-git-status-reconciliation.test.ts | 20 ++++++++++++++++--- .../editor/actions/open-conflict-review.ts | 6 +++--- .../slices/editor/file-ids/editor-file-ids.ts | 10 +++++----- 5 files changed, 26 insertions(+), 13 deletions(-) diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index 8576722aabb8..3ebd307d63e3 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -45,7 +45,6 @@ 2 src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts 5 src/renderer/src/runtime/web-session-tabs-sync/apply-terminal-records.ts 4 src/renderer/src/runtime/web-session-tabs-sync/apply-worktree-records.ts -1 src/renderer/src/runtime/web-session-tabs-sync/mirrored-editor-file-identity.ts 2 src/renderer/src/runtime/web-session-tabs-sync/session-tabs-inventory-absence.ts 1 src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts 2 src/renderer/src/runtime/web-session-terminal-orphan-topology.ts diff --git a/src/renderer/src/components/editor/useEditorPanelActiveTabContentLoad.ts b/src/renderer/src/components/editor/useEditorPanelActiveTabContentLoad.ts index 6a739b65707b..895c1e4bd2b3 100644 --- a/src/renderer/src/components/editor/useEditorPanelActiveTabContentLoad.ts +++ b/src/renderer/src/components/editor/useEditorPanelActiveTabContentLoad.ts @@ -14,7 +14,7 @@ type GitStatusByWorktree = ReturnType['gitStatusByW type UseEditorPanelActiveTabContentLoadParams = { activeFile: OpenFile | null selectedConflictReviewFile: OpenFile | null - openFiles: OpenFile[] + openFiles: readonly OpenFile[] isVisible: boolean isChangesMode: boolean gitStatusEntries: GitStatusByWorktree[string] | undefined diff --git a/src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts b/src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts index ab6db100e088..410765b33075 100644 --- a/src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts +++ b/src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { createEditorStore } from './editor-slice-test-harness' +import { makeFolderWorkspace } from './worktrees-slice-test-fixtures' const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() @@ -521,15 +522,28 @@ describe('createEditorSlice conflict status reconciliation', () => { if (onServer) { store.setState({ folderWorkspaces: [ - { + makeFolderWorkspace({ id: 'server-folder', projectGroupId: 'server-group', folderPath: '/repo', connectionId: null - } as never + }) ], projectGroups: [ - { id: 'server-group', connectionId: null, executionHostId: 'runtime:env-1' } as never + { + id: 'server-group', + name: 'Server', + parentPath: '/repo', + connectionId: null, + executionHostId: 'runtime:env-1', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } ] }) } diff --git a/src/renderer/src/store/slices/editor/actions/open-conflict-review.ts b/src/renderer/src/store/slices/editor/actions/open-conflict-review.ts index e2a1463bcd14..4c99c0a52958 100644 --- a/src/renderer/src/store/slices/editor/actions/open-conflict-review.ts +++ b/src/renderer/src/store/slices/editor/actions/open-conflict-review.ts @@ -5,17 +5,17 @@ import type { OpenFile } from '../types/open-file' import type { ConflictReviewState } from '../../../../../../shared/editor-tab-view-types' import { toOpenConflictMetadata } from '../git/git-status-reconciliation' import { openWorkspaceEditorItem } from '../tabs/workspace-editor-item' -import { resolveEditorFileIdForOwner, runtimeOwnerKey } from '../file-ids/editor-file-ids' +import { resolveEditorFileIdForOwner } from '../file-ids/editor-file-ids' import { captureEditorFileOperationProvenance } from '@/lib/editor-file-operation-owner' import type { AppState } from '../../../types' -/** The runtime owner `openFile` would give a file opened in this workspace. */ +/** The runtime owner `openFile` gives a file opened in this workspace; null when it cannot tell. */ function conflictFileRuntimeOwner(s: AppState, worktreeId: string): string | null { try { return captureEditorFileOperationProvenance(s, worktreeId, undefined, false).generation.route .runtimeEnvironmentId } catch { - return runtimeOwnerKey(s.settings?.activeRuntimeEnvironmentId) + return null } } diff --git a/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts b/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts index 71c5125d9197..7afa1238b5a9 100644 --- a/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts +++ b/src/renderer/src/store/slices/editor/file-ids/editor-file-ids.ts @@ -186,13 +186,13 @@ export function getOpenedEditFileIdAfterOpen( /** The id a conflict-review overview shows a file under: an open edit tab's, so its unsaved edits show. */ export function conflictOverviewFileId( - openFiles: OpenFile[], + openFiles: readonly OpenFile[], worktreeId: string, filePath: string ): string { - return getOpenedEditFileIdAfterOpen( - { openFiles, activeFileIdByWorktree: {} }, - filePath, - worktreeId + return ( + openFiles.find( + (file) => file.filePath === filePath && file.worktreeId === worktreeId && file.mode === 'edit' + )?.id ?? filePath ) } From 4fde4391dda791274470f6b1a80d858299662b6e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 20 Oct 2026 16:49:05 -0400 Subject: [PATCH 6/6] test(mirror): the browser-placement replay test names its server file by the owned id --- .../src/runtime/web-session-tabs-sync-browser-tabs.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts index 18c2c992eed5..0a37cf8287e3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { recordWebSessionFocusIntent } from './web-session-focus-intent' +import { buildOwnedEditorFileId } from '../store/slices/editor/file-ids/editor-file-ids' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' import type { Tab, TabGroupLayoutNode } from '../../../shared/tab-types' import { @@ -351,7 +352,9 @@ describe('applyWebSessionTabsSnapshot', () => { it('replays a pre-response browser snapshot after canonical placement and focus are known', () => { const editorGroupId = 'client-editor-group' const previewGroupId = 'client-preview-group' - const editorFileId = '/srv/repo/example.html' + const editorFilePath = '/srv/repo/example.html' + // A server's file is named by its owned id from the first open. + const editorFileId = buildOwnedEditorFileId(editorFilePath, WT, ENV) const editorTab: Tab = { id: 'host-editor', worktreeId: WT, @@ -405,7 +408,7 @@ describe('applyWebSessionTabsSnapshot', () => { type: 'file', id: 'host-editor', title: 'example.html', - filePath: editorFileId, + filePath: editorFilePath, relativePath: 'example.html', language: 'html', isDirty: false,