From 90b37f5748316869d3beacd53dd70d0ccb4579cb Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 14:32:09 -0700 Subject: [PATCH 1/6] fix: durably recover damaged editor owner records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 김영민 --- ...ofile-project-session-field-disposition.ts | 2 + .../session-worktree-ownership.ts | 1 + src/renderer/src/i18n/locales/en.json | 4 +- .../src/lib/workspace-session-patch.ts | 7 + ...rkspace-session-recovered-editor-drafts.ts | 28 + .../workspace-session-relevant-fields.test.ts | 1 + src/renderer/src/lib/workspace-session.ts | 6 + .../editor-recently-closed-tabs.test.ts | 519 ++++++++++++++++- .../slices/editor-recovery-durability.test.ts | 93 +++ .../editor/actions/close-file-action.ts | 7 +- .../editor/actions/hydrate-editor-session.ts | 131 ++++- .../slices/editor/actions/open-file-apply.ts | 12 +- .../parked-recovered-editor-drafts.test.ts | 166 ++++++ .../actions/parked-recovered-editor-drafts.ts | 86 +++ .../actions/recently-closed-editor-tabs.ts | 38 +- .../actions/recovered-draft-block-notice.ts | 24 + .../actions/reopen-recovered-editor-tab.ts | 156 +++++ .../file-ids/editor-document-identity.test.ts | 52 ++ .../file-ids/editor-document-identity.ts | 8 + .../file-ids/hydrated-editor-file-ids.test.ts | 121 ++++ .../file-ids/hydrated-editor-file-ids.ts | 80 ++- .../file-ids/hydrated-editor-owner-healing.ts | 351 ++++++++++++ .../store/slices/editor/types/open-file.ts | 3 + .../store/slices/recently-closed-tabs.test.ts | 14 + .../src/store/slices/recently-closed-tabs.ts | 30 +- .../stale-editor-tab-owner-healing.test.ts | 533 ++++++++++++++++++ .../stale-editor-tab-resurrection-fixture.ts | 123 ++++ src/shared/workspace-session-editor-schema.ts | 16 + .../workspace-session-host-field-ownership.ts | 4 +- ...pace-session-schema-field-coverage.test.ts | 1 + src/shared/workspace-session-schema.ts | 7 +- src/shared/workspace-session-state-types.ts | 2 + 32 files changed, 2552 insertions(+), 74 deletions(-) create mode 100644 src/renderer/src/lib/workspace-session-recovered-editor-drafts.ts create mode 100644 src/renderer/src/store/slices/editor-recovery-durability.test.ts create mode 100644 src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.test.ts create mode 100644 src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.ts create mode 100644 src/renderer/src/store/slices/editor/actions/recovered-draft-block-notice.ts create mode 100644 src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts create mode 100644 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.test.ts create mode 100644 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-owner-healing.ts create mode 100644 src/renderer/src/store/slices/stale-editor-tab-owner-healing.test.ts create mode 100644 src/renderer/src/store/slices/stale-editor-tab-resurrection-fixture.ts diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index 219ef63d8cac..174ae6a1c00c 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -65,6 +65,8 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { onTransfer: 'copiedByBespokeRule' }, openFilesByWorktree: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByBespokeRule' }, + // Recovery copies stay in the source profile even if its workspace is removed or transferred. + recoveredEditorDraftsByWorktree: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' }, activeFileIdByWorktree: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByOwnerKey' }, markdownFrontmatterVisible: { onRepoRemoval: 'prunedByBespokeRule', diff --git a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts index 414db2867f03..925b34930ac5 100644 --- a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts +++ b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts @@ -30,6 +30,7 @@ export const WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND = { localOnlyScrollbackByTabId: 'none', activeWorktreeIdsOnShutdown: 'worktree-id-array', openFilesByWorktree: 'owner-keyed-row-arrays', + recoveredEditorDraftsByWorktree: 'none', activeFileIdByWorktree: 'owner-keyed', markdownFrontmatterVisible: 'none', browserTabsByWorktree: 'owner-keyed-browser-row-arrays', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8f17cf37f402..5bdb3696a32b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -524,7 +524,9 @@ "f2e00db373": "File not found: {{value0}}", "checkRunDetailsUnavailable": "No details are available for this check.", "checkRunDetailsLoadFailed": "Failed to load check details.", - "checkRunDetailsRepoUnavailable": "Repository details are unavailable for this check." + "checkRunDetailsRepoUnavailable": "Repository details are unavailable for this check.", + "parkedDraftBlockedByUnsavedChanges": "{{path}} is open with unsaved changes. Save or close it, then reopen to recover the parked draft.", + "parkedDraftBlockedByReadOnlyTab": "{{path}} is open read-only. Close it, then reopen to recover the parked draft." }, "github": { "f129c42773": "GitHub did not return the new comment.", diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index d1a16cf25e9c..72673ac03dd0 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -21,6 +21,8 @@ import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' +import { buildPersistedRecoveredEditorDrafts } from './workspace-session-recovered-editor-drafts' + type SessionRelevantField = keyof WorkspaceSessionSnapshot function hasAnyChangedField( @@ -151,6 +153,11 @@ export function buildWorkspaceSessionPatch( ) ) } + if (changed.has('recentlyClosedEditorTabsByWorktree')) { + patch.recoveredEditorDraftsByWorktree = buildPersistedRecoveredEditorDrafts( + snapshot.recentlyClosedEditorTabsByWorktree + ) + } // Why: withoutStagedBrowserTabs hides rows based on the handle map, so clearing a staged flag // changes which browser and tab rows persist even when the rows themselves are untouched. const stagedVisibilityChanged = changed.has('remoteBrowserPageHandlesByPageId') diff --git a/src/renderer/src/lib/workspace-session-recovered-editor-drafts.ts b/src/renderer/src/lib/workspace-session-recovered-editor-drafts.ts new file mode 100644 index 000000000000..75521bea9865 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-recovered-editor-drafts.ts @@ -0,0 +1,28 @@ +import type { PersistedOpenFile } from '../../../shared/workspace-session-state-types' +import type { ClosedEditorTabSnapshot } from '../store/slices/editor/types/open-file' + +export function buildPersistedRecoveredEditorDrafts( + snapshotsByWorktree: Record | undefined +): Record { + const archive: Record = {} + for (const [worktreeId, snapshots] of Object.entries(snapshotsByWorktree ?? {})) { + const drafts = snapshots.filter( + (snapshot) => snapshot.dirtyDraftContent !== undefined && snapshot.readOnly !== true + ) + if (drafts.length === 0) { + continue + } + archive[worktreeId] = drafts.map((snapshot) => ({ + filePath: snapshot.filePath, + relativePath: snapshot.relativePath, + worktreeId, + language: snapshot.language, + isPreview: false, + runtimeEnvironmentId: snapshot.runtimeEnvironmentId, + externalSshTargetId: snapshot.externalSshTargetId, + dirtyDraftContent: snapshot.dirtyDraftContent, + lastKnownDiskSignature: snapshot.lastKnownDiskSignature + })) + } + return archive +} diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index 9aadf0579647..f9730a74c010 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -16,6 +16,7 @@ describe('SESSION_RELEVANT_FIELDS', () => { localOnlyScrollbackByTabId: true, activeTabIdByWorktree: true, openFiles: true, + recentlyClosedEditorTabsByWorktree: true, editorDrafts: true, markdownFrontmatterVisible: true, activeFileIdByWorktree: true, diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index b536f01df883..748b7b8a54a9 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -12,6 +12,7 @@ import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agen import { buildActiveConnectionIdsAtShutdown } from './workspace-session-reconnect-targets' import { withoutStagedBrowserTabs } from './workspace-session-staged-browser-tabs' import { buildBrowserSessionData } from './workspace-session-browser-tabs' +import { buildPersistedRecoveredEditorDrafts } from './workspace-session-recovered-editor-drafts' export { buildActiveConnectionIdsAtShutdown } @@ -55,6 +56,7 @@ export type WorkspaceSessionSnapshot = Pick< | 'lastVisitedAtByWorktreeId' | 'defaultTerminalTabsAppliedByWorktreeId' > & { + recentlyClosedEditorTabsByWorktree?: AppState['recentlyClosedEditorTabsByWorktree'] activeWorkspaceExecutionHostId?: AppState['activeWorkspaceExecutionHostId'] sleepingAgentSessionsByPaneKey?: AppState['sleepingAgentSessionsByPaneKey'] clientHostedBrowserCloseIntentsByEnvironment?: AppState['clientHostedBrowserCloseIntentsByEnvironment'] @@ -76,6 +78,7 @@ export const SESSION_RELEVANT_FIELDS = [ 'localOnlyScrollbackByTabId', 'activeTabIdByWorktree', 'openFiles', + 'recentlyClosedEditorTabsByWorktree', 'editorDrafts', 'markdownFrontmatterVisible', 'activeFileIdByWorktree', @@ -284,6 +287,9 @@ export function buildWorkspaceSessionPayload( // Why: session:set fully replaces the persisted object, so dropping this silently disables eager terminal reconnect on restart. activeWorktreeIdsOnShutdown: terminalSessionData.activeWorktreeIdsOnShutdown, activeTabIdByWorktree: snapshot.activeTabIdByWorktree, + recoveredEditorDraftsByWorktree: buildPersistedRecoveredEditorDrafts( + snapshot.recentlyClosedEditorTabsByWorktree + ), ...buildEditorSessionData( snapshot.openFiles, snapshot.editorDrafts, 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..da183cbeb35a 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 @@ -1,19 +1,21 @@ import type { StoreApi } from 'zustand/vanilla' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createEditorStore, createEditorTabsStore, ownedEditorFileId } from './editor-slice-test-harness' +import { createRecentlyClosedTabsSlice } from './recently-closed-tabs' import type { AppState } from '../types' import type { Tab } from '../../../../shared/tab-types' -const { toastErrorMock } = vi.hoisted(() => ({ - toastErrorMock: vi.fn() +const { toastErrorMock, toastInfoMock } = vi.hoisted(() => ({ + toastErrorMock: vi.fn(), + toastInfoMock: vi.fn() })) vi.mock('sonner', () => ({ - toast: { error: toastErrorMock } + toast: { error: toastErrorMock, info: toastInfoMock } })) const { notifyHostOfMirroredEditorCloseMock } = vi.hoisted(() => ({ @@ -40,7 +42,24 @@ function withCountedEntityIdReads(tabs: readonly Tab[], onRead: () => void): Tab }) } +/** The cross-type Cmd+Shift+T dispatcher owns the kind stack the editor reopen pops from, so a + * test of that handoff needs the real dispatcher rather than a stub. */ +function withCrossTypeReopen(store: StoreApi): StoreApi { + const { reopenClosedTerminalTab, reopenClosedTab } = createRecentlyClosedTabsSlice( + store.setState, + store.getState, + store + ) + // Why actions only: spreading the slice's initial state would blank the kind stack a test seeded. + store.setState({ reopenClosedTerminalTab, reopenClosedTab }) + return store +} + describe('createEditorSlice recently closed editor tabs', () => { + beforeEach(() => { + toastInfoMock.mockClear() + }) + function openMirroredEditor(store: StoreApi, filePath: string, preview = false): void { store.getState().openFile( { @@ -56,6 +75,44 @@ describe('createEditorSlice recently closed editor tabs', () => { ) } + function openLocalEditor(store: StoreApi, filePath = '/repo/notes.md'): string { + return store.getState().openFile({ + filePath, + relativePath: filePath.replace('/repo/', ''), + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + } + + /** A draft the restore heal could not give a tab of its own, waiting on the reopen stack. */ + function parkRecoveredDraft( + store: StoreApi, + dirtyDraftContent: string, + options: { filePath?: string; lastKnownDiskSignature?: string } = {} + ): void { + const filePath = options.filePath ?? '/repo/notes.md' + store.setState({ + recentlyClosedEditorTabsByWorktree: { + 'wt-1': [ + { + filePath, + relativePath: filePath.replace('/repo/', ''), + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + dirtyDraftContent, + ...(options.lastKnownDiskSignature + ? { lastKnownDiskSignature: options.lastKnownDiskSignature } + : {}) + } + ] + }, + // Why: hydration pairs every parked snapshot with a kind entry, so the fixture must too. + recentlyClosedTabKindsByWorktree: { 'wt-1': ['editor'] } + }) + } + it('reopens a closed mirrored editor tab as a local tab', () => { const store = createEditorStore() openMirroredEditor(store, '/repo/notes.md') @@ -71,6 +128,460 @@ describe('createEditorSlice recently closed editor tabs', () => { expect(store.getState().openFiles[0]).not.toHaveProperty('mirroredFromRuntimeSession') }) + it('restores a parked unsaved buffer when its snapshot carries one', () => { + const store = createEditorStore() + store.setState({ + recentlyClosedEditorTabsByWorktree: { + 'wt-1': [ + { + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + dirtyDraftContent: 'rescued draft' + } + ] + } + }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + const restored = store.getState().openFiles[0] + expect(restored).toMatchObject({ filePath: '/repo/notes.md', isDirty: true }) + expect(store.getState().editorDrafts[restored.id]).toBe('rescued draft') + expect(restored).not.toHaveProperty('dirtyDraftContent') + }) + + it('retains the recovery copy when opening its file fails', () => { + const store = createEditorStore() + parkRecoveredDraft(store, 'rescued draft') + store.setState({ + openFile: () => { + throw new Error('owner unavailable') + } + }) + expect(() => store.getState().reopenClosedEditorTab('wt-1')).toThrow('owner unavailable') + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1'][0].dirtyDraftContent).toBe( + 'rescued draft' + ) + }) + + it('ordinary closes and preview replacements cannot evict recovered buffers', () => { + const store = createEditorStore() + parkRecoveredDraft(store, 'rescued draft') + for (let index = 0; index < 24; index += 1) { + const id = openLocalEditor(store, `/repo/saved-${index}.md`) + store.getState().closeFile(id) + } + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']).toHaveLength(11) + expect( + store.getState().recentlyClosedEditorTabsByWorktree['wt-1'].at(-1)?.dirtyDraftContent + ).toBe('rescued draft') + for (let index = 0; index < 24; index += 1) { + store.getState().openFile( + { + filePath: `/repo/preview-${index}.md`, + relativePath: `preview-${index}.md`, + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }, + { preview: true } + ) + } + expect( + store.getState().recentlyClosedEditorTabsByWorktree['wt-1'].at(-1)?.dirtyDraftContent + ).toBe('rescued draft') + }) + + it('applies a recovered draft to a record that is already open and clean', () => { + const store = createEditorStore() + const openId = openLocalEditor(store) + parkRecoveredDraft(store, 'rescued draft') + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().editorDrafts[openId]).toBe('rescued draft') + expect(store.getState().openFiles[0].isDirty).toBe(true) + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']).toEqual([]) + }) + + it('never overwrites the unsaved buffer of a record that is already open', () => { + const store = createEditorStore() + const openId = openLocalEditor(store) + store.getState().setEditorDraft(openId, 'live buffer') + store.getState().markFileDirty(openId, true) + parkRecoveredDraft(store, 'rescued draft') + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().editorDrafts[openId]).toBe('live buffer') + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']).toEqual([ + expect.objectContaining({ dirtyDraftContent: 'rescued draft' }) + ]) + expect(toastInfoMock).toHaveBeenCalledTimes(1) + }) + + it('re-verifies the disk baseline a recovered draft derives from', () => { + const store = createEditorStore() + parkRecoveredDraft(store, 'rescued draft', { lastKnownDiskSignature: 'sig-1' }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().openFiles[0]).toMatchObject({ + lastKnownDiskSignature: 'sig-1', + pendingDiskBaselineVerification: true + }) + }) + + it('blocks autosave of a recovered draft with no verifiable baseline', () => { + const store = createEditorStore() + parkRecoveredDraft(store, 'rescued draft') + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + const restored = store.getState().openFiles[0] + expect(restored.lastKnownDiskSignature).toBeUndefined() + expect(restored.pendingDiskBaselineVerification).toBeUndefined() + expect(restored.externalMutation).toBe('changed') + }) + + it("replaces a reused clean record's baseline with the one the recovered draft derives from", () => { + const store = createEditorStore() + const openId = openLocalEditor(store) + store.getState().setLastKnownDiskSignature(openId, 'sig-clean-load') + parkRecoveredDraft(store, 'rescued draft', { lastKnownDiskSignature: 'sig-draft' }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]).toMatchObject({ + id: openId, + lastKnownDiskSignature: 'sig-draft', + pendingDiskBaselineVerification: true + }) + }) + + it('sends a draft that cannot land to the back of both reopen stacks', () => { + const store = withCrossTypeReopen(createEditorTabsStore()) + const openId = openLocalEditor(store) + store.getState().setEditorDraft(openId, 'live buffer') + store.getState().markFileDirty(openId, true) + parkRecoveredDraft(store, 'rescued draft') + const parked = store.getState().recentlyClosedEditorTabsByWorktree['wt-1'] ?? [] + store.setState({ + recentlyClosedEditorTabsByWorktree: { + 'wt-1': [...parked, { ...parked[0], filePath: '/repo/other.md', relativePath: 'other.md' }] + }, + recentlyClosedTerminalTabsByWorktree: { 'wt-1': [{ startupCwd: '/repo' }] }, + recentlyClosedTabKindsByWorktree: { 'wt-1': ['editor', 'editor', 'terminal'] } + }) + + expect(store.getState().reopenClosedTab('wt-1')).toBe(true) + + // The colliding snapshot moved behind the untouched one, and its kind behind the terminal's. + expect( + store.getState().recentlyClosedEditorTabsByWorktree['wt-1']?.map((s) => s.filePath) + ).toEqual(['/repo/other.md', '/repo/notes.md']) + expect(store.getState().recentlyClosedTabKindsByWorktree['wt-1']).toEqual([ + 'editor', + 'terminal', + 'editor' + ]) + expect(store.getState().editorDrafts[openId]).toBe('live buffer') + }) + + it('lets a later cross-type reopen pass a deferred draft instead of queuing behind it', () => { + const store = withCrossTypeReopen(createEditorTabsStore()) + const openId = openLocalEditor(store) + store.getState().setEditorDraft(openId, 'live buffer') + store.getState().markFileDirty(openId, true) + parkRecoveredDraft(store, 'rescued draft') + // The terminal slice is out of this harness, so stand in for the reopen the dispatcher calls. + const terminalReopenMock = vi.fn(() => true) + store.setState({ + reopenClosedTerminalTab: terminalReopenMock, + recentlyClosedTabKindsByWorktree: { 'wt-1': ['editor', 'terminal'] } + }) + + expect(store.getState().reopenClosedTab('wt-1')).toBe(true) + expect(store.getState().reopenClosedTab('wt-1')).toBe(true) + + expect(terminalReopenMock).toHaveBeenCalledTimes(1) + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']).toEqual([ + expect.objectContaining({ dirtyDraftContent: 'rescued draft' }) + ]) + }) + + it('leaves the live record untouched when a recovered draft collides with its unsaved work', () => { + const store = createEditorTabsStore() + const openId = openLocalEditor(store) + store.getState().setEditorDraft(openId, 'live buffer') + store.getState().markFileDirty(openId, true) + store.getState().setLastKnownDiskSignature(openId, 'sig-live') + const tabCountBefore = (store.getState().unifiedTabsByWorktree['wt-1'] ?? []).length + const orderBefore = store.getState().tabBarOrderByWorktree['wt-1'] + parkRecoveredDraft(store, 'rescued draft', { lastKnownDiskSignature: 'sig-draft' }) + // The toast names a document the user must act on, so the editor has to come back to the front. + store.setState({ activeTabType: 'terminal', activeTabTypeByWorktree: { 'wt-1': 'terminal' } }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().activeTabType).toBe('editor') + expect(store.getState().activeTabTypeByWorktree['wt-1']).toBe('editor') + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().unifiedTabsByWorktree['wt-1']).toHaveLength(tabCountBefore) + expect(store.getState().editorDrafts[openId]).toBe('live buffer') + expect(store.getState().openFiles[0]).toMatchObject({ lastKnownDiskSignature: 'sig-live' }) + expect(store.getState().openFiles[0].pendingDiskBaselineVerification).toBeUndefined() + expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual(orderBefore) + expect(store.getState().activeFileId).toBe(openId) + expect(toastInfoMock).toHaveBeenCalledTimes(1) + expect(toastInfoMock).toHaveBeenCalledWith( + 'notes.md is open with unsaved changes. Save or close it, then reopen to recover the parked draft.' + ) + }) + + it('opens no second tab when the colliding snapshot names another group', () => { + const store = createEditorTabsStore() + const openId = openLocalEditor(store) + const firstGroupId = store.getState().groupsByWorktree['wt-1']?.[0]?.id ?? '' + const secondGroupId = store + .getState() + .createEmptySplitGroup('wt-1', firstGroupId, 'right', { activate: false }) + expect(secondGroupId).toBeTruthy() + expect(secondGroupId).not.toBe(firstGroupId) + store.getState().setEditorDraft(openId, 'live buffer') + store.getState().markFileDirty(openId, true) + parkRecoveredDraft(store, 'rescued draft') + const parked = (store.getState().recentlyClosedEditorTabsByWorktree['wt-1'] ?? [])[0] + store.setState({ + recentlyClosedEditorTabsByWorktree: { + 'wt-1': [{ ...parked, position: { groupId: secondGroupId ?? undefined } }] + } + }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect( + (store.getState().unifiedTabsByWorktree['wt-1'] ?? []).filter( + (tab) => tab.entityId === openId + ) + ).toHaveLength(1) + }) + + it('leaves the live tab where it is when a recovered draft parks', () => { + const store = createEditorTabsStore() + const firstId = openLocalEditor(store, '/repo/first.md') + const notesId = openLocalEditor(store) + store.getState().setEditorDraft(notesId, 'live buffer') + store.getState().markFileDirty(notesId, true) + store.getState().setTabBarOrder('wt-1', [firstId, notesId]) + parkRecoveredDraft(store, 'rescued draft') + // The snapshot's position is stale: honouring it would drag the live tab to the front. + store.setState({ + recentlyClosedEditorTabsByWorktree: { + 'wt-1': [ + { + ...(store.getState().recentlyClosedEditorTabsByWorktree['wt-1'] ?? [])[0], + position: { tabBarIndex: 0 } + } + ] + } + }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([firstId, notesId]) + }) + + it('drops a recovered draft the live record already holds without rewriting its baseline', () => { + const store = createEditorTabsStore() + const openId = openLocalEditor(store) + store.getState().setEditorDraft(openId, 'same draft') + store.getState().markFileDirty(openId, true) + store.getState().setLastKnownDiskSignature(openId, 'sig-live') + parkRecoveredDraft(store, 'same draft', { lastKnownDiskSignature: 'sig-draft' }) + store.setState({ activeTabType: 'terminal', activeTabTypeByWorktree: { 'wt-1': 'terminal' } }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().activeTabType).toBe('editor') + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']).toEqual([]) + expect(store.getState().recentlyClosedTabKindsByWorktree['wt-1']).toEqual(['editor']) + expect(store.getState().editorDrafts[openId]).toBe('same draft') + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]).toMatchObject({ lastKnownDiskSignature: 'sig-live' }) + expect(store.getState().openFiles[0].pendingDiskBaselineVerification).toBeUndefined() + expect(store.getState().activeFileId).toBe(openId) + }) + + it('gives a baseline-less live record the equal draft snapshot baseline to verify', () => { + const store = createEditorTabsStore() + const openId = openLocalEditor(store) + store.getState().setEditorDraft(openId, 'same draft') + store.getState().markFileDirty(openId, true) + parkRecoveredDraft(store, 'same draft', { lastKnownDiskSignature: 'sig-draft' }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + // Without the baseline the restored draft has nothing the conflict scan can verify it against. + expect(store.getState().openFiles[0]).toMatchObject({ + lastKnownDiskSignature: 'sig-draft', + pendingDiskBaselineVerification: true + }) + expect(store.getState().editorDrafts[openId]).toBe('same draft') + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']).toEqual([]) + }) + + it('gives a baseline-less reused alias record the equal draft snapshot baseline', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + try { + const store = createEditorStore() + const liveId = store.getState().openFile({ + filePath: '//wsl.localhost/Ubuntu/home/Alice/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + store.getState().setEditorDraft(liveId, 'same draft') + store.getState().markFileDirty(liveId, true) + parkRecoveredDraft(store, 'same draft', { + filePath: '\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\notes.md', + lastKnownDiskSignature: 'sig-draft' + }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]).toMatchObject({ + lastKnownDiskSignature: 'sig-draft', + pendingDiskBaselineVerification: true + }) + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']).toEqual([]) + } finally { + vi.unstubAllGlobals() + } + }) + + it('parks a recovered draft rather than feeding it to a read-only record', () => { + const store = withCrossTypeReopen(createEditorTabsStore()) + const logId = store.getState().openFile({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + readOnly: true + }) + parkRecoveredDraft(store, 'rescued draft') + + expect(store.getState().reopenClosedTab('wt-1')).toBe(true) + + // openFile reuses the read-only record, and its draft/dirty writes hard no-op — the snapshot + // is the only copy of that text, so it goes back on the stack instead of being consumed. + expect(store.getState().openFiles.map((f) => [f.id, f.readOnly === true, f.isDirty])).toEqual([ + [logId, true, false] + ]) + expect(store.getState().editorDrafts).toEqual({}) + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']).toEqual([ + expect.objectContaining({ dirtyDraftContent: 'rescued draft' }) + ]) + expect(store.getState().recentlyClosedTabKindsByWorktree['wt-1']?.at(-1)).toBe('editor') + expect(toastInfoMock).toHaveBeenCalledTimes(1) + expect(toastInfoMock).toHaveBeenCalledWith( + 'notes.md is open read-only. Close it, then reopen to recover the parked draft.' + ) + }) + + it('parks a recovered draft beside a read-only log without opening a second tab', () => { + const store = createEditorTabsStore() + const logId = store.getState().openFile({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + readOnly: true + }) + const firstGroupId = store.getState().groupsByWorktree['wt-1']?.[0]?.id ?? '' + const secondGroupId = store + .getState() + .createEmptySplitGroup('wt-1', firstGroupId, 'right', { activate: false }) + expect(secondGroupId).toBeTruthy() + parkRecoveredDraft(store, 'rescued draft') + const parked = (store.getState().recentlyClosedEditorTabsByWorktree['wt-1'] ?? [])[0] + store.setState({ + recentlyClosedEditorTabsByWorktree: { + 'wt-1': [{ ...parked, position: { groupId: secondGroupId ?? undefined } }] + }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { 'wt-1': 'terminal' } + }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + // The read-only record is reused by openFile whatever its identity key says, so the open must + // not happen at all: a second tab in the snapshot's group would render the same log twice. + expect(store.getState().unifiedTabsByWorktree['wt-1']).toHaveLength(1) + expect(store.getState().openFiles.map((f) => f.id)).toEqual([logId]) + expect(store.getState().activeFileId).toBe(logId) + expect(store.getState().activeTabType).toBe('editor') + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']).toEqual([ + expect.objectContaining({ dirtyDraftContent: 'rescued draft' }) + ]) + expect(toastInfoMock).toHaveBeenCalledTimes(1) + }) + + it('consumes a recovered draft the reused alias record already holds', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + try { + const store = createEditorStore() + // Why the alias pair: only openFile's WSL-alias reuse lands a snapshot on a live record the + // identity-keyed pre-open collision check cannot see. + const liveId = store.getState().openFile({ + filePath: '//wsl.localhost/Ubuntu/home/Alice/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + store.getState().setEditorDraft(liveId, 'same draft') + store.getState().markFileDirty(liveId, true) + store.getState().setLastKnownDiskSignature(liveId, 'sig-live') + parkRecoveredDraft(store, 'same draft', { + filePath: '\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\notes.md', + lastKnownDiskSignature: 'sig-draft' + }) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().editorDrafts[liveId]).toBe('same draft') + expect(store.getState().openFiles[0]).toMatchObject({ lastKnownDiskSignature: 'sig-live' }) + expect(store.getState().openFiles[0].pendingDiskBaselineVerification).toBeUndefined() + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']).toEqual([]) + expect(toastInfoMock).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it('applies the same recovered draft when the reused record is writable', () => { + const store = withCrossTypeReopen(createEditorTabsStore()) + const liveId = openLocalEditor(store) + parkRecoveredDraft(store, 'rescued draft') + + expect(store.getState().reopenClosedTab('wt-1')).toBe(true) + + expect(store.getState().editorDrafts[liveId]).toBe('rescued draft') + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1'] ?? []).toEqual([]) + expect(toastInfoMock).not.toHaveBeenCalled() + }) + it('reopens close-all mirrored editor tabs as local tabs', () => { const store = createEditorStore() openMirroredEditor(store, '/repo/notes.md') diff --git a/src/renderer/src/store/slices/editor-recovery-durability.test.ts b/src/renderer/src/store/slices/editor-recovery-durability.test.ts new file mode 100644 index 000000000000..090ae38c860d --- /dev/null +++ b/src/renderer/src/store/slices/editor-recovery-durability.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import { createStoreSessionMockApi } from './store-session-test-harness' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { buildWorkspaceSessionPatch } from '@/lib/workspace-session-patch' +import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema' +import { parkRecoveredEditorDrafts } from './editor/actions/parked-recovered-editor-drafts' +import type { ClosedEditorTabSnapshot } from './editor/types/open-file' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +createStoreSessionMockApi() + +const WORKSPACE = 'folder:recovery' +function draft(index: number): ClosedEditorTabSnapshot { + return { + filePath: `/workspace/draft-${index}.ts`, + relativePath: `draft-${index}.ts`, + worktreeId: WORKSPACE, + language: 'typescript', + mode: 'edit', + runtimeEnvironmentId: 'removed-host', + externalSshTargetId: 'ssh-owner', + dirtyDraftContent: index === 0 ? '' : `unsaved ${index}`, + lastKnownDiskSignature: `baseline-${index}` + } +} + +describe('durable editor recovery', () => { + it('retains every buffer and its authority through serialization and fresh-store hydration', () => { + const store = createTestStore() + const originals = Array.from({ length: 42 }, (_value, index) => draft(index)) + store.setState(parkRecoveredEditorDrafts(store.getState(), WORKSPACE, originals)) + const payload = buildWorkspaceSessionPayload(store.getState()) + const parsed = parseWorkspaceSession(JSON.parse(JSON.stringify(payload))) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error('session did not parse') + } + const restarted = createTestStore() + restarted.getState().hydrateEditorSession(parsed.value) + const recovered = restarted.getState().recentlyClosedEditorTabsByWorktree[WORKSPACE] + expect( + recovered.map((entry) => [ + entry.dirtyDraftContent, + entry.lastKnownDiskSignature, + entry.runtimeEnvironmentId, + entry.externalSshTargetId + ]) + ).toEqual( + originals.map((entry) => [ + entry.dirtyDraftContent, + entry.lastKnownDiskSignature, + entry.runtimeEnvironmentId, + entry.externalSshTargetId + ]) + ) + // Missing workspace inventory cannot erase a recovery copy. + restarted.getState().hydrateEditorSession(parsed.value) + expect(restarted.getState().recentlyClosedEditorTabsByWorktree[WORKSPACE]).toHaveLength(42) + expect( + buildWorkspaceSessionPayload(restarted.getState()).recoveredEditorDraftsByWorktree + ).toEqual(payload.recoveredEditorDraftsByWorktree) + }) + + it('writes recovery additions and consumption through the incremental writer', () => { + const store = createTestStore() + store.setState(parkRecoveredEditorDrafts(store.getState(), WORKSPACE, [draft(0)])) + const added = buildWorkspaceSessionPatch(store.getState(), [ + 'recentlyClosedEditorTabsByWorktree' + ]) + expect(added.recoveredEditorDraftsByWorktree?.[WORKSPACE]?.[0]?.dirtyDraftContent).toBe('') + store.setState({ recentlyClosedEditorTabsByWorktree: {} }) + expect( + buildWorkspaceSessionPatch(store.getState(), ['recentlyClosedEditorTabsByWorktree']) + .recoveredEditorDraftsByWorktree + ).toEqual({}) + }) + + it('reaches recovery after cross-type history expires without reviving ordinary old closes', () => { + const store = createTestStore() + const recovered = draft(1) + const { dirtyDraftContent: _draft, ...saved } = draft(2) + store.setState({ + recentlyClosedEditorTabsByWorktree: { [WORKSPACE]: [saved, recovered] }, + recentlyClosedTabKindsByWorktree: {}, + reopenClosedEditorTab: vi.fn(() => true) + }) + expect(store.getState().reopenClosedTab(WORKSPACE)).toBe(true) + expect(store.getState().recentlyClosedEditorTabsByWorktree[WORKSPACE][0]).toEqual(recovered) + store.setState({ recentlyClosedEditorTabsByWorktree: { [WORKSPACE]: [saved] } }) + expect(store.getState().reopenClosedTab(WORKSPACE)).toBe(false) + }) +}) 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 12da12b4b8bb..01379603dc9c 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 @@ -1,8 +1,9 @@ +import { retainClosedEditorSnapshots } from './parked-recovered-editor-drafts' import type { EditorGet, EditorSet } from '../types/editor-set-get' import type { EditorSlice } from '../types/editor-slice' import { getRecentlyClosedTabPosition, pushRecentlyClosedTabKind } from '../../recently-closed-tabs' import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab' -import { MAX_RECENT_CLOSED_EDITOR_TABS, type OpenFile } from '../types/open-file' +import type { OpenFile } from '../types/open-file' import { removeMarkdownVisibilityKeys } from '../tabs/workspace-editor-item' import { isEditorTabContentType } from '../tabs/editor-tab-content-type' import { @@ -246,14 +247,14 @@ export function createCloseFileAction( const position = getRecentlyClosedTabPosition(s, wtRecent, fileId) nextRecentlyClosed = { ...s.recentlyClosedEditorTabsByWorktree, - [wtRecent]: [ + [wtRecent]: retainClosedEditorSnapshots([ { ...snap, reopenId: fileId, ...(position ? { position } : {}) }, ...stack - ].slice(0, MAX_RECENT_CLOSED_EDITOR_TABS) + ]) } nextRecentlyClosedKinds = pushRecentlyClosedTabKind( s.recentlyClosedTabKindsByWorktree, diff --git a/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts b/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts index ee12fb3de584..7877e05082c5 100644 --- a/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts +++ b/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts @@ -5,7 +5,13 @@ import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydrati import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' import type { WorkspaceVisibleTabType } from '../../../../../../shared/tab-types' -import type { OpenFile } from '../types/open-file' +import type { AppState } from '../../../types' +import type { PersistedOpenFile } from '../../../../../../shared/workspace-session-state-types' +import type { ClosedEditorTabSnapshot, OpenFile } from '../types/open-file' +import { + parkRecoveredEditorDrafts, + type ParkedRecoveredEditorDrafts +} from './parked-recovered-editor-drafts' import { buildValidWorktreeIdsForSessionHydration } from '../../degraded-repo-worktree-validity' import { buildOwnedEditorFileId } from '../file-ids/editor-file-ids' import { resolveHydratedEditorFileSelection } from '../file-ids/hydrated-editor-file-selection' @@ -16,11 +22,57 @@ import { LegacyHydratedEditorFileIndex, shouldHydrateWithOwnedEditorFileId } from '../file-ids/hydrated-editor-file-ids' +import { + alignHealedEditorTabHosts, + planHealedPersistedEditorFiles, + resolveHealableWorktreeOwnerRoute, + type HealedEditorTabHostTarget +} from '../file-ids/hydrated-editor-owner-healing' import { collectHydratedOrphanEditorFileIds, isOrphanEditorFile } from '../file-ids/orphan-editor-file-ids' +/** A draft with no restorable identity of its own, parked where Mod+Shift+T can bring it back. */ +function buildRecoveredDraftSnapshot( + file: PersistedOpenFile, + worktreeId: string +): ClosedEditorTabSnapshot { + return { + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId, + language: detectLanguage(file.relativePath || file.filePath), + runtimeEnvironmentId: file.runtimeEnvironmentId, + externalSshTargetId: file.externalSshTargetId, + mode: 'edit', + dirtyDraftContent: file.dirtyDraftContent, + // Why: without the baseline the draft derives from, a reopen restores it with nothing for the + // conflict scan to compare, and autosave can clobber an offline write. + lastKnownDiskSignature: file.lastKnownDiskSignature + } +} + +function buildRecoveredDraftReopenState( + state: Pick, + recoveredByWorktree: Record +): Partial< + Pick +> { + const worktreeIds = Object.keys(recoveredByWorktree) + if (worktreeIds.length === 0) { + return {} + } + let parked: ParkedRecoveredEditorDrafts = { + recentlyClosedEditorTabsByWorktree: state.recentlyClosedEditorTabsByWorktree, + recentlyClosedTabKindsByWorktree: state.recentlyClosedTabKindsByWorktree + } + for (const worktreeId of worktreeIds) { + parked = parkRecoveredEditorDrafts(parked, worktreeId, recoveredByWorktree[worktreeId]) + } + return parked +} + export function createHydrateEditorSession( set: EditorSet, _get: EditorGet @@ -48,13 +100,43 @@ export function createHydrateEditorSession( const usedOpenFileIds = new Set() const legacyFileIndex = new LegacyHydratedEditorFileIndex() const editorFileIdMigrationsByWorktree: Record> = {} + const healedTabHostTargets: Record = {} + const recoveredDraftTabsByWorktree: Record = {} + for (const [worktreeId, drafts] of Object.entries( + session.recoveredEditorDraftsByWorktree ?? {} + )) { + recoveredDraftTabsByWorktree[worktreeId] = drafts + .filter((file) => file.dirtyDraftContent !== undefined && file.readOnly !== true) + .map((file) => buildRecoveredDraftSnapshot(file, worktreeId)) + } for (const [worktreeId, files] of Object.entries(openFilesByWorktree)) { if (!validWorktreeIds.has(worktreeId)) { continue } - for (const pf of files) { + const route = resolveHealableWorktreeOwnerRoute(s, worktreeId) + const healed = planHealedPersistedEditorFiles({ + files, + worktreeId, + route, + persistedActiveFileId: persistedActiveFileIdByWorktree[worktreeId] + }) + const parkRecoveredDraft = (draftFile: PersistedOpenFile): void => { + const parked = (recoveredDraftTabsByWorktree[worktreeId] ??= []) + parked.push(buildRecoveredDraftSnapshot(draftFile, worktreeId)) + } + for (const draftFile of healed.recoverableDrafts) { + parkRecoveredDraft(draftFile) + } + const healedFileIds = new Set() + for (const { file: pf, supersededIds, ownerNormalized } of healed.files) { // Split tabs share one OpenFile; repeated records for the same owner are corruption. if (legacyFileIndex.hasOwner(pf, worktreeId)) { + // Why: a read-only and a writable row for one path resolve to the same owned id, so + // this skip can land on the only copy of an unsaved draft. Read-only rows are exempt — + // a reopen snapshot restores writable, which a log tab must never become. + if (pf.dirtyDraftContent !== undefined && pf.readOnly !== true) { + parkRecoveredDraft(pf) + } continue } const legacyId = legacyFileIndex.resolve(pf, worktreeId) @@ -72,6 +154,17 @@ export function createHydrateEditorSession( usedOpenFileIds.add(id) // Why: map from the collision-derived legacy id; keying by filePath would collapse same-path local/runtime tabs onto the last owner to hydrate. addEditorFileIdMigration(editorFileIdMigrationsByWorktree, worktreeId, legacyId, id) + for (const supersededId of supersededIds) { + addEditorFileIdMigration( + editorFileIdMigrationsByWorktree, + worktreeId, + supersededId, + id + ) + } + if (ownerNormalized) { + healedFileIds.add(id) + } legacyFileIndex.add({ id: legacyId, filePath: pf.filePath, @@ -104,9 +197,30 @@ export function createHydrateEditorSession( pf.lastKnownDiskSignature !== undefined ? true : undefined, + externalMutation: + !isReadOnly && + pf.dirtyDraftContent !== undefined && + pf.lastKnownDiskSignature === undefined + ? 'changed' + : undefined, mode: 'edit' }) } + if (route && healedFileIds.size > 0) { + healedTabHostTargets[worktreeId] = { fileIds: healedFileIds, route } + } + // Why after the loop: drafts parked by the id-collision skip above are only known now. + const parkedDraftCount = recoveredDraftTabsByWorktree[worktreeId]?.length ?? 0 + if ( + healed.droppedCount > 0 || + healed.ownerRewrittenCount > 0 || + healed.divergentDraftGroupCount > 0 || + parkedDraftCount > 0 + ) { + console.warn( + `[editor-hydration] healed persisted editor state for ${worktreeId}: dropped ${healed.droppedCount} duplicate record(s), re-owned ${healed.ownerRewrittenCount}, kept ${healed.divergentDraftGroupCount} divergent-draft group(s) apart, ${parkedDraftCount} draft(s) retained for recovery` + ) + } } // Why: use the store's activeWorktreeId — hydrateWorkspaceSession may have nulled an invalid ID, and we must respect that. @@ -147,8 +261,10 @@ export function createHydrateEditorSession( editorFileIdMigrationsByWorktree ) // `?? {}` because an editor-only store (tests, partial slices) has no tab map at all. - const nextTabsByWorktree = + const migratedTabs = migratedTabsAndGroups.unifiedTabsByWorktree ?? s.unifiedTabsByWorktree ?? {} + const healedTabs = alignHealedEditorTabHosts(migratedTabs, healedTabHostTargets) + const nextTabsByWorktree = healedTabs ?? migratedTabs const orphanFileIdsByWorktree = collectHydratedOrphanEditorFileIds( openFiles, nextTabsByWorktree, @@ -162,12 +278,9 @@ export function createHydrateEditorSession( // Why by surviving id, not by orphan id: drafts and front-matter keys are keyed by id alone, // and an id orphaned in one worktree can still name a live document in another. const survivingFileIds = new Set(survivingFiles.map((file) => file.id)) - const survivingIds = new Set( - [...usedOpenFileIds].filter((fileId) => survivingFileIds.has(fileId)) - ) const markdownFrontmatterVisible = resolveHydratedEditorFrontmatter( persistedMarkdownFrontmatterVisible, - survivingIds, + survivingFileIds, editorFileIdMigrationsByWorktree ) @@ -184,7 +297,9 @@ export function createHydrateEditorSession( activeFileIdByWorktree: filteredActiveFileIdByWorktree, activeTabType: nextActiveTabType, activeTabTypeByWorktree: filteredActiveTabTypeByWorktree, - ...migratedTabsAndGroups + ...migratedTabsAndGroups, + ...(healedTabs ? { unifiedTabsByWorktree: healedTabs } : {}), + ...buildRecoveredDraftReopenState(s, recoveredDraftTabsByWorktree) } }) } diff --git a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts index f8c2f7b7fe96..b98d279a3d0b 100644 --- a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts +++ b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts @@ -1,14 +1,10 @@ +import { retainClosedEditorSnapshots } from './parked-recovered-editor-drafts' import { toast } from 'sonner' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { captureEditorFileOperationProvenance } from '@/lib/editor-file-operation-owner' import { getRecentlyClosedTabPosition, pushRecentlyClosedTabKind } from '../../recently-closed-tabs' import type { AppState } from '../../../types' -import { - type ClosedEditorTabSnapshot, - type EditorTabSelection, - MAX_RECENT_CLOSED_EDITOR_TABS, - type OpenFile -} from '../types/open-file' +import type { ClosedEditorTabSnapshot, EditorTabSelection, OpenFile } from '../types/open-file' import { canReuseLocalWslAlias, getReusableOpenFileModes, @@ -231,14 +227,14 @@ export function applyOpenFileToState( const position = getRecentlyClosedTabPosition(s, worktreeId, replacedPreview.id) nextRecentlyClosed = { ...s.recentlyClosedEditorTabsByWorktree, - [worktreeId]: [ + [worktreeId]: retainClosedEditorSnapshots([ { ...(snap as ClosedEditorTabSnapshot), reopenId: replacedPreview.id, ...(position ? { position } : {}) }, ...stack - ].slice(0, MAX_RECENT_CLOSED_EDITOR_TABS) + ]) } nextRecentlyClosedKinds = pushRecentlyClosedTabKind( s.recentlyClosedTabKindsByWorktree, diff --git a/src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.test.ts b/src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.test.ts new file mode 100644 index 000000000000..98d996e4a2a1 --- /dev/null +++ b/src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClosedEditorTabSnapshot } from '../types/open-file' +import { + deferRecoveredEditorDraft, + parkRecoveredEditorDrafts, + type ParkedRecoveredEditorDrafts +} from './parked-recovered-editor-drafts' + +const WORKTREE_ID = 'repo-1::/workspace' + +function snapshot(index: number): ClosedEditorTabSnapshot { + return { + filePath: `/workspace/draft-${index}.ts`, + relativePath: `draft-${index}.ts`, + worktreeId: WORKTREE_ID, + language: 'typescript', + mode: 'edit', + dirtyDraftContent: `draft ${index}` + } +} + +/** A plain close with nothing unsaved — the entry the cap may evict without losing text. */ +function savedSnapshot(index: number): ClosedEditorTabSnapshot { + const { dirtyDraftContent: _draft, ...rest } = snapshot(index) + return { ...rest, filePath: `/workspace/saved-${index}.ts`, relativePath: `saved-${index}.ts` } +} + +const EMPTY: ParkedRecoveredEditorDrafts = { + recentlyClosedEditorTabsByWorktree: {}, + recentlyClosedTabKindsByWorktree: {} +} + +describe('parkRecoveredEditorDrafts', () => { + it('pairs every parked snapshot with one cross-type reopen entry', () => { + for (const count of [1, 3, 10]) { + const snapshots = Array.from({ length: count }, (_value, index) => snapshot(index)) + + const parked = parkRecoveredEditorDrafts(EMPTY, WORKTREE_ID, snapshots) + + expect(parked.recentlyClosedEditorTabsByWorktree[WORKTREE_ID]).toHaveLength(count) + expect(parked.recentlyClosedTabKindsByWorktree[WORKTREE_ID]).toEqual( + Array.from({ length: count }, () => 'editor') + ) + } + }) + + it('preserves every recovered draft beyond ordinary history limits', () => { + const snapshots = Array.from({ length: 24 }, (_value, index) => snapshot(index)) + + const parked = parkRecoveredEditorDrafts(EMPTY, WORKTREE_ID, snapshots) + + const stack = parked.recentlyClosedEditorTabsByWorktree[WORKTREE_ID] + const kinds = parked.recentlyClosedTabKindsByWorktree[WORKTREE_ID] + expect(stack).toEqual(snapshots) + expect(kinds).toHaveLength(stack.length) + expect(kinds.every((kind) => kind === 'editor')).toBe(true) + }) + + it('keeps kinds paired with snapshots when the cap evicts what was already parked', () => { + const existing = parkRecoveredEditorDrafts( + EMPTY, + WORKTREE_ID, + Array.from({ length: 8 }, (_value, index) => snapshot(index)) + ) + + const parked = parkRecoveredEditorDrafts(existing, WORKTREE_ID, [snapshot(20), snapshot(21)]) + + expect(parked.recentlyClosedTabKindsByWorktree[WORKTREE_ID]).toHaveLength( + parked.recentlyClosedEditorTabsByWorktree[WORKTREE_ID].length + ) + }) + + it('stacks newest first on top of what the worktree already parked', () => { + const existing = parkRecoveredEditorDrafts(EMPTY, WORKTREE_ID, [snapshot(1)]) + + const parked = parkRecoveredEditorDrafts(existing, WORKTREE_ID, [snapshot(2)]) + + expect( + parked.recentlyClosedEditorTabsByWorktree[WORKTREE_ID].map((entry) => entry.filePath) + ).toEqual(['/workspace/draft-2.ts', '/workspace/draft-1.ts']) + expect(parked.recentlyClosedTabKindsByWorktree[WORKTREE_ID]).toEqual(['editor', 'editor']) + }) + + it('leaves both stacks untouched when there is nothing to park', () => { + const existing = parkRecoveredEditorDrafts(EMPTY, WORKTREE_ID, [snapshot(1)]) + + const parked = parkRecoveredEditorDrafts(existing, WORKTREE_ID, []) + + expect(parked.recentlyClosedEditorTabsByWorktree).toBe( + existing.recentlyClosedEditorTabsByWorktree + ) + expect(parked.recentlyClosedTabKindsByWorktree).toBe(existing.recentlyClosedTabKindsByWorktree) + }) +}) + +describe('deferRecoveredEditorDraft', () => { + let warn: ReturnType + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + }) + + it('appends the snapshot and its kind to the back of both stacks', () => { + const existing = parkRecoveredEditorDrafts(EMPTY, WORKTREE_ID, [snapshot(1), snapshot(2)]) + + const deferred = deferRecoveredEditorDraft(existing, WORKTREE_ID, snapshot(3)) + + expect( + deferred.recentlyClosedEditorTabsByWorktree[WORKTREE_ID].map((entry) => entry.filePath) + ).toEqual(['/workspace/draft-1.ts', '/workspace/draft-2.ts', '/workspace/draft-3.ts']) + expect(deferred.recentlyClosedTabKindsByWorktree[WORKTREE_ID]).toEqual([ + 'editor', + 'editor', + 'editor' + ]) + }) + + it('pairs a front park and a tail defer one kind entry each', () => { + const parked = parkRecoveredEditorDrafts(EMPTY, WORKTREE_ID, [snapshot(1)]) + + const deferred = deferRecoveredEditorDraft(parked, WORKTREE_ID, snapshot(2)) + + expect(deferred.recentlyClosedEditorTabsByWorktree[WORKTREE_ID]).toHaveLength( + deferred.recentlyClosedTabKindsByWorktree[WORKTREE_ID].length + ) + }) + + it('retains saved close history alongside recovered text', () => { + const full = parkRecoveredEditorDrafts(EMPTY, WORKTREE_ID, [ + ...Array.from({ length: 9 }, (_value, index) => snapshot(index)), + savedSnapshot(0) + ]) + + const deferred = deferRecoveredEditorDraft(full, WORKTREE_ID, snapshot(99)) + + const stack = deferred.recentlyClosedEditorTabsByWorktree[WORKTREE_ID] + expect(stack).toHaveLength(11) + expect(stack.at(-1)?.filePath).toBe('/workspace/draft-99.ts') + expect(stack.some((entry) => entry.filePath === '/workspace/saved-0.ts')).toBe(true) + // A kind with no snapshot behind it would make a cross-type reopen pop a missing editor. + expect(deferred.recentlyClosedTabKindsByWorktree[WORKTREE_ID]).toHaveLength(11) + }) + + it('retains a deferred draft when every existing entry holds unsaved text', () => { + const full = parkRecoveredEditorDrafts( + EMPTY, + WORKTREE_ID, + Array.from({ length: 40 }, (_value, index) => snapshot(index)) + ) + const deferred = deferRecoveredEditorDraft(full, WORKTREE_ID, snapshot(99)) + expect(deferred.recentlyClosedEditorTabsByWorktree[WORKTREE_ID]).toHaveLength(41) + expect(deferred.recentlyClosedEditorTabsByWorktree[WORKTREE_ID].at(-1)?.dirtyDraftContent).toBe( + 'draft 99' + ) + expect(warn).not.toHaveBeenCalled() + }) + + it('does not duplicate recovery copies on repeated hydration', () => { + const parked = parkRecoveredEditorDrafts(EMPTY, WORKTREE_ID, [snapshot(1)]) + expect(parkRecoveredEditorDrafts(parked, WORKTREE_ID, [snapshot(1)])).toBe(parked) + }) +}) diff --git a/src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.ts b/src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.ts new file mode 100644 index 000000000000..ded3230f535c --- /dev/null +++ b/src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.ts @@ -0,0 +1,86 @@ +import type { AppState } from '../../../types' +import { type ClosedEditorTabSnapshot, MAX_RECENT_CLOSED_EDITOR_TABS } from '../types/open-file' +import { appendRecentlyClosedTabKind, pushRecentlyClosedTabKind } from '../../recently-closed-tabs' + +export type ParkedRecoveredEditorDrafts = Pick< + AppState, + 'recentlyClosedEditorTabsByWorktree' | 'recentlyClosedTabKindsByWorktree' +> + +/** Ordinary close history may expire; recovered unsaved text must remain available. */ +export function retainClosedEditorSnapshots( + snapshots: readonly ClosedEditorTabSnapshot[] +): ClosedEditorTabSnapshot[] { + let savedCount = 0 + return snapshots.filter( + (snapshot) => + snapshot.dirtyDraftContent !== undefined || ++savedCount <= MAX_RECENT_CLOSED_EDITOR_TABS + ) +} + +function recoveryKey(snapshot: ClosedEditorTabSnapshot): string { + return JSON.stringify([ + snapshot.worktreeId, + snapshot.filePath, + snapshot.runtimeEnvironmentId ?? null, + snapshot.externalSshTargetId ?? null, + snapshot.dirtyDraftContent, + snapshot.lastKnownDiskSignature ?? null + ]) +} + +export function parkRecoveredEditorDrafts( + state: ParkedRecoveredEditorDrafts, + worktreeId: string, + snapshots: readonly ClosedEditorTabSnapshot[] +): ParkedRecoveredEditorDrafts { + const existing = state.recentlyClosedEditorTabsByWorktree[worktreeId] ?? [] + const seen = new Set( + existing.filter((entry) => entry.dirtyDraftContent !== undefined).map(recoveryKey) + ) + const additions = snapshots.filter((snapshot) => { + const key = recoveryKey(snapshot) + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) + if (additions.length === 0) { + return state + } + return { + recentlyClosedEditorTabsByWorktree: { + ...state.recentlyClosedEditorTabsByWorktree, + [worktreeId]: retainClosedEditorSnapshots([...additions, ...existing]) + }, + recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind( + state.recentlyClosedTabKindsByWorktree, + worktreeId, + 'editor', + additions.length + ) + } +} + +/** Queue a blocked draft behind other closes without discarding its only copy. */ +export function deferRecoveredEditorDraft( + state: ParkedRecoveredEditorDrafts, + worktreeId: string, + snapshot: ClosedEditorTabSnapshot +): ParkedRecoveredEditorDrafts { + return { + recentlyClosedEditorTabsByWorktree: { + ...state.recentlyClosedEditorTabsByWorktree, + [worktreeId]: retainClosedEditorSnapshots([ + ...(state.recentlyClosedEditorTabsByWorktree[worktreeId] ?? []), + snapshot + ]) + }, + recentlyClosedTabKindsByWorktree: appendRecentlyClosedTabKind( + state.recentlyClosedTabKindsByWorktree, + worktreeId, + 'editor' + ) + } +} diff --git a/src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts b/src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts index e62c8422ea88..994d92d3cff0 100644 --- a/src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts +++ b/src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts @@ -1,16 +1,13 @@ +import { reopenRecoveredEditorTab } from './reopen-recovered-editor-tab' import type { EditorGet, EditorSet } from '../types/editor-set-get' import type { EditorSlice } from '../types/editor-slice' import { createRecentlyClosedTabPositionIndex, - pushRecentlyClosedTabKind, - restoreRecentlyClosedTabPosition + pushRecentlyClosedTabKind } from '../../recently-closed-tabs' import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab' -import { - type ClosedEditorTabSnapshot, - MAX_RECENT_CLOSED_EDITOR_TABS, - type OpenFile -} from '../types/open-file' +import type { OpenFile } from '../types/open-file' +import { retainClosedEditorSnapshots } from './parked-recovered-editor-drafts' import { mayShareEditorBackingFile } from '../file-ids/editor-file-ids' import { deleteUntouchedUntitledFile, @@ -23,26 +20,7 @@ export function createRecentlyClosedEditorTabs( get: EditorGet ): Pick { return { - reopenClosedEditorTab: (worktreeId) => { - const stack = get().recentlyClosedEditorTabsByWorktree[worktreeId] ?? [] - const next = stack[0] - if (!next) { - return false - } - set((s) => ({ - recentlyClosedEditorTabsByWorktree: { - ...s.recentlyClosedEditorTabsByWorktree, - [worktreeId]: (s.recentlyClosedEditorTabsByWorktree[worktreeId] ?? []).slice(1) - } - })) - const { position, reopenId, ...file } = next - const restoredFileId = get().openFile(file, { - targetGroupId: position?.groupId, - reopenId - }) - restoreRecentlyClosedTabPosition(get, worktreeId, restoredFileId, position) - return true - }, + reopenClosedEditorTab: (worktreeId) => reopenRecoveredEditorTab(set, get, worktreeId), closeAllFiles: () => { const state = get() @@ -173,14 +151,14 @@ export function createRecentlyClosedEditorTabs( } const { id: _id, isDirty: _dirty, mirroredFromRuntimeSession: _mirrored, ...snap } = f const position = positionIndex.positionFor(f.id) - nextRecentClosed = [ + nextRecentClosed = retainClosedEditorSnapshots([ { - ...(snap as ClosedEditorTabSnapshot), + ...snap, reopenId: f.id, ...(position ? { position } : {}) }, ...nextRecentClosed - ].slice(0, MAX_RECENT_CLOSED_EDITOR_TABS) + ]) capturedCloseCount += 1 } diff --git a/src/renderer/src/store/slices/editor/actions/recovered-draft-block-notice.ts b/src/renderer/src/store/slices/editor/actions/recovered-draft-block-notice.ts new file mode 100644 index 000000000000..abf751e12166 --- /dev/null +++ b/src/renderer/src/store/slices/editor/actions/recovered-draft-block-notice.ts @@ -0,0 +1,24 @@ +import { translate } from '@/i18n/i18n' +import type { ClosedEditorTabSnapshot } from '../types/open-file' + +/** Why the record blocked the parked draft, in the wording the user needs to act on. */ +export type RecoveredDraftBlockReason = 'unsaved-rival' | 'read-only' + +/** The parked draft stayed parked; name the document and what unblocks it. */ +export function recoveredDraftBlockedMessage( + reason: RecoveredDraftBlockReason, + file: Pick +): string { + const path = file.relativePath || file.filePath + return reason === 'read-only' + ? translate( + 'auto.store.slices.editor.parkedDraftBlockedByReadOnlyTab', + '{{path}} is open read-only. Close it, then reopen to recover the parked draft.', + { path } + ) + : translate( + 'auto.store.slices.editor.parkedDraftBlockedByUnsavedChanges', + '{{path}} is open with unsaved changes. Save or close it, then reopen to recover the parked draft.', + { path } + ) +} diff --git a/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts b/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts new file mode 100644 index 000000000000..91e112e3c5cf --- /dev/null +++ b/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts @@ -0,0 +1,156 @@ +import { toast } from 'sonner' +import type { EditorGet, EditorSet } from '../types/editor-set-get' +import { restoreRecentlyClosedTabPosition } from '../../recently-closed-tabs' +import { buildEditorActiveResult } from '../tabs/editor-open-target-group' +import { deferRecoveredEditorDraft } from './parked-recovered-editor-drafts' +import { editorDocumentPathOwnerKey } from '../file-ids/editor-document-identity' +import { recoveredDraftBlockedMessage } from './recovered-draft-block-notice' +import { getReusableOpenFileModes, matchesEditorMode } from '../file-ids/editor-file-ids' + +export function reopenRecoveredEditorTab( + set: EditorSet, + get: EditorGet, + worktreeId: string +): boolean { + const stack = get().recentlyClosedEditorTabsByWorktree[worktreeId] ?? [] + const next = stack[0] + if (!next) { + return false + } + set((s) => ({ + recentlyClosedEditorTabsByWorktree: { + ...s.recentlyClosedEditorTabsByWorktree, + [worktreeId]: (s.recentlyClosedEditorTabsByWorktree[worktreeId] ?? []).slice(1) + } + })) + const { position, reopenId, dirtyDraftContent, ...file } = next + const collisionToast = (): void => { + toast.info(recoveredDraftBlockedMessage('unsaved-rival', file)) + } + const readOnlyCollisionToast = (): void => { + toast.info(recoveredDraftBlockedMessage('read-only', file)) + } + // Why both halves: setActiveFile promotes the record's unified tab inside its group, and the + // open-target result raises the editor surface the normal open path would have raised. + const activateLiveRecord = (liveFileId: string): void => { + get().setActiveFile(liveFileId) + set((s) => buildEditorActiveResult(s, file.worktreeId, liveFileId)) + } + // Why only when the record has none: a snapshot baseline older than the live record's would + // manufacture a conflict, but a dirty record with no baseline at all is unverifiable. + const adoptSnapshotDiskBaseline = (liveFileId: string): void => { + const liveFile = get().openFiles.find((f) => f.id === liveFileId) + if (!liveFile || liveFile.lastKnownDiskSignature !== undefined) { + return + } + if (next.lastKnownDiskSignature === undefined) { + get().setExternalMutation(liveFileId, 'changed') + return + } + get().setLastKnownDiskSignature(liveFileId, next.lastKnownDiskSignature) + get().setPendingDiskBaselineVerification(liveFileId, true) + } + if (dirtyDraftContent !== undefined) { + // Why decided before the open: openFile would give the live record a second unified tab in + // the snapshot's group, and the writes below would then land on the wrong document. + const beforeCollisionCheck = get() + // Why the surface-blind key: openFile's reuse rule ignores readOnly/liveTail, so a writable + // snapshot whose identity key differs from a live read-only log still lands on that record. + const identity = editorDocumentPathOwnerKey(file) + const modes = getReusableOpenFileModes(file.mode) + const matches = beforeCollisionCheck.openFiles.filter( + (candidate) => + matchesEditorMode(candidate, modes) && editorDocumentPathOwnerKey(candidate) === identity + ) + // Why writable first: a read-only twin only blocks the draft when nothing writable can hold it. + const live = matches.find((candidate) => candidate.readOnly !== true) ?? matches[0] + const liveDraft = live ? beforeCollisionCheck.editorDrafts[live.id] : undefined + if (live?.readOnly === true) { + // Why no open: openFile would reuse this record and hang a second unified tab off the + // snapshot's group, and a read-only record can hold neither the draft nor its baseline. + set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) + activateLiveRecord(live.id) + readOnlyCollisionToast() + return true + } + if (live && liveDraft === dirtyDraftContent) { + // Why nothing is written: the live record already holds this exact text, so the draft and + // baseline writes would only replace a newer baseline with the snapshot's older one. + adoptSnapshotDiskBaseline(live.id) + activateLiveRecord(live.id) + return true + } + if (live && (liveDraft !== undefined || live.isDirty === true)) { + // Why deferred rather than dropped: the snapshot holds the only copy of that unsaved text. + set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) + // Why activate: the toast names a document the user must act on, so show it to them. + activateLiveRecord(live.id) + collisionToast() + return true + } + } + // Why captured before the open: openFile's reuse rule is coarser than the identity key above + // (it also reuses across local/WSL path aliases), so a collision can still surface here. + // Why targetGroupId is still passed: only that alias case can reuse a live record here, and + // the snapshot's own group is where the user closed it from. + const beforeOpen = get() + const reusableRecordIds = new Set(beforeOpen.openFiles.map((f) => f.id)) + const draftsBeforeOpen = beforeOpen.editorDrafts + const dirtyBeforeOpen = new Set( + beforeOpen.openFiles.filter((f) => f.isDirty === true).map((f) => f.id) + ) + let restoredFileId: string + try { + restoredFileId = get().openFile(file, { + targetGroupId: position?.groupId, + reopenId + }) + } catch (error) { + set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) + throw error + } + if ( + dirtyDraftContent !== undefined && + get().openFiles.find((f) => f.id === restoredFileId)?.readOnly === true + ) { + // Why: openFile's reuse rule ignores readOnly, and setEditorDraft/markFileDirty hard no-op + // on a read-only record — writing the draft below would consume the snapshot and lose it. + set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) + readOnlyCollisionToast() + return true + } + const reusedLiveRecord = reusableRecordIds.has(restoredFileId) + const reusedRecordHasUnsavedWork = + reusedLiveRecord && + (draftsBeforeOpen[restoredFileId] !== undefined || dirtyBeforeOpen.has(restoredFileId)) + if (dirtyDraftContent !== undefined && reusedRecordHasUnsavedWork) { + if (draftsBeforeOpen[restoredFileId] === dirtyDraftContent) { + // Why nothing is written: the reused record already holds this exact text, so the draft + // and baseline writes would only replace a newer baseline with the snapshot's older one. + adoptSnapshotDiskBaseline(restoredFileId) + return true + } + // Why put the snapshot back: its buffer has nowhere to restore to yet, and dropping it here + // would destroy the only copy of that unsaved text. + set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) + collisionToast() + return true + } + // Why: a live `OpenFile` has no dirtyDraftContent — only the hydration heal parks one on a + // snapshot, for a record the restore could not give an id of its own. Reopen restores it. + if (dirtyDraftContent !== undefined) { + get().setEditorDraft(restoredFileId, dirtyDraftContent) + get().markFileDirty(restoredFileId, true) + // Why: the draft derives from the disk state this baseline was taken over, so the + // restored-tab conflict scan must re-verify it before autosave resumes. + if (next.lastKnownDiskSignature !== undefined) { + get().setLastKnownDiskSignature(restoredFileId, next.lastKnownDiskSignature) + get().setPendingDiskBaselineVerification(restoredFileId, true) + } else { + // No baseline means an automatic overwrite cannot be verified safe. + get().setExternalMutation(restoredFileId, 'changed') + } + } + restoreRecentlyClosedTabPosition(get, worktreeId, restoredFileId, position) + return true +} diff --git a/src/renderer/src/store/slices/editor/file-ids/editor-document-identity.test.ts b/src/renderer/src/store/slices/editor/file-ids/editor-document-identity.test.ts index 0188ce2a608d..01c5f1ac2555 100644 --- a/src/renderer/src/store/slices/editor/file-ids/editor-document-identity.test.ts +++ b/src/renderer/src/store/slices/editor/file-ids/editor-document-identity.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { buildPersistedEditorFileRecords } from '@/lib/workspace-session-editor-records' +import type { PersistedOpenFile } from '../../../../../../shared/workspace-session-state-types' import type { OpenFile } from '../types/open-file' +import { planHealedPersistedEditorFiles } from './hydrated-editor-owner-healing' import { editorDocumentIdentityKey } from './editor-document-identity' import { collectSameDocumentOpenFileIds } from './editor-file-ids' @@ -109,3 +111,53 @@ describe('editor document identity', () => { ) }) }) + +describe('editor document identity at the restore heal', () => { + function persistedFile(overrides: Partial = {}): PersistedOpenFile { + return { + filePath: FILE_PATH, + relativePath: 'app.ts', + worktreeId: WORKTREE_ID, + language: 'typescript', + runtimeEnvironmentId: null, + ...overrides + } + } + + function healedFiles(files: PersistedOpenFile[]): PersistedOpenFile[] { + return planHealedPersistedEditorFiles({ + files, + worktreeId: WORKTREE_ID, + route: null, + persistedActiveFileId: null + }).files.map((healed) => healed.file) + } + + it('merges records whose own worktreeId drifted away from the bucket they restore into', () => { + expect( + healedFiles([persistedFile(), persistedFile({ worktreeId: 'repo-1::/stale' })]) + ).toHaveLength(1) + }) + + it('keeps the read-only log apart from the writable record the key separates', () => { + expect(healedFiles([persistedFile(), persistedFile({ readOnly: true })])).toHaveLength(2) + }) + + it('heals the owner of a writable live-tail record, which the key treats as writable', () => { + const healed = planHealedPersistedEditorFiles({ + files: [persistedFile({ runtimeEnvironmentId: 'env-stale', liveTail: true })], + worktreeId: WORKTREE_ID, + route: { executionHostId: 'local', runtimeEnvironmentId: null }, + persistedActiveFileId: null + }) + + expect(healed.files[0].file.runtimeEnvironmentId).toBeNull() + expect(healed.ownerRewrittenCount).toBe(1) + }) + + it('keeps an ssh-pinned record apart from the worktree-local one', () => { + expect( + healedFiles([persistedFile(), persistedFile({ externalSshTargetId: 'ssh-target' })]) + ).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/store/slices/editor/file-ids/editor-document-identity.ts b/src/renderer/src/store/slices/editor/file-ids/editor-document-identity.ts index 3db6204efdcb..ce032425a475 100644 --- a/src/renderer/src/store/slices/editor/file-ids/editor-document-identity.ts +++ b/src/renderer/src/store/slices/editor/file-ids/editor-document-identity.ts @@ -17,6 +17,14 @@ export type EditorDocumentIdentityFields = { liveTail?: boolean } +/** + * Same document, ignoring the read-only/live-tail surface flags. `openFile`'s reuse rule ignores + * both, so a caller that must predict which record an open will land on compares on this key. + */ +export function editorDocumentPathOwnerKey(file: EditorDocumentIdentityFields): string { + return editorDocumentIdentityKey({ ...file, readOnly: false, liveTail: false }) +} + /** * Identity of one edit document: one path, one owner. Persisted records carry no id, so two * records sharing this key serialize to indistinguishable rows that restore as one document — diff --git a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.test.ts b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.test.ts new file mode 100644 index 000000000000..19b5f5038841 --- /dev/null +++ b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import type { Tab, TabGroup } from '../../../../../../shared/tab-types' +import { migrateHydratedEditorTabsAndGroups } from './hydrated-editor-file-ids' + +const WORKTREE_ID = 'wt-1' +const FILE_PATH = '/w/a.txt' +const RUNTIME_FILE_ID = `editor:${WORKTREE_ID}:env-1:${FILE_PATH}` + +function editorTab(id: string, entityId: string, groupId = 'g1'): Tab { + return { + id, + entityId, + groupId, + worktreeId: WORKTREE_ID, + contentType: 'editor', + label: 'a.txt', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function group(id: string, tabIds: string[], activeTabId: string): TabGroup { + return { + id, + worktreeId: WORKTREE_ID, + activeTabId, + tabOrder: tabIds, + recentTabIds: tabIds + } +} + +/** The heal redirects the runtime-owned record onto the local survivor's id. */ +const MIGRATIONS = { [WORKTREE_ID]: new Map([[RUNTIME_FILE_ID, FILE_PATH]]) } + +describe('migrateHydratedEditorTabsAndGroups', () => { + it('collapses two tabs the migration redirects onto one document in one group', () => { + const state = { + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + editorTab('tab-local', FILE_PATH), + editorTab('tab-runtime', RUNTIME_FILE_ID) + ] + }, + groupsByWorktree: { + [WORKTREE_ID]: [group('g1', ['tab-local', 'tab-runtime'], 'tab-runtime')] + } + } + + const next = migrateHydratedEditorTabsAndGroups(state, MIGRATIONS) + + expect(next.unifiedTabsByWorktree?.[WORKTREE_ID].map((tab) => tab.id)).toEqual(['tab-local']) + const healedGroup = next.groupsByWorktree?.[WORKTREE_ID][0] + expect(healedGroup?.tabOrder).toEqual(['tab-local']) + expect(healedGroup?.recentTabIds).toEqual(['tab-local']) + // Repointed at the survivor rather than nulled: the user's active tab is still that document. + expect(healedGroup?.activeTabId).toBe('tab-local') + }) + + it('keeps the merged tab pinned when the dropped twin was the pinned one', () => { + const state = { + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + editorTab('tab-local', FILE_PATH), + { ...editorTab('tab-runtime', RUNTIME_FILE_ID), isPinned: true, color: 'red' } + ] + }, + groupsByWorktree: { + [WORKTREE_ID]: [group('g1', ['tab-local', 'tab-runtime'], 'tab-runtime')] + } + } + + const next = migrateHydratedEditorTabsAndGroups(state, MIGRATIONS) + + expect(next.unifiedTabsByWorktree?.[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: 'tab-local', isPinned: true, color: 'red' }) + ]) + }) + + it('keeps one tab per group when the redirected tabs are in different groups', () => { + const state = { + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + editorTab('tab-left', FILE_PATH, 'g1'), + editorTab('tab-right', RUNTIME_FILE_ID, 'g2') + ] + }, + groupsByWorktree: { + [WORKTREE_ID]: [ + group('g1', ['tab-left'], 'tab-left'), + group('g2', ['tab-right'], 'tab-right') + ] + } + } + + const next = migrateHydratedEditorTabsAndGroups(state, MIGRATIONS) + + expect(next.unifiedTabsByWorktree?.[WORKTREE_ID].map((tab) => tab.id)).toEqual([ + 'tab-left', + 'tab-right' + ]) + // No tab died, so the groups need no repoint at all. + expect(next.groupsByWorktree).toBeUndefined() + }) + + it('leaves tabs for distinct documents alone', () => { + const state = { + unifiedTabsByWorktree: { + [WORKTREE_ID]: [editorTab('tab-a', FILE_PATH), editorTab('tab-b', '/w/b.txt')] + }, + groupsByWorktree: { + [WORKTREE_ID]: [group('g1', ['tab-a', 'tab-b'], 'tab-b')] + } + } + + const next = migrateHydratedEditorTabsAndGroups(state, MIGRATIONS) + + expect(next).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.ts b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.ts index ff5b9fbbe8e6..32189a1df2c5 100644 --- a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.ts +++ b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.ts @@ -4,6 +4,7 @@ import type { PersistedOpenFile } from '../../../../../../shared/workspace-sessi import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' import type { OpenFile } from '../types/open-file' import { isEditorTabContentType } from '../tabs/editor-tab-content-type' +import { dedupeEditorTabsWithinGroups } from '../../tab-group-state' import { buildOwnedEditorFileId } from './editor-file-ids' import { runtimeOwnerKey } from './editor-document-identity' @@ -126,6 +127,46 @@ export function areStringArraysEqual( return a.every((value, index) => value === b[index]) } +/** + * Move a dropped twin's pin and labelling onto the tab the dedupe keeps. The dedupe survivor is + * simply the first tab in array order, so a pinned runtime-owned twin merged into an unpinned local + * one would come back unpinned — undoing the protection that keeps a pinned sibling open. + */ +function carryEditorTwinDecorationOntoSurvivor(tabs: Tab[]): Tab[] { + const survivorIndexByGroupAndEntity = new Map() + let merged: Tab[] | null = null + tabs.forEach((tab, index) => { + if (tab.contentType !== 'editor') { + return + } + const key = `${tab.groupId}\u0000${tab.entityId}` + const survivorIndex = survivorIndexByGroupAndEntity.get(key) + if (survivorIndex === undefined) { + survivorIndexByGroupAndEntity.set(key, index) + return + } + const survivor = merged?.[survivorIndex] ?? tabs[survivorIndex] + const decorated: Tab = { + ...survivor, + ...(tab.isPinned === true && survivor.isPinned !== true ? { isPinned: true } : {}), + ...(survivor.customLabel == null && tab.customLabel != null + ? { customLabel: tab.customLabel } + : {}), + ...(survivor.color == null && tab.color != null ? { color: tab.color } : {}) + } + if ( + decorated.isPinned === survivor.isPinned && + decorated.customLabel === survivor.customLabel && + decorated.color === survivor.color + ) { + return + } + merged ??= [...tabs] + merged[survivorIndex] = decorated + }) + return merged ?? tabs +} + export function migrateHydratedEditorTabsAndGroups( state: Pick, migrationsByWorktree: Record> @@ -134,6 +175,7 @@ export function migrateHydratedEditorTabsAndGroups( let groupsChanged = false const nextUnifiedTabsByWorktree: Record = { ...state.unifiedTabsByWorktree } const tabIdMigrationsByWorktree: Record> = {} + const dedupeAliasesByWorktree: Record>> = {} for (const [worktreeId, idMigrations] of Object.entries(migrationsByWorktree)) { const tabs = state.unifiedTabsByWorktree[worktreeId] @@ -160,30 +202,42 @@ export function migrateHydratedEditorTabsAndGroups( if (tabIdMigrations.size > 0) { tabIdMigrationsByWorktree[worktreeId] = tabIdMigrations } - nextUnifiedTabsByWorktree[worktreeId] = nextTabs + // Why here and not in tabs hydration: that dedupe ran before this rewrite, so a local tab and a + // runtime-owned tab for one path were still two entities. Redirecting both onto the survivor's + // id makes them one document in one group, and a repeated React key leaves a ghost row mounted. + const deduped = dedupeEditorTabsWithinGroups(carryEditorTwinDecorationOntoSurvivor(nextTabs)) + if (deduped.tabs.length !== nextTabs.length) { + tabsChanged = true + dedupeAliasesByWorktree[worktreeId] = deduped.tabIdAliasesByGroup + } + nextUnifiedTabsByWorktree[worktreeId] = deduped.tabs } const nextGroupsByWorktree: Record = { ...state.groupsByWorktree } - for (const [worktreeId, tabIdMigrations] of Object.entries(tabIdMigrationsByWorktree)) { + const repointedWorktreeIds = new Set([ + ...Object.keys(tabIdMigrationsByWorktree), + ...Object.keys(dedupeAliasesByWorktree) + ]) + for (const worktreeId of repointedWorktreeIds) { const groups = state.groupsByWorktree[worktreeId] if (!groups) { continue } + const tabIdMigrations = tabIdMigrationsByWorktree[worktreeId] + const dedupeAliasesByGroup = dedupeAliasesByWorktree[worktreeId] const validTabIds = new Set((nextUnifiedTabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) nextGroupsByWorktree[worktreeId] = groups.map((group) => { - const tabOrder = dedupeEditorTabOrder( - group.tabOrder.map((tabId) => tabIdMigrations.get(tabId) ?? tabId), - validTabIds - ) - const activeTabId = group.activeTabId - ? (tabIdMigrations.get(group.activeTabId) ?? group.activeTabId) - : null + const dedupeAliases = dedupeAliasesByGroup?.get(group.id) + // Why this order: the dedupe ran on already-migrated tabs, so its aliases key on migrated ids. + const canonicalTabId = (tabId: string): string => { + const migrated = tabIdMigrations?.get(tabId) ?? tabId + return dedupeAliases?.get(migrated) ?? migrated + } + const tabOrder = dedupeEditorTabOrder(group.tabOrder.map(canonicalTabId), validTabIds) + const activeTabId = group.activeTabId ? canonicalTabId(group.activeTabId) : null const validActiveTabId = activeTabId && validTabIds.has(activeTabId) ? activeTabId : null const recentTabIds = group.recentTabIds - ? dedupeEditorTabOrder( - group.recentTabIds.map((tabId) => tabIdMigrations.get(tabId) ?? tabId), - validTabIds - ) + ? dedupeEditorTabOrder(group.recentTabIds.map(canonicalTabId), validTabIds) : group.recentTabIds if ( validActiveTabId === group.activeTabId && diff --git a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-owner-healing.ts b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-owner-healing.ts new file mode 100644 index 000000000000..fbf21e8bed24 --- /dev/null +++ b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-owner-healing.ts @@ -0,0 +1,351 @@ +import type { Tab } from '../../../../../../shared/tab-types' +import type { PersistedOpenFile } from '../../../../../../shared/workspace-session-state-types' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../../../shared/workspace-scope' +import { + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' +import { findFolderWorkspaceOwner } from '@/lib/folder-workspace-runtime-owner' +import { isExecutionHostAliasForWorktree } from '@/lib/worktree-execution-host-alias' +import { findIndexedProjectGroupOwner } from '@/lib/worktree-runtime-owner-index' +import { + resolveExplicitWorktreeOperationRouteResult, + type WorktreeOperationRoute, + type WorktreeOperationRouteState +} from '@/lib/worktree-operation-route' +import { isEditorTabContentType } from '../tabs/editor-tab-content-type' +import { buildOwnedEditorFileId } from './editor-file-ids' +import { editorDocumentIdentityKey, runtimeOwnerKey } from './editor-document-identity' + +export type HealedPersistedEditorFile = { + /** Survivor record, carrying the route owner when the heal applied. */ + file: PersistedOpenFile + /** Ids the merged-away duplicates would have restored under. */ + supersededIds: readonly string[] + /** The record now follows the worktree route, so its tabs may be re-stamped too. */ + ownerNormalized: boolean +} + +export type HealedPersistedEditorFilePlan = { + files: HealedPersistedEditorFile[] + /** + * Drafts that cannot restore as their own tab: the persisted schema identifies a document by + * (owner, path), so a second record with the same owner has no id of its own. Their content is + * handed back for the reopen stack instead of being dropped on the floor. + */ + recoverableDrafts: PersistedOpenFile[] + droppedCount: number + ownerRewrittenCount: number + divergentDraftGroupCount: number +} + +const stampedRouteStateCache = new WeakMap< + WorktreeOperationRouteState, + WorktreeOperationRouteState +>() + +/** One focus-suppressed copy per store snapshot, so a many-workspace hydration re-spreads nothing. */ +function stampedRouteState(state: WorktreeOperationRouteState): WorktreeOperationRouteState { + const cached = stampedRouteStateCache.get(state) + if (cached) { + return cached + } + const next = { ...state, activeWorkspaceExecutionHostId: null, settings: null } + stampedRouteStateCache.set(state, next) + return next +} + +function routeForStampedHost(hostId: string | null | undefined): WorktreeOperationRoute | null { + const parsed = parseExecutionHostId(hostId) + return parsed + ? { + executionHostId: parsed.id, + runtimeEnvironmentId: parsed.kind === 'runtime' ? parsed.environmentId : null + } + : null +} + +/** + * A folder workspace's owner, taken only from evidence someone wrote down: the folder or project + * group row's host, its SSH connection, or the per-host session partition it restored from. + * `getExecutionHostIdForFolderWorkspace` cannot be used here — it defaults to `local`, and a + * default is not evidence that the records are local. + */ +function resolveStampedFolderWorkspaceRoute( + state: WorktreeOperationRouteState, + folderWorkspaceId: string +): WorktreeOperationRoute | null { + const folderWorkspace = findFolderWorkspaceOwner(state, folderWorkspaceId) + if (!folderWorkspace) { + return null + } + const projectGroup = findIndexedProjectGroupOwner( + state.projectGroups, + folderWorkspace.projectGroupId + ) + const connectionId = + folderWorkspace.connectionId?.trim() || projectGroup?.connectionId?.trim() || null + return ( + routeForStampedHost(folderWorkspace.executionHostId ?? projectGroup?.executionHostId) ?? + (connectionId ? routeForStampedHost(toSshExecutionHostId(connectionId)) : null) ?? + routeForStampedHost( + state.restoredRuntimeHostIdByWorkspaceSessionKey?.[folderWorkspaceKey(folderWorkspaceId)] + ) + ) +} + +/** + * The owner a worktree's editor records must agree with, or `null` when nothing stamped can + * place it. Both focus signals — the active workspace's host selection and the focused runtime + * environment — are suppressed, because UI focus leaking into ownership is what wrote the + * corrupt owners this heal removes. Git ids resolve from stamped catalog rows only; the legacy + * unstamped fallbacks below them are inference, and inference is not a licence to rewrite an + * owner. Folder workspaces have no catalog, so their own owner record plays that role. + */ +export function resolveHealableWorktreeOwnerRoute( + state: WorktreeOperationRouteState, + worktreeId: string +): WorktreeOperationRoute | null { + const stampedState = stampedRouteState(state) + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + return resolveStampedFolderWorkspaceRoute(stampedState, workspaceScope.folderWorkspaceId) + } + const resolution = resolveExplicitWorktreeOperationRouteResult(stampedState, worktreeId) + return resolution.kind === 'resolved' ? resolution.route : null +} + +/** Why no liveTail term: a pinned read-only log and an SSH-pinned external path name their own host, + * and `editorDocumentIdentityKey` only reads liveTail on a read-only row, already excluded here. */ +function isOwnerHealable(file: PersistedOpenFile): boolean { + return file.readOnly !== true && !file.externalSshTargetId?.trim() +} + +function restoredIdCandidates(file: PersistedOpenFile, worktreeId: string): string[] { + return [ + file.filePath, + buildOwnedEditorFileId(file.filePath, worktreeId, file.runtimeEnvironmentId) + ] +} + +function pickSurvivor( + files: readonly PersistedOpenFile[], + worktreeId: string, + persistedActiveFileId: string | null | undefined +): PersistedOpenFile { + const drafted = files.find((file) => file.dirtyDraftContent !== undefined) + if (drafted) { + // Why the equal-draft scan: same text means the same document, so keep the copy that still + // carries the disk baseline — the restored draft has nothing to verify against without it. + return ( + files.find( + (file) => + file.dirtyDraftContent === drafted.dirtyDraftContent && + file.lastKnownDiskSignature !== undefined + ) ?? drafted + ) + } + const active = + persistedActiveFileId != null + ? files.find((file) => restoredIdCandidates(file, worktreeId).includes(persistedActiveFileId)) + : undefined + return active ?? files[0] +} + +function countDistinctDrafts(files: readonly PersistedOpenFile[]): number { + return new Set( + files.flatMap((file) => (file.dirtyDraftContent !== undefined ? [file.dirtyDraftContent] : [])) + ).size +} + +function groupByIdentity( + files: readonly PersistedOpenFile[], + worktreeId: string, + ownerOf: (file: PersistedOpenFile) => string | null +): Map { + const groups = new Map() + for (const file of files) { + // Why the bucket's worktreeId, not the record's: a record whose own worktreeId drifted is + // exactly the corruption this heal merges, so it must not split the group. + const key = editorDocumentIdentityKey({ ...file, worktreeId }, ownerOf(file)) + const group = groups.get(key) + if (group) { + group.push(file) + continue + } + groups.set(key, [file]) + } + return groups +} + +/** + * Collapse one worktree's persisted editor records onto one survivor per document, normalizing + * owners to the worktree's resolved route. Duplicates whose unsaved drafts disagree are kept + * apart instead: a merge would destroy one of them. + */ +export function planHealedPersistedEditorFiles(args: { + files: readonly PersistedOpenFile[] + worktreeId: string + route: WorktreeOperationRoute | null + persistedActiveFileId: string | null | undefined +}): HealedPersistedEditorFilePlan { + const { files, worktreeId, route, persistedActiveFileId } = args + const verbatimOwner = (file: PersistedOpenFile): string | null => + runtimeOwnerKey(file.runtimeEnvironmentId) + const healedOwner = (file: PersistedOpenFile): string | null => + route && isOwnerHealable(file) + ? runtimeOwnerKey(route.runtimeEnvironmentId) + : verbatimOwner(file) + + const survivors: { + file: PersistedOpenFile + /** Records whose restored ids must be redirected onto the survivor — merged siblings, plus + * the survivor's own pre-heal identity when the route re-owned it. */ + superseded: PersistedOpenFile[] + droppedCount: number + ownerRewritten: boolean + }[] = [] + let divergentDraftGroupCount = 0 + const recoverableDrafts: PersistedOpenFile[] = [] + for (const group of groupByIdentity(files, worktreeId, healedOwner).values()) { + if (countDistinctDrafts(group) > 1) { + divergentDraftGroupCount += 1 + for (const verbatimGroup of groupByIdentity(group, worktreeId, verbatimOwner).values()) { + const file = pickSurvivor(verbatimGroup, worktreeId, persistedActiveFileId) + const superseded = verbatimGroup.filter((entry) => entry !== file) + // Same owner and path as the survivor: no second id exists, so the draft leaves as a reopen snapshot. + for (const entry of superseded) { + if ( + entry.dirtyDraftContent !== undefined && + entry.dirtyDraftContent !== file.dirtyDraftContent && + // Why: a read-only row must restore clean, and the reopen snapshot drops readOnly/liveTail + // and reopens as 'edit' — parking its draft would hand back a writable dirty log tab. + entry.readOnly !== true + ) { + recoverableDrafts.push(entry) + } + } + survivors.push({ + file, + superseded, + droppedCount: superseded.length, + ownerRewritten: false + }) + } + continue + } + const picked = pickSurvivor(group, worktreeId, persistedActiveFileId) + const owner = healedOwner(picked) + const ownerRewritten = group.some((entry) => verbatimOwner(entry) !== owner) + const dropped = group.filter((entry) => entry !== picked) + survivors.push({ + file: owner === verbatimOwner(picked) ? picked : { ...picked, runtimeEnvironmentId: owner }, + superseded: owner === verbatimOwner(picked) ? dropped : [picked, ...dropped], + droppedCount: dropped.length, + ownerRewritten + }) + } + + const survivorCountByPath = new Map() + for (const { file } of survivors) { + survivorCountByPath.set(file.filePath, (survivorCountByPath.get(file.filePath) ?? 0) + 1) + } + let droppedCount = 0 + let ownerRewrittenCount = 0 + const healedFiles = survivors.map((survivor) => { + const { file, superseded, ownerRewritten } = survivor + droppedCount += survivor.droppedCount + // Why the owner comparison: a divergent-draft survivor keeps its verbatim owner, so re-stamping + // its tabs would make them describe a record that still follows a different route. + const ownerNormalized = + route !== null && + isOwnerHealable(file) && + runtimeOwnerKey(file.runtimeEnvironmentId) === runtimeOwnerKey(route.runtimeEnvironmentId) + if (ownerRewritten) { + ownerRewrittenCount += 1 + } + const supersededIds = superseded.flatMap((entry) => + restoredIdCandidates(entry, worktreeId).filter( + // Why: a plain path id is only unambiguous while one survivor owns that path. + (id) => id !== entry.filePath || survivorCountByPath.get(entry.filePath) === 1 + ) + ) + return { file, supersededIds, ownerNormalized } + }) + + return { + files: healedFiles, + recoverableDrafts, + droppedCount, + ownerRewrittenCount, + divergentDraftGroupCount + } +} + +/** + * A stamp the route already names. An SSH worktree reached through a paired HUB routes as + * `{ ssh:conn, runtime:hub }`, and `runtime:hub` is a correct stamp for it — equality alone would + * rewrite those tabs on every hydration. A route with no host names no alias, so its tabs are + * unstamped as before. + */ +function isEditorTabHostOnRoute( + executionHostId: ExecutionHostId, + route: WorktreeOperationRoute +): boolean { + return ( + route.executionHostId !== null && + isExecutionHostAliasForWorktree(executionHostId, { + hostId: route.executionHostId, + runtimeOwnerEnvironmentId: route.runtimeEnvironmentId ?? undefined + }) + ) +} + +function withEditorTabExecutionHost(tab: Tab, executionHostId: ExecutionHostId | null): Tab { + const { executionHostId: previousHostId, ...withoutHost } = tab + void previousHostId + return executionHostId ? { ...withoutHost, executionHostId } : withoutHost +} + +export type HealedEditorTabHostTarget = { + fileIds: ReadonlySet + route: WorktreeOperationRoute +} + +/** + * Re-stamp editor tabs that name a host the worktree's route contradicts. Such a tab keeps + * failing `isUnifiedTabOwnedByWorktree` for its own worktree. Unstamped tabs stay unstamped. + */ +export function alignHealedEditorTabHosts( + tabsByWorktree: Record, + targetsByWorktree: Record +): Record | null { + let anyChanged = false + const next: Record = { ...tabsByWorktree } + for (const [worktreeId, { fileIds, route }] of Object.entries(targetsByWorktree)) { + const tabs = tabsByWorktree[worktreeId] + if (!tabs) { + continue + } + let changed = false + const nextTabs = tabs.map((tab) => { + if ( + !isEditorTabContentType(tab.contentType) || + !fileIds.has(tab.entityId) || + // Why: only a stamp that contradicts the route is corrected — stamping an unstamped tab is a different change. + !tab.executionHostId || + isEditorTabHostOnRoute(tab.executionHostId, route) + ) { + return tab + } + changed = true + return withEditorTabExecutionHost(tab, route.executionHostId) + }) + if (changed) { + anyChanged = true + next[worktreeId] = nextTabs + } + } + return anyChanged ? next : null +} diff --git a/src/renderer/src/store/slices/editor/types/open-file.ts b/src/renderer/src/store/slices/editor/types/open-file.ts index 0f096127b291..5ff364c48eec 100644 --- a/src/renderer/src/store/slices/editor/types/open-file.ts +++ b/src/renderer/src/store/slices/editor/types/open-file.ts @@ -164,6 +164,9 @@ export type ClosedEditorTabSnapshot = Omit< > & { reopenId?: string position?: RecentlyClosedTabPosition + /** Unsaved buffer to restore with the tab, parked by the hydration heal for a persisted record + * that had no id of its own to restore under. A close never sets it. */ + dirtyDraftContent?: string } export const MAX_RECENT_CLOSED_EDITOR_TABS = 10 diff --git a/src/renderer/src/store/slices/recently-closed-tabs.test.ts b/src/renderer/src/store/slices/recently-closed-tabs.test.ts index 8ca10f494e70..d2739ee3574f 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.test.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.test.ts @@ -43,6 +43,7 @@ import { makeUnifiedTab } from './store-test-helpers' import { + appendRecentlyClosedTabKind, pushRecentlyClosedTabKind, remapClosedTerminalTabSnapshotCwds, restoreRecentlyClosedTabPosition @@ -130,6 +131,19 @@ describe('recently-closed history bounds', () => { expect(result[WT]).toHaveLength(30) expect(result[WT]?.every((kind) => kind === 'editor')).toBe(true) }) + + it('appends a kind behind the existing ones and drops it rather than a newer close at the cap', () => { + expect(appendRecentlyClosedTabKind({ [WT]: ['terminal'] }, WT, 'editor')[WT]).toEqual([ + 'terminal', + 'editor' + ]) + + const full = pushRecentlyClosedTabKind({}, WT, 'terminal', 30) + const appended = appendRecentlyClosedTabKind(full, WT, 'editor') + + expect(appended[WT]).toHaveLength(30) + expect(appended[WT]?.every((kind) => kind === 'terminal')).toBe(true) + }) }) describe('terminal snapshot cwd remapping', () => { diff --git a/src/renderer/src/store/slices/recently-closed-tabs.ts b/src/renderer/src/store/slices/recently-closed-tabs.ts index 764470c892c3..ed7ac6f77bf2 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.ts @@ -75,6 +75,19 @@ export function pushRecentlyClosedTabKind( } } +/** Why the tail: a kind whose snapshot cannot be restored yet must queue behind the user's recent + * closes instead of blocking every other tab type's reopen at the head. */ +export function appendRecentlyClosedTabKind( + map: Record | undefined, + worktreeId: string, + kind: RecentlyClosedTabKind +): Record { + return { + ...map, + [worktreeId]: [...(map?.[worktreeId] ?? []), kind].slice(0, MAX_RECENT_CLOSED_TAB_KINDS) + } +} + export function remapClosedTerminalTabSnapshotCwds( snapshots: ClosedTerminalTabSnapshot[], oldWorktreePath: string, @@ -187,7 +200,22 @@ export const createRecentlyClosedTabsSlice: StateCreator< } }) if (!kind) { - return false + const stack = get().recentlyClosedEditorTabsByWorktree[worktreeId] ?? [] + const recoveredIndex = stack.findIndex((entry) => entry.dirtyDraftContent !== undefined) + if (recoveredIndex === -1) { + return false + } + // Recovered text outlives bounded cross-type history. + set((s) => ({ + recentlyClosedEditorTabsByWorktree: { + ...s.recentlyClosedEditorTabsByWorktree, + [worktreeId]: [ + stack[recoveredIndex], + ...stack.filter((_entry, index) => index !== recoveredIndex) + ] + } + })) + return get().reopenClosedEditorTab(worktreeId) } const reopened = kind === 'terminal' diff --git a/src/renderer/src/store/slices/stale-editor-tab-owner-healing.test.ts b/src/renderer/src/store/slices/stale-editor-tab-owner-healing.test.ts new file mode 100644 index 000000000000..9feee66d3d47 --- /dev/null +++ b/src/renderer/src/store/slices/stale-editor-tab-owner-healing.test.ts @@ -0,0 +1,533 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Tab } from '../../../../shared/tab-types' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../../../shared/workspace-session-state-types' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { buildEditorSessionData } from '@/lib/workspace-session' +import { + resolveWorktreeOperationRouteResult, + type WorktreeOperationRouteState +} from '@/lib/worktree-operation-route' +import { + alignHealedEditorTabHosts, + resolveHealableWorktreeOwnerRoute +} from './editor/file-ids/hydrated-editor-owner-healing' +import type { OpenFile } from './editor' +import { createStoreSessionMockApi } from './store-session-test-harness' +import { createTestStore, makeWorktree } from './store-test-helpers' +import { + buildStaleEditorTabSession, + STALE_TAB_BRAND_COMPOSITE_TAB_ID, + STALE_TAB_BRAND_PATH, + STALE_TAB_BRAND_PLAIN_TAB_ID, + STALE_TAB_CSV_PATH, + STALE_TAB_CSV_TAB_ID, + STALE_TAB_REPO_ID, + STALE_TAB_RUNTIME_ENV_ID, + STALE_TAB_WORKTREE_ID, + STALE_TAB_WORKTREE_PATH +} from './stale-editor-tab-resurrection-fixture' + +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 RUNTIME_HOST_ID: ExecutionHostId = `runtime:${STALE_TAB_RUNTIME_ENV_ID}` + +function prepareStore(hostIds: readonly (ExecutionHostId | undefined)[]) { + const store = createTestStore() + store.setState({ + repos: [ + { + id: STALE_TAB_REPO_ID, + path: STALE_TAB_WORKTREE_PATH, + displayName: 'eppo', + badgeColor: '#000', + addedAt: 0 + } + ], + worktreesByRepo: { + [STALE_TAB_REPO_ID]: hostIds.map((hostId) => + makeWorktree({ + id: STALE_TAB_WORKTREE_ID, + repoId: STALE_TAB_REPO_ID, + path: STALE_TAB_WORKTREE_PATH, + hostId + }) + ) + }, + activeWorktreeId: STALE_TAB_WORKTREE_ID, + // The focus selection that stamped the corrupt rows must not decide the heal. + activeWorkspaceExecutionHostId: RUNTIME_HOST_ID + }) + return store +} + +function hydrate(store: ReturnType, session: WorkspaceSessionState) { + store.getState().hydrateTabsSession(session) + store.getState().hydrateEditorSession(session) + return store.getState() +} + +describe('stale editor tab owner healing', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('collapses runtime-stamped duplicates onto the local route of a local worktree', () => { + const state = hydrate(prepareStore(['local']), buildStaleEditorTabSession()) + + expect(state.openFiles.map((file) => [file.filePath, file.runtimeEnvironmentId])).toEqual([ + [STALE_TAB_CSV_PATH, null], + [STALE_TAB_BRAND_PATH, null] + ]) + expect(state.openFiles.map((file) => file.id)).toEqual([ + STALE_TAB_CSV_PATH, + STALE_TAB_BRAND_PATH + ]) + const tabs = state.unifiedTabsByWorktree[STALE_TAB_WORKTREE_ID] ?? [] + expect(tabs.map((tab) => tab.entityId)).toEqual([STALE_TAB_CSV_PATH, STALE_TAB_BRAND_PATH]) + expect(tabs.map((tab) => tab.executionHostId)).toEqual(['local', 'local']) + const group = state.groupsByWorktree[STALE_TAB_WORKTREE_ID][0] + const tabIds = new Set(tabs.map((tab) => tab.id)) + expect(group.tabOrder.every((tabId) => tabIds.has(tabId))).toBe(true) + expect(group.recentTabIds?.every((tabId) => tabIds.has(tabId))).toBe(true) + expect(state.activeFileIdByWorktree[STALE_TAB_WORKTREE_ID]).toBe(STALE_TAB_BRAND_PATH) + }) + + it('redirects the tab of a single re-owned record instead of orphaning it', () => { + const session = buildStaleEditorTabSession() + const records = session.openFilesByWorktree![STALE_TAB_WORKTREE_ID]! + session.openFilesByWorktree![STALE_TAB_WORKTREE_ID] = [records[0]] + const tabs = session.unifiedTabs![STALE_TAB_WORKTREE_ID]! + session.unifiedTabs![STALE_TAB_WORKTREE_ID] = [{ ...tabs[0], entityId: STALE_TAB_CSV_TAB_ID }] + const state = hydrate(prepareStore(['local']), session) + + expect(state.openFiles.map((file) => [file.id, file.runtimeEnvironmentId])).toEqual([ + [STALE_TAB_CSV_PATH, null] + ]) + expect( + (state.unifiedTabsByWorktree[STALE_TAB_WORKTREE_ID] ?? []).map((tab) => tab.entityId) + ).toEqual([STALE_TAB_CSV_PATH]) + }) + + it('leaves an unstamped editor tab unstamped while healing its owner', () => { + const session = buildStaleEditorTabSession() + session.unifiedTabs![STALE_TAB_WORKTREE_ID] = session.unifiedTabs![STALE_TAB_WORKTREE_ID]!.map( + ({ executionHostId: _stampedByFocus, ...tab }) => tab + ) + const state = hydrate(prepareStore(['local']), session) + + const tabs = state.unifiedTabsByWorktree[STALE_TAB_WORKTREE_ID] ?? [] + expect(tabs.map((tab) => tab.executionHostId)).toEqual([undefined, undefined]) + expect(state.openFiles.map((file) => file.runtimeEnvironmentId)).toEqual([null, null]) + }) + + it('collapses onto the runtime owner when the worktree really is runtime-owned', () => { + const state = hydrate(prepareStore([RUNTIME_HOST_ID]), buildStaleEditorTabSession()) + + expect(state.openFiles.map((file) => [file.filePath, file.runtimeEnvironmentId])).toEqual([ + [STALE_TAB_CSV_PATH, STALE_TAB_RUNTIME_ENV_ID], + [STALE_TAB_BRAND_PATH, STALE_TAB_RUNTIME_ENV_ID] + ]) + const tabs = state.unifiedTabsByWorktree[STALE_TAB_WORKTREE_ID] ?? [] + expect(tabs.map((tab) => tab.executionHostId)).toEqual([RUNTIME_HOST_ID, RUNTIME_HOST_ID]) + const openFileIds = new Set(state.openFiles.map((file) => file.id)) + expect(tabs.every((tab) => openFileIds.has(tab.entityId))).toBe(true) + }) + + it('keeps persisted owners verbatim when the worktree route is ambiguous', () => { + const state = hydrate(prepareStore(['local', RUNTIME_HOST_ID]), buildStaleEditorTabSession()) + + // The runtime-stamped csv keeps its persisted owner: an unplaceable worktree is no evidence. + expect(state.openFiles.map((file) => [file.filePath, file.runtimeEnvironmentId])).toEqual([ + [STALE_TAB_CSV_PATH, STALE_TAB_RUNTIME_ENV_ID], + [STALE_TAB_BRAND_PATH, null] + ]) + const tabs = state.unifiedTabsByWorktree[STALE_TAB_WORKTREE_ID] ?? [] + expect(tabs.map((tab) => tab.executionHostId)).toEqual([RUNTIME_HOST_ID, RUNTIME_HOST_ID]) + }) + + it('never rewrites a read-only live-tail log pinned to the client-local host', () => { + const session = buildStaleEditorTabSession() + session.openFilesByWorktree![STALE_TAB_WORKTREE_ID] = [ + { + filePath: '/Users/tester/.orca/logs/session.log', + relativePath: '/Users/tester/.orca/logs/session.log', + worktreeId: STALE_TAB_WORKTREE_ID, + language: 'plaintext', + runtimeEnvironmentId: null, + readOnly: true, + liveTail: true + } + ] + const state = hydrate(prepareStore([RUNTIME_HOST_ID]), session) + + expect(state.openFiles.map((file) => file.runtimeEnvironmentId)).toEqual([null]) + }) + + it('keeps both records when two duplicates carry divergent unsaved drafts', () => { + const session = buildStaleEditorTabSession() + const [csv, brandLocal, brandRuntime] = session.openFilesByWorktree![STALE_TAB_WORKTREE_ID] + session.openFilesByWorktree![STALE_TAB_WORKTREE_ID] = [ + csv, + { ...brandLocal, dirtyDraftContent: 'draft from the local pane' }, + { ...brandRuntime, dirtyDraftContent: 'draft from the runtime pane' } + ] + const state = hydrate(prepareStore(['local']), session) + + expect(state.openFiles.map((file) => [file.filePath, file.runtimeEnvironmentId])).toEqual([ + [STALE_TAB_CSV_PATH, null], + [STALE_TAB_BRAND_PATH, null], + [STALE_TAB_BRAND_PATH, STALE_TAB_RUNTIME_ENV_ID] + ]) + expect(Object.values(state.editorDrafts).sort()).toEqual([ + 'draft from the local pane', + 'draft from the runtime pane' + ]) + }) + + it('never re-stamps the tab of a divergent-draft survivor that kept its own owner', () => { + const session = buildStaleEditorTabSession() + const [csv, brandLocal, brandRuntime] = session.openFilesByWorktree![STALE_TAB_WORKTREE_ID] + session.openFilesByWorktree![STALE_TAB_WORKTREE_ID] = [ + csv, + { ...brandLocal, dirtyDraftContent: 'draft from the local pane' }, + { ...brandRuntime, dirtyDraftContent: 'draft from the runtime pane' } + ] + const tabs = session.unifiedTabs![STALE_TAB_WORKTREE_ID]! + // The runtime pane's tab names that record's own owned id, so it follows the runtime survivor. + session.unifiedTabs![STALE_TAB_WORKTREE_ID] = tabs.map((tab) => + tab.id === STALE_TAB_BRAND_COMPOSITE_TAB_ID + ? { ...tab, entityId: STALE_TAB_BRAND_COMPOSITE_TAB_ID } + : tab + ) + const state = hydrate(prepareStore(['local']), session) + + const stampByTabId = new Map( + (state.unifiedTabsByWorktree[STALE_TAB_WORKTREE_ID] ?? []).map((tab) => [ + tab.id, + tab.executionHostId ?? null + ]) + ) + expect(stampByTabId.get(STALE_TAB_BRAND_COMPOSITE_TAB_ID)).toBe(RUNTIME_HOST_ID) + expect(stampByTabId.get(STALE_TAB_BRAND_PLAIN_TAB_ID)).toBe('local') + }) + + it('survives a write/restore round trip of two same-owner divergent drafts', () => { + const liveFile = (id: string, isDirty: boolean): OpenFile => ({ + id, + filePath: STALE_TAB_BRAND_PATH, + relativePath: 'sandbox/2026-09-16-listings-survey/handout/brand-requests.txt', + worktreeId: STALE_TAB_WORKTREE_ID, + language: 'plaintext', + mode: 'edit', + isDirty, + isPreview: false, + runtimeEnvironmentId: null, + lastKnownDiskSignature: 'sig-1' + }) + const written = buildEditorSessionData( + [liveFile(STALE_TAB_BRAND_PATH, true), liveFile('editor:second-pane', true)], + { [STALE_TAB_BRAND_PATH]: 'left text', 'editor:second-pane': 'right text' }, + {}, + {}, + {} + ) + // The writer keeps both rows; the restore has one id for them, so the loser must stay recoverable. + expect(written.openFilesByWorktree?.[STALE_TAB_WORKTREE_ID]).toHaveLength(2) + + const session = buildStaleEditorTabSession() + session.openFilesByWorktree = written.openFilesByWorktree + const store = prepareStore(['local']) + const state = hydrate(store, session) + + const restored = state.openFiles.find((file) => file.filePath === STALE_TAB_BRAND_PATH) + expect(state.editorDrafts[restored!.id]).toBe('left text') + expect(state.recentlyClosedEditorTabsByWorktree[STALE_TAB_WORKTREE_ID]).toEqual([ + expect.objectContaining({ + filePath: STALE_TAB_BRAND_PATH, + dirtyDraftContent: 'right text', + lastKnownDiskSignature: 'sig-1' + }) + ]) + + // Once the user saves the survivor, reopen recovers the parked draft onto that record and must + // re-verify the baseline it derives from, exactly as hydrating a dirty record does. + store.getState().clearEditorDraft(restored!.id) + store.getState().markFileDirty(restored!.id, false) + // The saved tab re-baselined off its own disk read, and its verification already cleared. + store.getState().setLastKnownDiskSignature(restored!.id, 'sig-2') + store.getState().clearPendingDiskBaselineVerification(restored!.id) + expect(store.getState().reopenClosedEditorTab(STALE_TAB_WORKTREE_ID)).toBe(true) + + expect( + store.getState().openFiles.find((file) => file.filePath === STALE_TAB_BRAND_PATH) + ).toMatchObject({ + lastKnownDiskSignature: 'sig-1', + pendingDiskBaselineVerification: true + }) + }) + + it('keeps the only unsaved draft among clean duplicates', () => { + const session = buildStaleEditorTabSession() + const records = session.openFilesByWorktree![STALE_TAB_WORKTREE_ID] + records[2] = { + ...records[2], + dirtyDraftContent: '', + lastKnownDiskSignature: 'sig-1' + } + const state = hydrate(prepareStore(['local']), session) + + const brand = state.openFiles.find((file) => file.filePath === STALE_TAB_BRAND_PATH) + expect(brand?.isDirty).toBe(true) + expect(brand?.lastKnownDiskSignature).toBe('sig-1') + expect(state.editorDrafts[brand!.id]).toBe('') + }) + + it('keeps the disk baseline an equal-draft duplicate carries', () => { + const session = buildStaleEditorTabSession() + const records = session.openFilesByWorktree![STALE_TAB_WORKTREE_ID] + records[2] = { ...records[2], dirtyDraftContent: 'same text' } + records[4] = { + ...records[4], + dirtyDraftContent: 'same text', + lastKnownDiskSignature: 'sig-1' + } + const state = hydrate(prepareStore(['local']), session) + + const brand = state.openFiles.find((file) => file.filePath === STALE_TAB_BRAND_PATH) + expect(state.editorDrafts[brand!.id]).toBe('same text') + expect(brand?.lastKnownDiskSignature).toBe('sig-1') + expect(brand?.pendingDiskBaselineVerification).toBe(true) + }) +}) + +const FOLDER_WORKSPACE_ID = 'folder-1' +const FOLDER_WORKSPACE_KEY = folderWorkspaceKey(FOLDER_WORKSPACE_ID) +const FOLDER_FILE_PATH = '/srv/work/notes.md' + +function prepareFolderStore(executionHostId?: ExecutionHostId) { + const store = createTestStore() + store.setState({ + folderWorkspaces: [ + { + id: FOLDER_WORKSPACE_ID, + projectGroupId: 'project-group-1', + name: 'Folder', + folderPath: '/srv/work', + connectionId: null, + ...(executionHostId ? { executionHostId } : {}), + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ], + projectGroups: [], + activeWorktreeId: FOLDER_WORKSPACE_KEY, + // Focus and the focused runtime are the only "evidence" an unstamped folder row has. + activeWorkspaceExecutionHostId: RUNTIME_HOST_ID + }) + return store +} + +function folderSession(runtimeEnvironmentIds: readonly (string | null)[]): WorkspaceSessionState { + return { + activeRepoId: null, + activeWorktreeId: FOLDER_WORKSPACE_KEY, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { + [FOLDER_WORKSPACE_KEY]: runtimeEnvironmentIds.map((runtimeEnvironmentId) => ({ + filePath: FOLDER_FILE_PATH, + relativePath: 'notes.md', + worktreeId: FOLDER_WORKSPACE_KEY, + language: 'markdown', + runtimeEnvironmentId + })) + }, + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {} + } +} + +const BRAND_RELATIVE_PATH = 'sandbox/2026-09-16-listings-survey/handout/brand-requests.txt' + +function brandRow(overrides: Partial): PersistedOpenFile { + return { + filePath: STALE_TAB_BRAND_PATH, + relativePath: BRAND_RELATIVE_PATH, + worktreeId: STALE_TAB_WORKTREE_ID, + language: 'plaintext', + runtimeEnvironmentId: null, + ...overrides + } +} + +function hydrateBrandRows(rows: readonly PersistedOpenFile[]) { + const session = buildStaleEditorTabSession() + session.openFilesByWorktree![STALE_TAB_WORKTREE_ID] = [...rows] + return hydrate(prepareStore(['local']), session) +} + +describe('recovered draft parking at hydration', () => { + it('never parks a read-only row draft that would come back writable', () => { + const state = hydrateBrandRows([ + brandRow({ readOnly: true, liveTail: true, dirtyDraftContent: 'log draft A' }), + brandRow({ readOnly: true, liveTail: true, dirtyDraftContent: 'log draft B' }) + ]) + + expect(state.openFiles.map((file) => [file.id, file.readOnly === true, file.isDirty])).toEqual([ + [STALE_TAB_BRAND_PATH, true, false] + ]) + expect(state.editorDrafts).toEqual({}) + expect(state.recentlyClosedEditorTabsByWorktree[STALE_TAB_WORKTREE_ID] ?? []).toEqual([]) + }) + + it('parks the rival draft when both duplicates are writable', () => { + const state = hydrateBrandRows([ + brandRow({ dirtyDraftContent: 'draft A' }), + brandRow({ dirtyDraftContent: 'draft B' }) + ]) + + expect(state.editorDrafts[STALE_TAB_BRAND_PATH]).toBe('draft A') + expect( + (state.recentlyClosedEditorTabsByWorktree[STALE_TAB_WORKTREE_ID] ?? []).map( + (snapshot) => snapshot.dirtyDraftContent + ) + ).toEqual(['draft B']) + }) + + it('parks the draft of a writable row a read-only row for its path displaced', () => { + const state = hydrateBrandRows([ + brandRow({ readOnly: true, liveTail: true }), + brandRow({ dirtyDraftContent: 'unsaved edit' }) + ]) + + // Both rows resolve to the same owned id, so only the first restores as a record. + expect(state.openFiles.map((file) => [file.id, file.readOnly === true, file.isDirty])).toEqual([ + [STALE_TAB_BRAND_PATH, true, false] + ]) + expect(state.editorDrafts).toEqual({}) + expect( + (state.recentlyClosedEditorTabsByWorktree[STALE_TAB_WORKTREE_ID] ?? []).map( + (snapshot) => snapshot.dirtyDraftContent + ) + ).toEqual(['unsaved edit']) + expect(state.recentlyClosedTabKindsByWorktree[STALE_TAB_WORKTREE_ID]).toEqual(['editor']) + }) + + it('leaves the draft on the record when the writable row restores first', () => { + const state = hydrateBrandRows([ + brandRow({ dirtyDraftContent: 'unsaved edit' }), + brandRow({ readOnly: true, liveTail: true }) + ]) + + expect(state.openFiles.map((file) => [file.id, file.readOnly === true, file.isDirty])).toEqual([ + [STALE_TAB_BRAND_PATH, false, true] + ]) + expect(state.editorDrafts[STALE_TAB_BRAND_PATH]).toBe('unsaved edit') + expect(state.recentlyClosedEditorTabsByWorktree[STALE_TAB_WORKTREE_ID] ?? []).toEqual([]) + }) +}) + +describe('healed editor tab host alignment', () => { + const HUB_ENVIRONMENT_ID = 'hub-env-1' + const SSH_HOST_ID: ExecutionHostId = 'ssh:conn-1' + const SSH_HUB_ROUTE = { executionHostId: SSH_HOST_ID, runtimeEnvironmentId: HUB_ENVIRONMENT_ID } + + function stampedTab(executionHostId: ExecutionHostId): Tab { + return { + id: 'tab-1', + entityId: 'file-1', + groupId: 'group-1', + worktreeId: 'wt-1', + executionHostId, + contentType: 'editor', + label: 'a.txt', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + } + + function align(tab: Tab) { + return alignHealedEditorTabHosts( + { 'wt-1': [tab] }, + { 'wt-1': { fileIds: new Set(['file-1']), route: SSH_HUB_ROUTE } } + ) + } + + it('leaves a tab stamped with the hub that transports an ssh route untouched', () => { + expect(align(stampedTab(`runtime:${HUB_ENVIRONMENT_ID}`))).toBeNull() + expect(align(stampedTab(SSH_HOST_ID))).toBeNull() + }) + + it('still re-stamps a tab naming a host the route contradicts', () => { + expect(align(stampedTab('runtime:other-env'))?.['wt-1'][0].executionHostId).toBe(SSH_HOST_ID) + }) +}) + +describe('folder workspace owner healing', () => { + it('heals onto a stamped local folder owner', () => { + const store = prepareFolderStore('local') + store.getState().hydrateEditorSession(folderSession([STALE_TAB_RUNTIME_ENV_ID])) + + expect(store.getState().openFiles.map((file) => file.runtimeEnvironmentId)).toEqual([null]) + }) + + it('heals onto a stamped runtime folder owner', () => { + const store = prepareFolderStore(RUNTIME_HOST_ID) + store.getState().hydrateEditorSession(folderSession([null])) + + expect(store.getState().openFiles.map((file) => file.runtimeEnvironmentId)).toEqual([ + STALE_TAB_RUNTIME_ENV_ID + ]) + }) + + it('refuses to heal an unstamped folder row, workspace focus notwithstanding', () => { + const store = prepareFolderStore() + store.getState().hydrateEditorSession(folderSession([STALE_TAB_RUNTIME_ENV_ID, null])) + + expect(store.getState().openFiles.map((file) => file.runtimeEnvironmentId)).toEqual([ + STALE_TAB_RUNTIME_ENV_ID, + null + ]) + }) + + it('reads no owner out of the focused runtime environment', () => { + const focusedRuntimeState: WorktreeOperationRouteState = { + folderWorkspaces: [ + { id: FOLDER_WORKSPACE_ID, projectGroupId: 'project-group-1', connectionId: null } + ], + projectGroups: [{ id: 'project-group-1', connectionId: null }], + activeWorktreeId: FOLDER_WORKSPACE_KEY, + activeWorkspaceExecutionHostId: RUNTIME_HOST_ID, + settings: { activeRuntimeEnvironmentId: STALE_TAB_RUNTIME_ENV_ID }, + runtimeEnvironments: [{ id: STALE_TAB_RUNTIME_ENV_ID }] + } + + // The app's own resolver answers `runtime:…` here; an owner rewrite needs more than focus. + expect(resolveWorktreeOperationRouteResult(focusedRuntimeState, FOLDER_WORKSPACE_KEY)).toEqual({ + kind: 'resolved', + route: { executionHostId: RUNTIME_HOST_ID, runtimeEnvironmentId: STALE_TAB_RUNTIME_ENV_ID } + }) + expect(resolveHealableWorktreeOwnerRoute(focusedRuntimeState, FOLDER_WORKSPACE_KEY)).toBeNull() + }) +}) diff --git a/src/renderer/src/store/slices/stale-editor-tab-resurrection-fixture.ts b/src/renderer/src/store/slices/stale-editor-tab-resurrection-fixture.ts new file mode 100644 index 000000000000..1caebe90ae86 --- /dev/null +++ b/src/renderer/src/store/slices/stale-editor-tab-resurrection-fixture.ts @@ -0,0 +1,123 @@ +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../../../shared/workspace-session-state-types' + +/** + * Scrubbed copy of a real corrupted `workspaceSession`: a LOCAL git worktree whose editor + * records were stamped with a paired REMOTE runtime, duplicated once per in-session write. + */ +export const STALE_TAB_REPO_ID = '2fa91e38-9145-422c-8bd7-f2438886fb57' +export const STALE_TAB_WORKTREE_PATH = '/Users/tester/work/egongegong/eppo' +export const STALE_TAB_WORKTREE_ID = `${STALE_TAB_REPO_ID}::${STALE_TAB_WORKTREE_PATH}` +export const STALE_TAB_RUNTIME_ENV_ID = '107a064f-7bd8-42b4-95e1-192ffc9f5014' +export const STALE_TAB_GROUP_ID = '77437b43-6004-4d4f-921b-4fe2d7c91660' + +const CSV_RELATIVE_PATH = 'sandbox/2026-09-14-kahi-e2e-data-survey/kahi-2026-07-qty-by-listing.csv' +const BRAND_RELATIVE_PATH = 'sandbox/2026-09-16-listings-survey/handout/brand-requests.txt' +export const STALE_TAB_CSV_PATH = `${STALE_TAB_WORKTREE_PATH}/${CSV_RELATIVE_PATH}` +export const STALE_TAB_BRAND_PATH = `${STALE_TAB_WORKTREE_PATH}/${BRAND_RELATIVE_PATH}` + +const encodedWorktreeId = encodeURIComponent(STALE_TAB_WORKTREE_ID) +const compositeTabId = (filePath: string): string => + `editor:${encodedWorktreeId}:${STALE_TAB_RUNTIME_ENV_ID}:${encodeURIComponent(filePath)}` + +export const STALE_TAB_CSV_TAB_ID = compositeTabId(STALE_TAB_CSV_PATH) +export const STALE_TAB_BRAND_COMPOSITE_TAB_ID = compositeTabId(STALE_TAB_BRAND_PATH) +export const STALE_TAB_BRAND_PLAIN_TAB_ID = '0279b787-cef3-4a63-9c32-e1d167c1e30a' + +function persistedFile( + filePath: string, + relativePath: string, + language: string, + runtimeEnvironmentId: string | null +): PersistedOpenFile { + return { + filePath, + relativePath, + worktreeId: STALE_TAB_WORKTREE_ID, + language, + runtimeEnvironmentId + } +} + +function csvRecord(runtimeEnvironmentId: string | null): PersistedOpenFile { + return persistedFile(STALE_TAB_CSV_PATH, CSV_RELATIVE_PATH, 'csv', runtimeEnvironmentId) +} + +function brandRecord(runtimeEnvironmentId: string | null): PersistedOpenFile { + return persistedFile(STALE_TAB_BRAND_PATH, BRAND_RELATIVE_PATH, 'plaintext', runtimeEnvironmentId) +} + +function editorTab(id: string, entityId: string, label: string, sortOrder: number): Tab { + return { + id, + entityId, + groupId: STALE_TAB_GROUP_ID, + worktreeId: STALE_TAB_WORKTREE_ID, + executionHostId: `runtime:${STALE_TAB_RUNTIME_ENV_ID}`, + contentType: 'editor', + label, + customLabel: null, + color: null, + sortOrder, + createdAt: 1_789_550_578_678, + isPreview: false, + isPinned: false + } +} + +function tabGroup(): TabGroup { + return { + id: STALE_TAB_GROUP_ID, + worktreeId: STALE_TAB_WORKTREE_ID, + activeTabId: '0621f040-05e5-4b80-a286-6d47cb3a7e9d', + tabOrder: [ + 'd53f0978-4ac0-4523-8b7c-58ded597d25e', + '0621f040-05e5-4b80-a286-6d47cb3a7e9d', + STALE_TAB_BRAND_PLAIN_TAB_ID, + 'd12834f3-4903-48d0-8fcf-09142b36fbad', + STALE_TAB_CSV_TAB_ID, + STALE_TAB_BRAND_COMPOSITE_TAB_ID + ], + recentTabIds: [ + 'd53f0978-4ac0-4523-8b7c-58ded597d25e', + STALE_TAB_BRAND_PLAIN_TAB_ID, + 'd12834f3-4903-48d0-8fcf-09142b36fbad', + '0621f040-05e5-4b80-a286-6d47cb3a7e9d' + ] + } +} + +/** Fresh deep copy so a test can mutate one field without leaking into the next case. */ +export function buildStaleEditorTabSession(): WorkspaceSessionState { + return { + activeRepoId: STALE_TAB_REPO_ID, + activeWorktreeId: STALE_TAB_WORKTREE_ID, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { + [STALE_TAB_WORKTREE_ID]: [ + csvRecord(STALE_TAB_RUNTIME_ENV_ID), + brandRecord(null), + brandRecord(STALE_TAB_RUNTIME_ENV_ID), + csvRecord(STALE_TAB_RUNTIME_ENV_ID), + brandRecord(STALE_TAB_RUNTIME_ENV_ID), + brandRecord(STALE_TAB_RUNTIME_ENV_ID) + ] + }, + activeFileIdByWorktree: { [STALE_TAB_WORKTREE_ID]: STALE_TAB_BRAND_PATH }, + activeTabTypeByWorktree: { [STALE_TAB_WORKTREE_ID]: 'terminal' }, + unifiedTabs: { + [STALE_TAB_WORKTREE_ID]: [ + editorTab(STALE_TAB_CSV_TAB_ID, STALE_TAB_CSV_PATH, 'kahi-2026-07-qty-by-listing.csv', 0), + editorTab(STALE_TAB_BRAND_PLAIN_TAB_ID, STALE_TAB_BRAND_PATH, 'brand-requests.txt', 1), + editorTab(STALE_TAB_BRAND_COMPOSITE_TAB_ID, STALE_TAB_BRAND_PATH, 'brand-requests.txt', 2) + ] + }, + tabGroups: { [STALE_TAB_WORKTREE_ID]: [tabGroup()] }, + activeGroupIdByWorktree: { [STALE_TAB_WORKTREE_ID]: STALE_TAB_GROUP_ID } + } +} diff --git a/src/shared/workspace-session-editor-schema.ts b/src/shared/workspace-session-editor-schema.ts index 730c3c8e6211..9e5637fcc2b8 100644 --- a/src/shared/workspace-session-editor-schema.ts +++ b/src/shared/workspace-session-editor-schema.ts @@ -2,6 +2,7 @@ * workspace-session-schema.ts to keep that file inside its line budget, the * same way the browser slice already is; the schema itself is unchanged. */ import { z } from 'zod' +import { salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage' export const persistedOpenFileSchema = z.object({ filePath: z.string(), @@ -16,3 +17,18 @@ export const persistedOpenFileSchema = z.object({ readOnly: z.boolean().optional(), liveTail: z.boolean().optional() }) + +const persistedEditorFilesByWorktreeSchema = salvagingRecord( + z.string(), + salvagingArray(persistedOpenFileSchema) +) +export const persistedEditorSessionFields = { + openFilesByWorktree: salvagedOptional( + 'openFilesByWorktree', + persistedEditorFilesByWorktreeSchema + ), + recoveredEditorDraftsByWorktree: salvagedOptional( + 'recoveredEditorDraftsByWorktree', + persistedEditorFilesByWorktreeSchema + ) +} diff --git a/src/shared/workspace-session-host-field-ownership.ts b/src/shared/workspace-session-host-field-ownership.ts index ce8ce0f75b34..14225edca6f1 100644 --- a/src/shared/workspace-session-host-field-ownership.ts +++ b/src/shared/workspace-session-host-field-ownership.ts @@ -27,6 +27,7 @@ export const WORKSPACE_SESSION_FIELD_OWNERSHIP = { clientHostedBrowserCloseIntentsByEnvironment: 'global', tabsByWorktree: 'worktreeKeyed', openFilesByWorktree: 'worktreeKeyed', + recoveredEditorDraftsByWorktree: 'global', activeFileIdByWorktree: 'worktreeKeyed', activeBrowserTabIdByWorktree: 'worktreeKeyed', activeTabTypeByWorktree: 'worktreeKeyed', @@ -102,7 +103,8 @@ export const GLOBAL_WORKSPACE_SESSION_FIELDS = ( */ export const HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS = [ 'browserUrlHistory', - 'workspaceDocHistory' + 'workspaceDocHistory', + 'recoveredEditorDraftsByWorktree' ] as const satisfies readonly (keyof WorkspaceSessionState)[] /** Serialize-side sweep over every non-local partition. The load path re-seeds these fields at diff --git a/src/shared/workspace-session-schema-field-coverage.test.ts b/src/shared/workspace-session-schema-field-coverage.test.ts index c7001060b7b8..5461ea458b05 100644 --- a/src/shared/workspace-session-schema-field-coverage.test.ts +++ b/src/shared/workspace-session-schema-field-coverage.test.ts @@ -31,6 +31,7 @@ const PERSISTED_WORKSPACE_SESSION_FIELDS = { localOnlyScrollbackByTabId: true, activeWorktreeIdsOnShutdown: true, openFilesByWorktree: true, + recoveredEditorDraftsByWorktree: true, activeFileIdByWorktree: true, markdownFrontmatterVisible: true, browserTabsByWorktree: true, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index bf67f580a247..6bdfc3bb8501 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -32,7 +32,7 @@ import { } from './workspace-session-browser-schema' import { clientHostedBrowserCloseIntentSchema } from './client-hosted-browser-close-intent' import { persistedClientHostedBrowserPageSchema } from './client-hosted-browser-page-record' -import { persistedOpenFileSchema } from './workspace-session-editor-schema' +import { persistedEditorSessionFields } from './workspace-session-editor-schema' import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents' import { tabContentTypeSchema, @@ -232,10 +232,7 @@ export const workspaceSessionStateSchema: z.ZodType = z.o 'activeWorktreeIdsOnShutdown', salvagingArray(worktreeIdSchema) ), - openFilesByWorktree: salvagedOptional( - 'openFilesByWorktree', - salvagingRecord(worktreeIdSchema, salvagingArray(persistedOpenFileSchema)) - ), + ...persistedEditorSessionFields, activeFileIdByWorktree: salvagedOptional( 'activeFileIdByWorktree', salvagingRecord(worktreeIdSchema, z.string().nullable()) diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index 48d0314a0168..998f5228a4ed 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -72,6 +72,8 @@ export type WorkspaceSessionState = { * Only edit-mode files are persisted — diffs and conflict views are * transient and not restored. */ openFilesByWorktree?: Record + /** Client-local recovery copies of divergent unsaved editor buffers. */ + recoveredEditorDraftsByWorktree?: Record /** Per-worktree active editor file ID (filePath) at shutdown. */ activeFileIdByWorktree?: Record /** Per-file markdown preview front-matter visibility. Absent entry means hidden. */ From 19047bde4debae401a4c9d903e1e2a9a65de6bb7 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 15:49:58 -0700 Subject: [PATCH 2/6] Protect captured SSH owners during recovered draft reopening --- src/renderer/src/i18n/locales/en.json | 1 + ...rkspace-session-recovered-editor-drafts.ts | 4 +- .../slices/editor-recovery-durability.test.ts | 54 +++++++++++++++++++ .../actions/parked-recovered-editor-drafts.ts | 6 ++- .../actions/recovered-draft-block-notice.ts | 9 +++- .../actions/reopen-recovered-editor-tab.ts | 36 +++++++++++-- 6 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5bdb3696a32b..1d5962762d0a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -526,6 +526,7 @@ "checkRunDetailsLoadFailed": "Failed to load check details.", "checkRunDetailsRepoUnavailable": "Repository details are unavailable for this check.", "parkedDraftBlockedByUnsavedChanges": "{{path}} is open with unsaved changes. Save or close it, then reopen to recover the parked draft.", + "parkedDraftBlockedByOtherOwner": "{{path}} is open on another host. Close that tab, then reopen to recover the parked draft.", "parkedDraftBlockedByReadOnlyTab": "{{path}} is open read-only. Close it, then reopen to recover the parked draft." }, "github": { diff --git a/src/renderer/src/lib/workspace-session-recovered-editor-drafts.ts b/src/renderer/src/lib/workspace-session-recovered-editor-drafts.ts index 75521bea9865..a908e541062b 100644 --- a/src/renderer/src/lib/workspace-session-recovered-editor-drafts.ts +++ b/src/renderer/src/lib/workspace-session-recovered-editor-drafts.ts @@ -1,3 +1,4 @@ +import { getPersistedEditorOwnerFields } from './editor-file-operation-owner' import type { PersistedOpenFile } from '../../../shared/workspace-session-state-types' import type { ClosedEditorTabSnapshot } from '../store/slices/editor/types/open-file' @@ -18,8 +19,7 @@ export function buildPersistedRecoveredEditorDrafts( worktreeId, language: snapshot.language, isPreview: false, - runtimeEnvironmentId: snapshot.runtimeEnvironmentId, - externalSshTargetId: snapshot.externalSshTargetId, + ...getPersistedEditorOwnerFields(snapshot), dirtyDraftContent: snapshot.dirtyDraftContent, lastKnownDiskSignature: snapshot.lastKnownDiskSignature })) diff --git a/src/renderer/src/store/slices/editor-recovery-durability.test.ts b/src/renderer/src/store/slices/editor-recovery-durability.test.ts index 090ae38c860d..e6f78442d567 100644 --- a/src/renderer/src/store/slices/editor-recovery-durability.test.ts +++ b/src/renderer/src/store/slices/editor-recovery-durability.test.ts @@ -5,6 +5,7 @@ import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { buildWorkspaceSessionPatch } from '@/lib/workspace-session-patch' import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema' import { parkRecoveredEditorDrafts } from './editor/actions/parked-recovered-editor-drafts' +import { captureWorktreeOperationGenerationSnapshot } from '@/lib/worktree-operation-generation' import type { ClosedEditorTabSnapshot } from './editor/types/open-file' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -26,6 +27,59 @@ function draft(index: number): ClosedEditorTabSnapshot { } describe('durable editor recovery', () => { + it.each([false, true])('does not reuse a captured SSH rival when dirty=%s', (isDirty) => { + const store = createTestStore() + const snapshot = { ...draft(1), runtimeEnvironmentId: null, externalSshTargetId: 'target-a' } + const rival = { + ...snapshot, + id: 'rival', + isDirty, + externalSshTargetId: undefined, + operationProvenance: { + ownershipProjection: 'explicit' as const, + generation: captureWorktreeOperationGenerationSnapshot({ + executionHostId: 'ssh:target-b', + runtimeEnvironmentId: null + }) + } + } + const open = vi.fn(() => 'rival') + store.setState({ + openFiles: [rival], + editorDrafts: isDirty ? { rival: 'rival text' } : {}, + recentlyClosedEditorTabsByWorktree: { [WORKSPACE]: [snapshot] }, + openFile: open + }) + expect(store.getState().reopenClosedEditorTab(WORKSPACE)).toBe(true) + expect(open).not.toHaveBeenCalled() + expect(store.getState().openFiles).toEqual([rival]) + expect(store.getState().recentlyClosedEditorTabsByWorktree[WORKSPACE]).toContainEqual(snapshot) + }) + + it('persists a recovered draft under its captured SSH owner rather than stale fields', () => { + const store = createTestStore() + const snapshot = { + ...draft(1), + operationProvenance: { + ownershipProjection: 'explicit' as const, + generation: captureWorktreeOperationGenerationSnapshot({ + executionHostId: 'ssh:target', + runtimeEnvironmentId: 'hub' + }) + } + } + store.setState(parkRecoveredEditorDrafts(store.getState(), WORKSPACE, [snapshot])) + expect( + buildWorkspaceSessionPayload(store.getState()).recoveredEditorDraftsByWorktree?.[ + WORKSPACE + ]?.[0] + ).toMatchObject({ + runtimeEnvironmentId: 'hub', + externalSshTargetId: 'target', + dirtyDraftContent: snapshot.dirtyDraftContent + }) + }) + it('retains every buffer and its authority through serialization and fresh-store hydration', () => { const store = createTestStore() const originals = Array.from({ length: 42 }, (_value, index) => draft(index)) diff --git a/src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.ts b/src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.ts index ded3230f535c..aaddcf09ef82 100644 --- a/src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.ts +++ b/src/renderer/src/store/slices/editor/actions/parked-recovered-editor-drafts.ts @@ -1,3 +1,4 @@ +import { getPersistedEditorOwnerFields } from '@/lib/editor-file-operation-owner' import type { AppState } from '../../../types' import { type ClosedEditorTabSnapshot, MAX_RECENT_CLOSED_EDITOR_TABS } from '../types/open-file' import { appendRecentlyClosedTabKind, pushRecentlyClosedTabKind } from '../../recently-closed-tabs' @@ -19,11 +20,12 @@ export function retainClosedEditorSnapshots( } function recoveryKey(snapshot: ClosedEditorTabSnapshot): string { + const owner = getPersistedEditorOwnerFields(snapshot) return JSON.stringify([ snapshot.worktreeId, snapshot.filePath, - snapshot.runtimeEnvironmentId ?? null, - snapshot.externalSshTargetId ?? null, + owner.runtimeEnvironmentId ?? null, + owner.externalSshTargetId ?? null, snapshot.dirtyDraftContent, snapshot.lastKnownDiskSignature ?? null ]) diff --git a/src/renderer/src/store/slices/editor/actions/recovered-draft-block-notice.ts b/src/renderer/src/store/slices/editor/actions/recovered-draft-block-notice.ts index abf751e12166..a07a1dbfdf0c 100644 --- a/src/renderer/src/store/slices/editor/actions/recovered-draft-block-notice.ts +++ b/src/renderer/src/store/slices/editor/actions/recovered-draft-block-notice.ts @@ -2,7 +2,7 @@ import { translate } from '@/i18n/i18n' import type { ClosedEditorTabSnapshot } from '../types/open-file' /** Why the record blocked the parked draft, in the wording the user needs to act on. */ -export type RecoveredDraftBlockReason = 'unsaved-rival' | 'read-only' +export type RecoveredDraftBlockReason = 'unsaved-rival' | 'read-only' | 'other-owner' /** The parked draft stayed parked; name the document and what unblocks it. */ export function recoveredDraftBlockedMessage( @@ -10,6 +10,13 @@ export function recoveredDraftBlockedMessage( file: Pick ): string { const path = file.relativePath || file.filePath + if (reason === 'other-owner') { + return translate( + 'auto.store.slices.editor.parkedDraftBlockedByOtherOwner', + '{{path}} is open on another host. Close that tab, then reopen to recover the parked draft.', + { path } + ) + } return reason === 'read-only' ? translate( 'auto.store.slices.editor.parkedDraftBlockedByReadOnlyTab', diff --git a/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts b/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts index 91e112e3c5cf..33e985bf0f03 100644 --- a/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts +++ b/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts @@ -1,3 +1,4 @@ +import { getPersistedEditorOwnerFields } from '@/lib/editor-file-operation-owner' import { toast } from 'sonner' import type { EditorGet, EditorSet } from '../types/editor-set-get' import { restoreRecentlyClosedTabPosition } from '../../recently-closed-tabs' @@ -5,7 +6,12 @@ import { buildEditorActiveResult } from '../tabs/editor-open-target-group' import { deferRecoveredEditorDraft } from './parked-recovered-editor-drafts' import { editorDocumentPathOwnerKey } from '../file-ids/editor-document-identity' import { recoveredDraftBlockedMessage } from './recovered-draft-block-notice' -import { getReusableOpenFileModes, matchesEditorMode } from '../file-ids/editor-file-ids' +import { + canReuseLocalWslAlias, + getReusableOpenFileModes, + isSameEditorOwner, + matchesEditorMode +} from '../file-ids/editor-file-ids' export function reopenRecoveredEditorTab( set: EditorSet, @@ -23,7 +29,8 @@ export function reopenRecoveredEditorTab( [worktreeId]: (s.recentlyClosedEditorTabsByWorktree[worktreeId] ?? []).slice(1) } })) - const { position, reopenId, dirtyDraftContent, ...file } = next + const { position, reopenId, dirtyDraftContent, ...snapshotFile } = next + const file = { ...snapshotFile, ...getPersistedEditorOwnerFields(snapshotFile) } const collisionToast = (): void => { toast.info(recoveredDraftBlockedMessage('unsaved-rival', file)) } @@ -58,9 +65,32 @@ export function reopenRecoveredEditorTab( // snapshot whose identity key differs from a live read-only log still lands on that record. const identity = editorDocumentPathOwnerKey(file) const modes = getReusableOpenFileModes(file.mode) + const candidateIdentity = ( + candidate: (typeof beforeCollisionCheck.openFiles)[number] + ): string => + editorDocumentPathOwnerKey({ ...candidate, ...getPersistedEditorOwnerFields(candidate) }) + // The normal open path can reuse an unstamped SSH tab; protect its captured owner first. + const rivalOwner = beforeCollisionCheck.openFiles.find( + (candidate) => + matchesEditorMode(candidate, modes) && + isSameEditorOwner(candidate, file.worktreeId, file.runtimeEnvironmentId) && + (candidate.filePath === file.filePath || + canReuseLocalWslAlias( + beforeCollisionCheck, + candidate, + file, + file.runtimeEnvironmentId + )) && + candidateIdentity({ ...candidate, filePath: file.filePath }) !== identity + ) + if (rivalOwner) { + set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) + toast.info(recoveredDraftBlockedMessage('other-owner', file)) + return true + } const matches = beforeCollisionCheck.openFiles.filter( (candidate) => - matchesEditorMode(candidate, modes) && editorDocumentPathOwnerKey(candidate) === identity + matchesEditorMode(candidate, modes) && candidateIdentity(candidate) === identity ) // Why writable first: a read-only twin only blocks the draft when nothing writable can hold it. const live = matches.find((candidate) => candidate.readOnly !== true) ?? matches[0] From 36259cb288f0b218053abdbad22e532e23681435 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 17:44:17 -0700 Subject: [PATCH 3/6] Clarify recovered editor draft ownership comments --- .../actions/reopen-recovered-editor-tab.ts | 42 ++++++------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts b/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts index 33e985bf0f03..fd88658c49c8 100644 --- a/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts +++ b/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts @@ -37,14 +37,12 @@ export function reopenRecoveredEditorTab( const readOnlyCollisionToast = (): void => { toast.info(recoveredDraftBlockedMessage('read-only', file)) } - // Why both halves: setActiveFile promotes the record's unified tab inside its group, and the - // open-target result raises the editor surface the normal open path would have raised. + // Raise both the unified tab and its editor surface. const activateLiveRecord = (liveFileId: string): void => { get().setActiveFile(liveFileId) set((s) => buildEditorActiveResult(s, file.worktreeId, liveFileId)) } - // Why only when the record has none: a snapshot baseline older than the live record's would - // manufacture a conflict, but a dirty record with no baseline at all is unverifiable. + // Keep newer live baselines; a missing baseline cannot authorize autosave. const adoptSnapshotDiskBaseline = (liveFileId: string): void => { const liveFile = get().openFiles.find((f) => f.id === liveFileId) if (!liveFile || liveFile.lastKnownDiskSignature !== undefined) { @@ -58,11 +56,9 @@ export function reopenRecoveredEditorTab( get().setPendingDiskBaselineVerification(liveFileId, true) } if (dirtyDraftContent !== undefined) { - // Why decided before the open: openFile would give the live record a second unified tab in - // the snapshot's group, and the writes below would then land on the wrong document. + // Check collisions before openFile can reuse a live record or add a second tab. const beforeCollisionCheck = get() - // Why the surface-blind key: openFile's reuse rule ignores readOnly/liveTail, so a writable - // snapshot whose identity key differs from a live read-only log still lands on that record. + // Match openFile's reuse rule, which ignores readOnly and liveTail. const identity = editorDocumentPathOwnerKey(file) const modes = getReusableOpenFileModes(file.mode) const candidateIdentity = ( @@ -92,37 +88,31 @@ export function reopenRecoveredEditorTab( (candidate) => matchesEditorMode(candidate, modes) && candidateIdentity(candidate) === identity ) - // Why writable first: a read-only twin only blocks the draft when nothing writable can hold it. + // Prefer a writable twin that can retain the draft. const live = matches.find((candidate) => candidate.readOnly !== true) ?? matches[0] const liveDraft = live ? beforeCollisionCheck.editorDrafts[live.id] : undefined if (live?.readOnly === true) { - // Why no open: openFile would reuse this record and hang a second unified tab off the - // snapshot's group, and a read-only record can hold neither the draft nor its baseline. + // A read-only record cannot retain this draft or its baseline. set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) activateLiveRecord(live.id) readOnlyCollisionToast() return true } if (live && liveDraft === dirtyDraftContent) { - // Why nothing is written: the live record already holds this exact text, so the draft and - // baseline writes would only replace a newer baseline with the snapshot's older one. + // Identical text must not replace a newer live baseline. adoptSnapshotDiskBaseline(live.id) activateLiveRecord(live.id) return true } if (live && (liveDraft !== undefined || live.isDirty === true)) { - // Why deferred rather than dropped: the snapshot holds the only copy of that unsaved text. + // Preserve the rival unsaved text for a later reopen. set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) - // Why activate: the toast names a document the user must act on, so show it to them. activateLiveRecord(live.id) collisionToast() return true } } - // Why captured before the open: openFile's reuse rule is coarser than the identity key above - // (it also reuses across local/WSL path aliases), so a collision can still surface here. - // Why targetGroupId is still passed: only that alias case can reuse a live record here, and - // the snapshot's own group is where the user closed it from. + // Path aliases can still reuse a live record; preserve its draft before opening. const beforeOpen = get() const reusableRecordIds = new Set(beforeOpen.openFiles.map((f) => f.id)) const draftsBeforeOpen = beforeOpen.editorDrafts @@ -143,8 +133,7 @@ export function reopenRecoveredEditorTab( dirtyDraftContent !== undefined && get().openFiles.find((f) => f.id === restoredFileId)?.readOnly === true ) { - // Why: openFile's reuse rule ignores readOnly, and setEditorDraft/markFileDirty hard no-op - // on a read-only record — writing the draft below would consume the snapshot and lose it. + // Reuse may select a read-only record, whose draft setters do nothing. set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) readOnlyCollisionToast() return true @@ -155,24 +144,19 @@ export function reopenRecoveredEditorTab( (draftsBeforeOpen[restoredFileId] !== undefined || dirtyBeforeOpen.has(restoredFileId)) if (dirtyDraftContent !== undefined && reusedRecordHasUnsavedWork) { if (draftsBeforeOpen[restoredFileId] === dirtyDraftContent) { - // Why nothing is written: the reused record already holds this exact text, so the draft - // and baseline writes would only replace a newer baseline with the snapshot's older one. + // Identical text must not replace a newer live baseline. adoptSnapshotDiskBaseline(restoredFileId) return true } - // Why put the snapshot back: its buffer has nowhere to restore to yet, and dropping it here - // would destroy the only copy of that unsaved text. + // Preserve the draft until the reused record can safely accept it. set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) collisionToast() return true } - // Why: a live `OpenFile` has no dirtyDraftContent — only the hydration heal parks one on a - // snapshot, for a record the restore could not give an id of its own. Reopen restores it. if (dirtyDraftContent !== undefined) { get().setEditorDraft(restoredFileId, dirtyDraftContent) get().markFileDirty(restoredFileId, true) - // Why: the draft derives from the disk state this baseline was taken over, so the - // restored-tab conflict scan must re-verify it before autosave resumes. + // Verify the recovered draft's disk baseline before autosave resumes. if (next.lastKnownDiskSignature !== undefined) { get().setLastKnownDiskSignature(restoredFileId, next.lastKnownDiskSignature) get().setPendingDiskBaselineVerification(restoredFileId, true) From ce97702e87462139118b57d38c96436c42fccd5c Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 18:38:53 -0700 Subject: [PATCH 4/6] Select concrete headless server tests before Vitest project discovery --- config/scripts/node-server-change-scope.mjs | 13 +++--------- .../scripts/node-server-change-scope.test.mjs | 8 +++++++ config/scripts/node-server-test-paths.mjs | 21 +++++++++++++++++++ config/scripts/run-node-server-tests.mjs | 20 +++--------------- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 71e788867e1b..bb8732cc49c9 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -1,7 +1,7 @@ -import { appendFileSync, globSync, readFileSync } from 'node:fs' +import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { nodeServerTestPaths } from './node-server-test-paths.mjs' +import { nodeServerTestFiles, nodeServerTestPaths } from './node-server-test-paths.mjs' import { nodeServerQualification } from './node-server-qualification.mjs' const ROOT = resolve(import.meta.dirname, '../..') @@ -54,14 +54,7 @@ const ALWAYS_PREFIXES = [ ] export function discoverNodeServerTests(root = ROOT) { - const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true }) - return globSync( - ['src/**/*.test.{ts,tsx}', 'config/scripts/**/*.test.{ts,mjs}', 'tests/e2e/**/*.unit.test.ts'], - { cwd: root } - ) - .map((file) => file.replaceAll('\\', '/')) - .filter((file) => selectors.some((selector) => file.includes(selector))) - .sort() + return nodeServerTestFiles({ root, artifact: true, crossRuntime: true }) } export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) { diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 38b56f0d6000..4fdd5e470dda 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -215,6 +215,14 @@ describe('the actual Bun build and profile-test dependency graph', () => { expect((await classifyNodeServerChanges([file], async () => inputs)).shouldRun).toBe(true) }) + it('keeps desktop Electron probes outside the headless server selection', () => { + const tests = discoverNodeServerTests() + expect(tests).toContain('src/main/persistence/profile-state/profile-state-access.test.ts') + expect(tests).not.toContain( + 'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts' + ) + }) + it('retains all selected tests and the selectors the Bun runner uses', () => { const tests = discoverNodeServerTests() expect(tests.length).toBeGreaterThan(80) diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 8d9e8eb67102..410299611d7b 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -1,3 +1,7 @@ +import { globSync } from 'node:fs' +import { resolve } from 'node:path' +import { UNIT_INCLUDE } from './ci-unit-files.mjs' + /** Mirrors ORCA_REQUIRED_TEST_INPUTS_ENV in src/main/orcad/orcad-node-slot-fixture.ts. */ export const REQUIRED_TEST_INPUTS_ENV = 'ORCA_REQUIRED_TEST_INPUTS' @@ -40,3 +44,20 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = ...(crossRuntime ? CROSS_RUNTIME_TEST_PATHS : []) ] } + +export function nodeServerTestFiles({ + root = resolve(import.meta.dirname, '../..'), + artifact = false, + crossRuntime = false +} = {}) { + const selectors = nodeServerTestPaths({ artifact, crossRuntime }) + return globSync(UNIT_INCLUDE, { cwd: root }) + .map((file) => file.replaceAll('\\', '/')) + .filter( + (file) => + !file.endsWith('.electron.test.ts') && + (crossRuntime || !CROSS_RUNTIME_TEST_PATHS.includes(file)) && + selectors.some((selector) => file.includes(selector)) + ) + .sort() +} diff --git a/config/scripts/run-node-server-tests.mjs b/config/scripts/run-node-server-tests.mjs index 2183b9665503..5a3d1b46bc03 100644 --- a/config/scripts/run-node-server-tests.mjs +++ b/config/scripts/run-node-server-tests.mjs @@ -1,7 +1,7 @@ // Runs the headless-server suites under the pinned Node (design D4/D4a), not the host's. import { join, resolve } from 'node:path' import { randomUUID } from 'node:crypto' -import { globSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { ORCAD_VERSION_FILENAME } from '../../src/shared/orcad-artifacts.ts' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' import { @@ -11,13 +11,8 @@ import { import { packagedNodeRuntimePath } from './build-orcad-node.mjs' import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs' import { currentTarget } from './server-build-target.mjs' -import { UNIT_INCLUDE } from './ci-unit-files.mjs' import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' -import { - CROSS_RUNTIME_TEST_PATHS, - nodeServerTestPaths, - REQUIRED_TEST_INPUTS_ENV -} from './node-server-test-paths.mjs' +import { nodeServerTestFiles, REQUIRED_TEST_INPUTS_ENV } from './node-server-test-paths.mjs' const root = resolve(import.meta.dirname, '../..') const target = currentTarget() @@ -85,14 +80,5 @@ run(runtimePath, [ ]) function defaultTestArgs() { - // Why: vitest 5 drops CLI --exclude for inline projects, so the selectors' substring matches - // are resolved to files here and filtered before vitest sees them. - // Electron probes run in desktop jobs; headless compatibility containers have no display. - const selectors = nodeServerTestPaths({ artifact, crossRuntime }) - const skipped = new Set(crossRuntime ? [] : CROSS_RUNTIME_TEST_PATHS) - return globSync(UNIT_INCLUDE, { cwd: root }) - .map((path) => path.replaceAll('\\', '/')) - .filter((path) => selectors.some((selector) => path.includes(selector))) - .filter((path) => !path.endsWith('.electron.test.ts') && !skipped.has(path)) - .sort() + return nodeServerTestFiles({ root, artifact, crossRuntime }) } From a7bf72eeede6a22bcd08d63857487ca1540eb9b3 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 6 Oct 2026 20:24:45 -0700 Subject: [PATCH 5/6] Simplify recovered editor draft bookkeeping --- .../editor/actions/hydrate-editor-session.ts | 10 +--- .../actions/reopen-recovered-editor-tab.ts | 48 +++++++-------- .../file-ids/hydrated-editor-owner-healing.ts | 60 ++++--------------- 3 files changed, 39 insertions(+), 79 deletions(-) diff --git a/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts b/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts index 7877e05082c5..e4a49ed3fa54 100644 --- a/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts +++ b/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts @@ -5,7 +5,6 @@ import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydrati import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' import type { WorkspaceVisibleTabType } from '../../../../../../shared/tab-types' -import type { AppState } from '../../../types' import type { PersistedOpenFile } from '../../../../../../shared/workspace-session-state-types' import type { ClosedEditorTabSnapshot, OpenFile } from '../types/open-file' import { @@ -47,18 +46,15 @@ function buildRecoveredDraftSnapshot( externalSshTargetId: file.externalSshTargetId, mode: 'edit', dirtyDraftContent: file.dirtyDraftContent, - // Why: without the baseline the draft derives from, a reopen restores it with nothing for the - // conflict scan to compare, and autosave can clobber an offline write. + // Carry the baseline so recovery can detect offline disk changes before autosave. lastKnownDiskSignature: file.lastKnownDiskSignature } } function buildRecoveredDraftReopenState( - state: Pick, + state: ParkedRecoveredEditorDrafts, recoveredByWorktree: Record -): Partial< - Pick -> { +): Partial { const worktreeIds = Object.keys(recoveredByWorktree) if (worktreeIds.length === 0) { return {} diff --git a/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts b/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts index fd88658c49c8..663bd06dad43 100644 --- a/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts +++ b/src/renderer/src/store/slices/editor/actions/reopen-recovered-editor-tab.ts @@ -5,7 +5,10 @@ import { restoreRecentlyClosedTabPosition } from '../../recently-closed-tabs' import { buildEditorActiveResult } from '../tabs/editor-open-target-group' import { deferRecoveredEditorDraft } from './parked-recovered-editor-drafts' import { editorDocumentPathOwnerKey } from '../file-ids/editor-document-identity' -import { recoveredDraftBlockedMessage } from './recovered-draft-block-notice' +import { + recoveredDraftBlockedMessage, + type RecoveredDraftBlockReason +} from './recovered-draft-block-notice' import { canReuseLocalWslAlias, getReusableOpenFileModes, @@ -31,11 +34,11 @@ export function reopenRecoveredEditorTab( })) const { position, reopenId, dirtyDraftContent, ...snapshotFile } = next const file = { ...snapshotFile, ...getPersistedEditorOwnerFields(snapshotFile) } - const collisionToast = (): void => { - toast.info(recoveredDraftBlockedMessage('unsaved-rival', file)) + const deferDraft = (): void => { + set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) } - const readOnlyCollisionToast = (): void => { - toast.info(recoveredDraftBlockedMessage('read-only', file)) + const notifyBlocked = (reason: RecoveredDraftBlockReason): void => { + toast.info(recoveredDraftBlockedMessage(reason, file)) } // Raise both the unified tab and its editor surface. const activateLiveRecord = (liveFileId: string): void => { @@ -80,8 +83,8 @@ export function reopenRecoveredEditorTab( candidateIdentity({ ...candidate, filePath: file.filePath }) !== identity ) if (rivalOwner) { - set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) - toast.info(recoveredDraftBlockedMessage('other-owner', file)) + deferDraft() + notifyBlocked('other-owner') return true } const matches = beforeCollisionCheck.openFiles.filter( @@ -93,9 +96,9 @@ export function reopenRecoveredEditorTab( const liveDraft = live ? beforeCollisionCheck.editorDrafts[live.id] : undefined if (live?.readOnly === true) { // A read-only record cannot retain this draft or its baseline. - set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) + deferDraft() activateLiveRecord(live.id) - readOnlyCollisionToast() + notifyBlocked('read-only') return true } if (live && liveDraft === dirtyDraftContent) { @@ -106,19 +109,14 @@ export function reopenRecoveredEditorTab( } if (live && (liveDraft !== undefined || live.isDirty === true)) { // Preserve the rival unsaved text for a later reopen. - set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) + deferDraft() activateLiveRecord(live.id) - collisionToast() + notifyBlocked('unsaved-rival') return true } } // Path aliases can still reuse a live record; preserve its draft before opening. const beforeOpen = get() - const reusableRecordIds = new Set(beforeOpen.openFiles.map((f) => f.id)) - const draftsBeforeOpen = beforeOpen.editorDrafts - const dirtyBeforeOpen = new Set( - beforeOpen.openFiles.filter((f) => f.isDirty === true).map((f) => f.id) - ) let restoredFileId: string try { restoredFileId = get().openFile(file, { @@ -126,7 +124,7 @@ export function reopenRecoveredEditorTab( reopenId }) } catch (error) { - set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) + deferDraft() throw error } if ( @@ -134,23 +132,23 @@ export function reopenRecoveredEditorTab( get().openFiles.find((f) => f.id === restoredFileId)?.readOnly === true ) { // Reuse may select a read-only record, whose draft setters do nothing. - set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) - readOnlyCollisionToast() + deferDraft() + notifyBlocked('read-only') return true } - const reusedLiveRecord = reusableRecordIds.has(restoredFileId) + const reusedLiveRecord = beforeOpen.openFiles.find((f) => f.id === restoredFileId) + const priorDraft = beforeOpen.editorDrafts[restoredFileId] const reusedRecordHasUnsavedWork = - reusedLiveRecord && - (draftsBeforeOpen[restoredFileId] !== undefined || dirtyBeforeOpen.has(restoredFileId)) + reusedLiveRecord && (priorDraft !== undefined || reusedLiveRecord.isDirty === true) if (dirtyDraftContent !== undefined && reusedRecordHasUnsavedWork) { - if (draftsBeforeOpen[restoredFileId] === dirtyDraftContent) { + if (priorDraft === dirtyDraftContent) { // Identical text must not replace a newer live baseline. adoptSnapshotDiskBaseline(restoredFileId) return true } // Preserve the draft until the reused record can safely accept it. - set((s) => deferRecoveredEditorDraft(s, worktreeId, next)) - collisionToast() + deferDraft() + notifyBlocked('unsaved-rival') return true } if (dirtyDraftContent !== undefined) { diff --git a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-owner-healing.ts b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-owner-healing.ts index fbf21e8bed24..e24d814c4262 100644 --- a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-owner-healing.ts +++ b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-owner-healing.ts @@ -29,11 +29,7 @@ export type HealedPersistedEditorFile = { export type HealedPersistedEditorFilePlan = { files: HealedPersistedEditorFile[] - /** - * Drafts that cannot restore as their own tab: the persisted schema identifies a document by - * (owner, path), so a second record with the same owner has no id of its own. Their content is - * handed back for the reopen stack instead of being dropped on the floor. - */ + /** Same-owner drafts without a distinct tab identity go to the reopen stack. */ recoverableDrafts: PersistedOpenFile[] droppedCount: number ownerRewrittenCount: number @@ -45,7 +41,7 @@ const stampedRouteStateCache = new WeakMap< WorktreeOperationRouteState >() -/** One focus-suppressed copy per store snapshot, so a many-workspace hydration re-spreads nothing. */ +// Cache per snapshot to avoid copying the store for every workspace. function stampedRouteState(state: WorktreeOperationRouteState): WorktreeOperationRouteState { const cached = stampedRouteStateCache.get(state) if (cached) { @@ -66,12 +62,7 @@ function routeForStampedHost(hostId: string | null | undefined): WorktreeOperati : null } -/** - * A folder workspace's owner, taken only from evidence someone wrote down: the folder or project - * group row's host, its SSH connection, or the per-host session partition it restored from. - * `getExecutionHostIdForFolderWorkspace` cannot be used here — it defaults to `local`, and a - * default is not evidence that the records are local. - */ +// Use recorded ownership; the normal folder resolver's local default is not evidence. function resolveStampedFolderWorkspaceRoute( state: WorktreeOperationRouteState, folderWorkspaceId: string @@ -95,14 +86,7 @@ function resolveStampedFolderWorkspaceRoute( ) } -/** - * The owner a worktree's editor records must agree with, or `null` when nothing stamped can - * place it. Both focus signals — the active workspace's host selection and the focused runtime - * environment — are suppressed, because UI focus leaking into ownership is what wrote the - * corrupt owners this heal removes. Git ids resolve from stamped catalog rows only; the legacy - * unstamped fallbacks below them are inference, and inference is not a licence to rewrite an - * owner. Folder workspaces have no catalog, so their own owner record plays that role. - */ +/** Resolve recorded ownership without the focus defaults that caused the corruption. */ export function resolveHealableWorktreeOwnerRoute( state: WorktreeOperationRouteState, worktreeId: string @@ -116,8 +100,7 @@ export function resolveHealableWorktreeOwnerRoute( return resolution.kind === 'resolved' ? resolution.route : null } -/** Why no liveTail term: a pinned read-only log and an SSH-pinned external path name their own host, - * and `editorDocumentIdentityKey` only reads liveTail on a read-only row, already excluded here. */ +// Read-only logs and explicitly targeted SSH files retain their recorded owners. function isOwnerHealable(file: PersistedOpenFile): boolean { return file.readOnly !== true && !file.externalSshTargetId?.trim() } @@ -136,8 +119,7 @@ function pickSurvivor( ): PersistedOpenFile { const drafted = files.find((file) => file.dirtyDraftContent !== undefined) if (drafted) { - // Why the equal-draft scan: same text means the same document, so keep the copy that still - // carries the disk baseline — the restored draft has nothing to verify against without it. + // Equal drafts can share the baseline needed to verify recovery against disk. return ( files.find( (file) => @@ -166,8 +148,7 @@ function groupByIdentity( ): Map { const groups = new Map() for (const file of files) { - // Why the bucket's worktreeId, not the record's: a record whose own worktreeId drifted is - // exactly the corruption this heal merges, so it must not split the group. + // A corrupt record's workspace cannot split its persisted bucket. const key = editorDocumentIdentityKey({ ...file, worktreeId }, ownerOf(file)) const group = groups.get(key) if (group) { @@ -179,11 +160,7 @@ function groupByIdentity( return groups } -/** - * Collapse one worktree's persisted editor records onto one survivor per document, normalizing - * owners to the worktree's resolved route. Duplicates whose unsaved drafts disagree are kept - * apart instead: a merge would destroy one of them. - */ +/** Merge duplicate documents onto recorded owners while preserving every distinct draft. */ export function planHealedPersistedEditorFiles(args: { files: readonly PersistedOpenFile[] worktreeId: string @@ -200,8 +177,7 @@ export function planHealedPersistedEditorFiles(args: { const survivors: { file: PersistedOpenFile - /** Records whose restored ids must be redirected onto the survivor — merged siblings, plus - * the survivor's own pre-heal identity when the route re-owned it. */ + /** Includes the survivor's previous identity when its owner changed. */ superseded: PersistedOpenFile[] droppedCount: number ownerRewritten: boolean @@ -219,8 +195,7 @@ export function planHealedPersistedEditorFiles(args: { if ( entry.dirtyDraftContent !== undefined && entry.dirtyDraftContent !== file.dirtyDraftContent && - // Why: a read-only row must restore clean, and the reopen snapshot drops readOnly/liveTail - // and reopens as 'edit' — parking its draft would hand back a writable dirty log tab. + // Parking a read-only log's draft would reopen it writable. entry.readOnly !== true ) { recoverableDrafts.push(entry) @@ -256,8 +231,7 @@ export function planHealedPersistedEditorFiles(args: { const healedFiles = survivors.map((survivor) => { const { file, superseded, ownerRewritten } = survivor droppedCount += survivor.droppedCount - // Why the owner comparison: a divergent-draft survivor keeps its verbatim owner, so re-stamping - // its tabs would make them describe a record that still follows a different route. + // Divergent drafts keep their original owner; their tabs must agree. const ownerNormalized = route !== null && isOwnerHealable(file) && @@ -283,12 +257,7 @@ export function planHealedPersistedEditorFiles(args: { } } -/** - * A stamp the route already names. An SSH worktree reached through a paired HUB routes as - * `{ ssh:conn, runtime:hub }`, and `runtime:hub` is a correct stamp for it — equality alone would - * rewrite those tabs on every hydration. A route with no host names no alias, so its tabs are - * unstamped as before. - */ +// A nested SSH route may validly carry its paired runtime host's stamp. function isEditorTabHostOnRoute( executionHostId: ExecutionHostId, route: WorktreeOperationRoute @@ -313,10 +282,7 @@ export type HealedEditorTabHostTarget = { route: WorktreeOperationRoute } -/** - * Re-stamp editor tabs that name a host the worktree's route contradicts. Such a tab keeps - * failing `isUnifiedTabOwnedByWorktree` for its own worktree. Unstamped tabs stay unstamped. - */ +/** Correct contradictory host stamps so the workspace can render its recovered tabs. */ export function alignHealedEditorTabHosts( tabsByWorktree: Record, targetsByWorktree: Record From 0fadf6ef9314db31c5d345fadfa241bf798027d3 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 13:16:16 -0700 Subject: [PATCH 6/6] Keep shared test file patterns independent of installed Vitest --- config/scripts/ci-unit-files.mjs | 11 ++--------- config/scripts/node-server-change-scope.test.mjs | 15 +++++++++------ config/scripts/node-server-test-paths.mjs | 2 +- config/scripts/unit-test-file-patterns.mjs | 8 ++++++++ 4 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 config/scripts/unit-test-file-patterns.mjs diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 3e0cdb67cd8f..21e65a2e2fd8 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -1,18 +1,11 @@ import { globSync } from 'node:fs' import { defaultExclude } from 'vitest/config' +import { UNIT_INCLUDE } from './unit-test-file-patterns.mjs' +export { UNIT_INCLUDE } from './unit-test-file-patterns.mjs' // Why one constant: the cross-version-wire job runs this directory, so a new file there runs with no list to update. export const CROSS_VERSION_WIRE_DIR = 'tests/e2e/cross-version-wire/' -export const UNIT_INCLUDE = [ - 'src/**/*.test.ts', - 'src/**/*.test.tsx', - 'config/scripts/**/*.test.ts', - 'config/scripts/**/*.test.mjs', - 'tests/tools/**/*.test.mjs', - 'tests/e2e/**/*.unit.test.ts' -] - export const UNIT_EXCLUDE = [ ...defaultExclude, 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts', diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 4fdd5e470dda..01d8ca8c11c1 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -135,12 +135,15 @@ it.each([ ])('fails closed or requests dependencies in an uninstalled checkout: %j', (scenario) => { const root = moduleTree( Object.fromEntries( - ['node-server-change-scope', 'node-server-test-paths', 'node-server-qualification'].map( - (name) => [ - `config/scripts/${name}.mjs`, - readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8') - ] - ) + [ + 'node-server-change-scope', + 'node-server-test-paths', + 'node-server-qualification', + 'unit-test-file-patterns' + ].map((name) => [ + `config/scripts/${name}.mjs`, + readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8') + ]) ) ) const changes = join(root, 'changes') diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 410299611d7b..20fa00e8cb2c 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -1,6 +1,6 @@ import { globSync } from 'node:fs' import { resolve } from 'node:path' -import { UNIT_INCLUDE } from './ci-unit-files.mjs' +import { UNIT_INCLUDE } from './unit-test-file-patterns.mjs' /** Mirrors ORCA_REQUIRED_TEST_INPUTS_ENV in src/main/orcad/orcad-node-slot-fixture.ts. */ export const REQUIRED_TEST_INPUTS_ENV = 'ORCA_REQUIRED_TEST_INPUTS' diff --git a/config/scripts/unit-test-file-patterns.mjs b/config/scripts/unit-test-file-patterns.mjs new file mode 100644 index 000000000000..e345ce88ecb8 --- /dev/null +++ b/config/scripts/unit-test-file-patterns.mjs @@ -0,0 +1,8 @@ +export const UNIT_INCLUDE = [ + 'src/**/*.test.ts', + 'src/**/*.test.tsx', + 'config/scripts/**/*.test.ts', + 'config/scripts/**/*.test.mjs', + 'tests/tools/**/*.test.mjs', + 'tests/e2e/**/*.unit.test.ts' +]