From c8a4fc60476b358b350dc627eea09209c157ae30 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:54:00 -0700 Subject: [PATCH] refactor(editor): keep tab buffer state in a per-tab store record mirrored onto OpenFile (#30885) --- .../editor-buffer-dirty-readers.test.ts | 108 ++++++++ .../apply-final-patch.ts | 5 +- .../apply-preparation-browser.ts | 7 + .../runtime/web-session-tabs-sync/state.ts | 1 + .../editor-buffer-record-lifecycle.test.ts | 240 ++++++++++++++++++ .../store/editor-buffer-record-lifecycle.ts | 97 +++++++ src/renderer/src/store/index.ts | 121 ++++----- .../actions/hydrated-editor-session-state.ts | 4 + .../editor/actions/open-file-mutations.ts | 43 +--- .../slices/editor/actions/open-file-state.ts | 2 + .../editor/actions/rekey-open-files-action.ts | 8 + .../restored-editor-owner-destination.ts | 15 +- .../restored-editor-owner-transition.ts | 4 + .../editor/actions/restored-editor-owner.ts | 7 +- .../editor/buffers/editor-buffer-record.ts | 142 +++++++++++ .../slices/editor/types/editor-files-slice.ts | 3 + 16 files changed, 700 insertions(+), 107 deletions(-) create mode 100644 src/renderer/src/runtime/editor-buffer-dirty-readers.test.ts create mode 100644 src/renderer/src/store/editor-buffer-record-lifecycle.test.ts create mode 100644 src/renderer/src/store/editor-buffer-record-lifecycle.ts create mode 100644 src/renderer/src/store/slices/editor/buffers/editor-buffer-record.ts diff --git a/src/renderer/src/runtime/editor-buffer-dirty-readers.test.ts b/src/renderer/src/runtime/editor-buffer-dirty-readers.test.ts new file mode 100644 index 00000000000..71b2490c455 --- /dev/null +++ b/src/renderer/src/runtime/editor-buffer-dirty-readers.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '../store' +import type { AppState } from '../store/types' +import { buildEditorSessionData } from '../lib/workspace-session' +import { sameOpenFiles } from './web-session-tabs-sync/state-equality-files' +import { buildRuntimeMobileOpenFilesProjection } from './sync-runtime-graph/sync-projections' +import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' +import { makeState } from './sync-runtime-graph-test-harness' +import { attachMobileMarkdownBridge } from './mobile-markdown-bridge' +import { + cleanupMobileMarkdownBridgeHarness, + openMarkdownFile, + resetEditorState, + sendRequest, + setupWindow +} from './mobile-markdown-bridge-test-harness' + +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: () => null +})) + +const FILE_ID = '/repo/README.md' + +/** Dirties the tab through its buffer record and returns the store on either side. */ +function dirtyThroughRecord(): { before: AppState; after: AppState } { + const before = useAppStore.getState() + before.markFileDirty(FILE_ID, true) + const after = useAppStore.getState() + expect(after.editorBuffers[FILE_ID]?.isDirty).toBe(true) + return { before, after } +} + +describe('a dirty change on the buffer record reaches every openFiles reader', () => { + beforeEach(() => { + resetEditorState() + openMarkdownFile() + }) + + afterEach(() => { + cleanupMobileMarkdownBridgeHarness() + }) + + it('web session tab sync sees the open files change', () => { + const { before, after } = dirtyThroughRecord() + + expect(sameOpenFiles(before.openFiles, after.openFiles)).toBe(false) + }) + + it('session save persists the draft of the now-dirty tab', () => { + useAppStore.getState().setEditorDraft(FILE_ID, '# edited\n') + const save = (state: AppState) => + buildEditorSessionData( + state.openFiles, + state.editorDrafts, + state.markdownFrontmatterVisible, + state.activeFileIdByWorktree, + state.activeTabTypeByWorktree + ).openFilesByWorktree?.['wt-1']?.[0]?.dirtyDraftContent + + const { before, after } = dirtyThroughRecord() + + expect(save(before)).toBeUndefined() + expect(save(after)).toBe('# edited\n') + }) + + it('mobile session projections publish the dirty flag', () => { + const { before, after } = dirtyThroughRecord() + const markdownTab = (state: AppState) => + buildMobileSessionTabSnapshots( + makeState({ + openFiles: state.openFiles, + editorDrafts: state.editorDrafts, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, + tabBarOrderByWorktree: { 'wt-1': [FILE_ID] } + }) + )[0]?.tabs.find((tab) => tab.type === 'markdown') + + expect(buildRuntimeMobileOpenFilesProjection(after.openFiles)).not.toBe( + buildRuntimeMobileOpenFilesProjection(before.openFiles) + ) + expect(markdownTab(before)).toMatchObject({ isDirty: false }) + expect(markdownTab(after)).toMatchObject({ isDirty: true }) + }) + + it('the mobile markdown bridge reports the tab dirty', async () => { + setupWindow({ + readFile: vi.fn().mockResolvedValue({ content: 'disk', isBinary: false }) + }) + const detach = attachMobileMarkdownBridge() + dirtyThroughRecord() + + try { + const response = await sendRequest({ + id: 'read-dirty', + operation: 'read', + worktreeId: 'wt-1', + tabId: 'tab-md' + }) + + expect(response).toMatchObject({ + ok: true, + result: { source: 'file', isDirty: true } + }) + } finally { + detach() + } + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts index 1b5b4892181..1cdbb8f0e43 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts @@ -28,6 +28,7 @@ export function buildWebSessionTabsFinalPatch( removedTerminalResourceIds, mirroredTerminalIds, nextOpenFiles, + nextEditorBuffers, nextBrowserTabsByWorktree, nextUnifiedTabsByWorktree, nextGroupsByWorktree, @@ -95,7 +96,9 @@ export function buildWebSessionTabsFinalPatch( ...agentStatusPatch, ...retractedTabSweepPatch, ...remirroredClosedTabLiftPatch, - ...(nextOpenFiles !== state.openFiles ? { openFiles: nextOpenFiles } : {}), + ...(nextOpenFiles !== state.openFiles + ? { openFiles: nextOpenFiles, editorBuffers: nextEditorBuffers } + : {}), ...(nextTabsByWorktree !== state.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), ...(nextBrowserTabsByWorktree !== state.browserTabsByWorktree ? { browserTabsByWorktree: nextBrowserTabsByWorktree } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index f30146b780f..dd5d824e998 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -13,6 +13,7 @@ import { } from '../host-session-snapshot-authority' import type { prepareWebSessionTabsSnapshotBase } from './apply-preparation-base' import type { OpenFile } from '../../store/slices/editor' +import { createEditorBuffers } from '../../store/slices/editor/buffers/editor-buffer-record' import { advanceWebSessionOpenFilesIndex, sameOpenFiles, @@ -177,6 +178,11 @@ export function prepareWebSessionTabsSnapshotBrowser( return sameOpenFiles(state.openFiles, next) ? state.openFiles : next })() advanceWebSessionOpenFilesIndex(batchContext, nextOpenFiles, worktreeId) + // Why: a mirrored tab's buffer state is the host's, so its record is (re)created with the tab. + const nextEditorBuffers = + nextOpenFiles === state.openFiles + ? state.editorBuffers + : createEditorBuffers(state.editorBuffers ?? {}, mirroredOpenFiles) const retainedUnifiedTabs = currentUnifiedTabs.filter((tab) => { if (tab.contentType === 'agent-session') { // A matching host row is authoritative; retaining the provisional tab beside its mirror @@ -237,6 +243,7 @@ export function prepareWebSessionTabsSnapshotBrowser( removedEditorFileIds, nextWorktreeOpenFileIds, nextOpenFiles, + nextEditorBuffers, retainedUnifiedTabs, existingViewModeByTabId } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index a73e8bac635..dfb27cff157 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -257,6 +257,7 @@ export type WebSessionTabsSyncState = Pick< // Why: a client draft is the evidence that a mirrored file's dirty flag is the client's // own and must survive a host republish (#21392); absent here, the host flag wins. | 'editorDrafts' + | 'editorBuffers' | 'localOnlyScrollbackByTabId' | 'migrationUnsupportedByPtyId' | 'manuallyUnreadTurnsByPaneKey' diff --git a/src/renderer/src/store/editor-buffer-record-lifecycle.test.ts b/src/renderer/src/store/editor-buffer-record-lifecycle.test.ts new file mode 100644 index 00000000000..87267209751 --- /dev/null +++ b/src/renderer/src/store/editor-buffer-record-lifecycle.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { create } from 'zustand' +import { useAppStore } from '@/store' +import { + assertEditorBufferInvariant, + withEditorBufferRecords +} from './editor-buffer-record-lifecycle' +import type { EditorBufferRecords } from './slices/editor/buffers/editor-buffer-record' +import type { OpenFile } from './slices/editor/types/open-file' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' + +function openEditTab(filePath = '/repo/a.md', worktreeId = 'wt-1'): string { + return useAppStore.getState().openFile( + { + filePath, + relativePath: filePath.slice('/repo/'.length), + worktreeId, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) +} + +function fileOf(id: string): OpenFile | undefined { + return useAppStore.getState().openFiles.find((file) => file.id === id) +} + +/** Fails the test if any notification ever exposes a mirror without its record. */ +function watchInvariant(): { notifications: () => number; stop: () => void } { + let notifications = 0 + const stop = useAppStore.subscribe((state) => { + notifications += 1 + assertEditorBufferInvariant(state) + }) + return { notifications: () => notifications, stop } +} + +describe('editor buffer records', () => { + beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + }) + + it('creates a record when a tab opens and deletes it when the tab closes', () => { + const id = openEditTab() + expect(useAppStore.getState().editorBuffers[id]).toEqual({ + isDirty: false, + lastKnownDiskSignature: undefined, + externalMutation: undefined, + pendingDiskBaselineVerification: undefined, + pendingLiveDiskVerification: undefined, + pendingOwnerMigration: undefined + }) + + useAppStore.getState().closeFile(id) + + expect(useAppStore.getState().editorBuffers).toEqual({}) + }) + + it.each([ + [ + 'markFileDirty', + (id: string) => useAppStore.getState().markFileDirty(id, true), + 'isDirty', + true + ], + [ + 'setExternalMutation', + (id: string) => useAppStore.getState().setExternalMutation(id, 'changed'), + 'externalMutation', + 'changed' + ], + [ + 'setLastKnownDiskSignature', + (id: string) => useAppStore.getState().setLastKnownDiskSignature(id, 'sig-1'), + 'lastKnownDiskSignature', + 'sig-1' + ], + [ + 'setPendingDiskBaselineVerification', + (id: string) => useAppStore.getState().setPendingDiskBaselineVerification(id, true), + 'pendingDiskBaselineVerification', + true + ], + [ + 'setPendingLiveDiskVerification', + (id: string) => useAppStore.getState().setPendingLiveDiskVerification(id, true), + 'pendingLiveDiskVerification', + true + ], + [ + 'setRestoredEditorOwnerMigrationPending', + (id: string) => useAppStore.getState().setRestoredEditorOwnerMigrationPending(id, true), + 'pendingOwnerMigration', + true + ] + ] as const)('%s writes the record and its mirror in one update', (_name, write, field, value) => { + const id = openEditTab() + const watch = watchInvariant() + + write(id) + + watch.stop() + expect(watch.notifications()).toBe(1) + expect(useAppStore.getState().editorBuffers[id]?.[field]).toBe(value) + expect(fileOf(id)?.[field]).toBe(value) + }) + + it('clears the restored-baseline gate on the record and its mirror', () => { + const id = openEditTab() + useAppStore.getState().setPendingDiskBaselineVerification(id, true) + + useAppStore.getState().clearPendingDiskBaselineVerification(id) + + expect(useAppStore.getState().editorBuffers[id]?.pendingDiskBaselineVerification).toBe( + undefined + ) + expect(fileOf(id)?.pendingDiskBaselineVerification).toBe(undefined) + }) + + it('re-keys the record with a self-move and gates the moved dirty tab on both copies', () => { + const id = openEditTab() + useAppStore.getState().markFileDirty(id, true) + useAppStore.getState().setLastKnownDiskSignature(id, 'sig-a') + const watch = watchInvariant() + + const result = useAppStore.getState().rekeyOpenFilesForPathChange({ + rekeys: [ + { + oldFileId: id, + newFileId: '/repo/b.md', + oldFilePath: '/repo/a.md', + newFilePath: '/repo/b.md', + newRelativePath: 'b.md' + } + ], + moveOperationId: 'move-1' + }) + + watch.stop() + expect(result).toEqual({ ok: true }) + const records = useAppStore.getState().editorBuffers + expect(Object.keys(records)).toEqual(['/repo/b.md']) + expect(records['/repo/b.md']).toMatchObject({ + isDirty: true, + lastKnownDiskSignature: 'sig-a', + pendingLiveDiskVerification: true + }) + expect(fileOf('/repo/b.md')?.pendingLiveDiskVerification).toBe(true) + }) + + it('creates hydrated records from the session, not from a replaced same-id tab', () => { + const workspace = folderWorkspaceKey('ws') + const id = openEditTab('/repo/a.md', workspace) + + useAppStore.getState().hydrateEditorSession( + { + activeRepoId: null, + activeWorktreeId: workspace, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { + [workspace]: [ + { + filePath: '/repo/a.md', + relativePath: 'a.md', + worktreeId: workspace, + language: 'markdown', + dirtyDraftContent: 'draft', + lastKnownDiskSignature: 'sig-saved' + } + ] + } + }, + { additionalValidWorkspaceKeys: [workspace] } + ) + + expect(useAppStore.getState().editorBuffers[id]).toMatchObject({ + isDirty: true, + lastKnownDiskSignature: 'sig-saved', + pendingDiskBaselineVerification: true + }) + }) + + it('seeds a record for an externally written tab from the tab, never from defaults', () => { + useAppStore.setState({ + openFiles: [ + { + id: 'seeded', + filePath: '/repo/seeded.md', + relativePath: 'seeded.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: true, + externalMutation: 'changed' + } + ] + }) + + expect(useAppStore.getState().editorBuffers.seeded).toMatchObject({ + isDirty: true, + externalMutation: 'changed' + }) + }) + + it('rejects a slice write that changes a mirror field without its record', () => { + type State = { + openFiles: OpenFile[] + editorBuffers: EditorBufferRecords + dirtyMirrorOnly: () => void + } + const store = create()( + withEditorBufferRecords((set) => ({ + openFiles: [ + { + id: 'a', + filePath: '/a', + relativePath: 'a', + worktreeId: 'wt', + language: 'text', + mode: 'edit', + isDirty: false + } + ], + editorBuffers: {}, + dirtyMirrorOnly: () => + set((s) => ({ + openFiles: s.openFiles.map((file) => ({ ...file, isDirty: true })) + })) + })) + ) + store.setState({ openFiles: [...store.getState().openFiles] }) + + expect(() => store.getState().dirtyMirrorOnly()).toThrow(/"isDirty" of a/) + expect(store.getState().openFiles[0]?.isDirty).toBe(false) + }) +}) diff --git a/src/renderer/src/store/editor-buffer-record-lifecycle.ts b/src/renderer/src/store/editor-buffer-record-lifecycle.ts new file mode 100644 index 00000000000..c87d82b19e8 --- /dev/null +++ b/src/renderer/src/store/editor-buffer-record-lifecycle.ts @@ -0,0 +1,97 @@ +import type { StateCreator } from 'zustand' +import type { OpenFile } from './slices/editor/types/open-file' +import { + editorBufferMirrorMismatch, + editorBufferRecordFromFile, + reconcileEditorBufferMembership, + type EditorBufferRecords +} from './slices/editor/buffers/editor-buffer-record' + +type EditorBufferState = { + openFiles: OpenFile[] + editorBuffers: EditorBufferRecords +} + +/** + * Ties editor buffer records to `openFiles` membership inside the same write, so open, hydrate, + * close and teardown never need their own record bookkeeping. Slice writes must route the six + * buffer fields through the record; dev and test builds throw when a tab mirror drifts from it. + */ +function reconcileEditorBuffers( + state: TState, + write: TState | Partial, + adoptMirrorDrift: boolean +): TState | Partial { + const openFiles = write.openFiles ?? state.openFiles + const records = write.editorBuffers ?? state.editorBuffers + if (openFiles === state.openFiles && records === state.editorBuffers) { + return write + } + let next = reconcileEditorBufferMembership(records, openFiles) + for (const file of openFiles) { + const field = editorBufferMirrorMismatch(file, next[file.id]) + if (field === null) { + continue + } + // Why adopt outside dev: a missed writer must keep main's behaviour, not revert the tab. + if (!adoptMirrorDrift && import.meta.env.DEV) { + throw new Error( + `Editor buffer mirror "${field}" of ${file.id} was written without its record` + ) + } + next = next === records ? { ...records } : next + next[file.id] = editorBufferRecordFromFile(file) + } + return next === records ? write : { ...write, editorBuffers: next } +} + +export function assertEditorBufferInvariant(state: EditorBufferState): void { + const ids = new Set(state.openFiles.map((file) => file.id)) + const recordIds = Object.keys(state.editorBuffers) + if (recordIds.length !== ids.size || recordIds.some((id) => !ids.has(id))) { + throw new Error('Editor buffer records are not keyed by the open editor tabs') + } + for (const file of state.openFiles) { + const field = editorBufferMirrorMismatch(file, state.editorBuffers[file.id]) + if (field !== null) { + throw new Error(`Editor buffer mirror "${field}" of ${file.id} differs from its record`) + } + } +} + +export function withEditorBufferRecords( + createState: StateCreator +): StateCreator { + return (set, get, api) => { + // Why external writes adopt: `useAppStore.setState` is the fixture/seed path, like hydrate. + const wrap = (write: typeof set, adoptMirrorDrift: boolean): typeof set => { + const wrapped = ( + partial: TState | Partial | ((state: TState) => TState | Partial), + replace?: boolean + ): void => { + const before = get() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: forwards the caller's own partial/replace pair; with replace=true the updater still yields a full state. + ;(write as (next: (state: TState) => TState | Partial, replace?: boolean) => void)( + (state) => + reconcileEditorBuffers( + state, + typeof partial === 'function' ? partial(state) : partial, + adoptMirrorDrift + ), + replace + ) + const after = get() + if ( + import.meta.env.DEV && + (after.openFiles !== before.openFiles || after.editorBuffers !== before.editorBuffers) + ) { + assertEditorBufferInvariant(after) + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: one implementation serves both zustand setState overloads. + return wrapped as typeof set + } + api.setState = wrap(api.setState, true) + return createState(wrap(set, false), get, api) + } +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index a41e03c1c81..fd7b7db0759 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -61,6 +61,7 @@ import { installSettledSortEpoch } from './settled-sort-epoch' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../shared/sidebar-inputs-switch' import { withReactCommitCascadeWriteProbe } from './react-commit-cascade-write-probe' import { withStoreIdentityChurnProbe } from './store-identity-churn-probe' +import { withEditorBufferRecords } from './editor-buffer-record-lifecycle' import { registerRendererMemoryProfileContributor, summarizeStateCollectionSizes @@ -78,65 +79,67 @@ const withDevelopmentStoreProbes = (createState: StateCreator) export const useAppStore = create()( withDevelopmentStoreProbes( - withReactCommitCascadeWriteProbe((...a) => { - // Why first: settling inside the bump's own notify means hook subscribers never see it unsettled. - // Switch point: the stamp-sorted list reads no settled epoch. - if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) { - installSettledSortEpoch(a[2]) - } - // Why: the inner api is only reachable here, before create() copies subscribe onto the hook. - installStoreListenerCensus(a[2]) - return { - ...createRepoSlice(...a), - ...createSparsePresetsSlice(...a), - ...createWorktreeSlice(...a), - ...createTerminalSlice(...a), - ...createTabsSlice(...a), - ...createUISlice(...a), - ...createSettingsSlice(...a), - ...createKeybindingsSlice(...a), - ...createGitHubSlice(...a), - ...createHostedReviewSlice(...a), - ...createLinearSlice(...a), - ...createPreflightSlice(...a), - ...createJiraSlice(...a), - ...createEditorSlice(...a), - ...createStatsSlice(...a), - ...createMemorySlice(...a), - ...createWorkspaceSpaceSlice(...a), - ...createClaudeUsageSlice(...a), - ...createCodexUsageSlice(...a), - ...createOpenCodeUsageSlice(...a), - ...createMuseUsageSlice(...a), - ...createBrowserSlice(...a), - ...createRateLimitSlice(...a), - ...createSshSlice(...a), - ...createRuntimeEnvironmentSshSlice(...a), - ...createWslHostsSlice(...a), - ...createAgentStatusSlice(...a), - ...createPaneForegroundAgentSlice(...a), - ...createDiffCommentsSlice(...a), - ...createDetectedAgentsSlice(...a), - ...createRuntimeDetectedAgentsSlice(...a), - ...createWorktreeNavHistorySlice(...a), - ...createDictationSlice(...a), - ...createWorkspaceCleanupSlice(...a), - ...createWorkspaceCleanupBrowseSlice(...a), - ...createRuntimeStatusSlice(...a), - ...createPullRequestGenerationSlice(...a), - ...createCommitMessageGenerationSlice(...a), - ...createPinnedTabCloseConfirmSlice(...a), - ...createRecentlyClosedTabsSlice(...a), - ...createOrcaProfilesSlice(...a), - ...createNewIssueDraftSlice(...a), - ...createFeedbackDraftSlice(...a), - ...createTaskCreationDraftsSlice(...a), - ...createRemoteServerUpdatesSlice(...a), - ...createTerminalQuickCommandHostsSlice(...a), - ...createStructuredSessionLaunchDirectorySlice(...a), - ...createWorkspaceAttentionSlice(...a) - } - }) + withReactCommitCascadeWriteProbe( + withEditorBufferRecords((...a) => { + // Why first: settling inside the bump's own notify means hook subscribers never see it unsettled. + // Switch point: the stamp-sorted list reads no settled epoch. + if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) { + installSettledSortEpoch(a[2]) + } + // Why: the inner api is only reachable here, before create() copies subscribe onto the hook. + installStoreListenerCensus(a[2]) + return { + ...createRepoSlice(...a), + ...createSparsePresetsSlice(...a), + ...createWorktreeSlice(...a), + ...createTerminalSlice(...a), + ...createTabsSlice(...a), + ...createUISlice(...a), + ...createSettingsSlice(...a), + ...createKeybindingsSlice(...a), + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a), + ...createLinearSlice(...a), + ...createPreflightSlice(...a), + ...createJiraSlice(...a), + ...createEditorSlice(...a), + ...createStatsSlice(...a), + ...createMemorySlice(...a), + ...createWorkspaceSpaceSlice(...a), + ...createClaudeUsageSlice(...a), + ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), + ...createMuseUsageSlice(...a), + ...createBrowserSlice(...a), + ...createRateLimitSlice(...a), + ...createSshSlice(...a), + ...createRuntimeEnvironmentSshSlice(...a), + ...createWslHostsSlice(...a), + ...createAgentStatusSlice(...a), + ...createPaneForegroundAgentSlice(...a), + ...createDiffCommentsSlice(...a), + ...createDetectedAgentsSlice(...a), + ...createRuntimeDetectedAgentsSlice(...a), + ...createWorktreeNavHistorySlice(...a), + ...createDictationSlice(...a), + ...createWorkspaceCleanupSlice(...a), + ...createWorkspaceCleanupBrowseSlice(...a), + ...createRuntimeStatusSlice(...a), + ...createPullRequestGenerationSlice(...a), + ...createCommitMessageGenerationSlice(...a), + ...createPinnedTabCloseConfirmSlice(...a), + ...createRecentlyClosedTabsSlice(...a), + ...createOrcaProfilesSlice(...a), + ...createNewIssueDraftSlice(...a), + ...createFeedbackDraftSlice(...a), + ...createTaskCreationDraftsSlice(...a), + ...createRemoteServerUpdatesSlice(...a), + ...createTerminalQuickCommandHostsSlice(...a), + ...createStructuredSessionLaunchDirectorySlice(...a), + ...createWorkspaceAttentionSlice(...a) + } + }) + ) ) ) diff --git a/src/renderer/src/store/slices/editor/actions/hydrated-editor-session-state.ts b/src/renderer/src/store/slices/editor/actions/hydrated-editor-session-state.ts index a50ae12f5d4..50197fcabef 100644 --- a/src/renderer/src/store/slices/editor/actions/hydrated-editor-session-state.ts +++ b/src/renderer/src/store/slices/editor/actions/hydrated-editor-session-state.ts @@ -3,6 +3,7 @@ import type { WorkspaceVisibleTabType } from '../../../../../../shared/tab-types import type { WorkspaceSessionState } from '../../../../../../shared/workspace-session-state-types' import type { AppState } from '../../../types' import type { OpenFile } from '../types/open-file' +import { reconcileEditorBufferMembership } from '../buffers/editor-buffer-record' import { buildOwnedEditorFileId } from '../file-ids/editor-file-ids' import { resolveHydratedEditorFileSelection } from '../file-ids/hydrated-editor-file-selection' import { resolveHydratedEditorFrontmatter } from '../file-ids/hydrated-editor-frontmatter' @@ -15,6 +16,7 @@ import { export type HydratedEditorSessionState = Pick< AppState, | 'openFiles' + | 'editorBuffers' | 'editorDrafts' | 'markdownFrontmatterVisible' | 'activeFileId' @@ -155,6 +157,8 @@ export function buildHydratedEditorSessionState( return { state: { openFiles, + // Why fresh: a restored tab's buffer state comes from the session, not a same-id tab it replaces. + editorBuffers: reconcileEditorBufferMembership({}, openFiles), editorDrafts, markdownFrontmatterVisible, activeFileId: nextActiveFileId, diff --git a/src/renderer/src/store/slices/editor/actions/open-file-mutations.ts b/src/renderer/src/store/slices/editor/actions/open-file-mutations.ts index 823cee33bcf..529f282c913 100644 --- a/src/renderer/src/store/slices/editor/actions/open-file-mutations.ts +++ b/src/renderer/src/store/slices/editor/actions/open-file-mutations.ts @@ -8,6 +8,7 @@ import { previewEditorTabsOf, recordPromotedEditorTabsInLayoutOwner } from '../tabs/editor-tab-layout-owner' +import { writeEditorBuffer } from '../buffers/editor-buffer-record' export function createOpenFileMutations( set: EditorSet, @@ -84,13 +85,13 @@ export function createOpenFileMutations( if (ORCA_LAYOUT_OWNER_IS_MAIN && needsPreviewClear) { promoted.push(...previewEditorTabsOf(s.unifiedTabsByWorktree, fileId)) } - const nextOpenFiles = s.openFiles.map((f) => - f.id === fileId - ? { ...f, isDirty: dirty, ...(needsPreviewClear ? { isPreview: undefined } : {}) } - : f - ) return { - openFiles: nextOpenFiles, + ...writeEditorBuffer( + s, + fileId, + { isDirty: dirty }, + needsPreviewClear ? { isPreview: undefined } : undefined + ), ...(needsPreviewClear ? { unifiedTabsByWorktree: Object.fromEntries( @@ -123,11 +124,7 @@ export function createOpenFileMutations( if (file.externalMutation === next) { return s } - return { - openFiles: s.openFiles.map((f) => - f.id === fileId ? { ...f, externalMutation: next } : f - ) - } + return writeEditorBuffer(s, fileId, { externalMutation: next }) }), setLastKnownDiskSignature: (fileId, signature) => @@ -136,11 +133,7 @@ export function createOpenFileMutations( if (!file || file.lastKnownDiskSignature === signature) { return s } - return { - openFiles: s.openFiles.map((f) => - f.id === fileId ? { ...f, lastKnownDiskSignature: signature } : f - ) - } + return writeEditorBuffer(s, fileId, { lastKnownDiskSignature: signature }) }), clearPendingDiskBaselineVerification: (fileId) => @@ -149,11 +142,7 @@ export function createOpenFileMutations( if (!file?.pendingDiskBaselineVerification) { return s } - return { - openFiles: s.openFiles.map((f) => - f.id === fileId ? { ...f, pendingDiskBaselineVerification: undefined } : f - ) - } + return writeEditorBuffer(s, fileId, { pendingDiskBaselineVerification: undefined }) }), setPendingDiskBaselineVerification: (fileId, value) => @@ -163,11 +152,7 @@ export function createOpenFileMutations( if (!file || file.pendingDiskBaselineVerification === next) { return s } - return { - openFiles: s.openFiles.map((f) => - f.id === fileId ? { ...f, pendingDiskBaselineVerification: next } : f - ) - } + return writeEditorBuffer(s, fileId, { pendingDiskBaselineVerification: next }) }), setPendingLiveDiskVerification: (fileId, value) => @@ -177,11 +162,7 @@ export function createOpenFileMutations( if (!file || file.pendingLiveDiskVerification === next) { return s } - return { - openFiles: s.openFiles.map((f) => - f.id === fileId ? { ...f, pendingLiveDiskVerification: next } : f - ) - } + return writeEditorBuffer(s, fileId, { pendingLiveDiskVerification: next }) }), clearSelfMoveEcho: (fileId) => diff --git a/src/renderer/src/store/slices/editor/actions/open-file-state.ts b/src/renderer/src/store/slices/editor/actions/open-file-state.ts index a2100daa1f9..72b980d12b8 100644 --- a/src/renderer/src/store/slices/editor/actions/open-file-state.ts +++ b/src/renderer/src/store/slices/editor/actions/open-file-state.ts @@ -8,6 +8,7 @@ export function createOpenFileState( ): Pick< EditorSlice, | 'openFiles' + | 'editorBuffers' | 'activeFileId' | 'activeFileIdByWorktree' | 'activeTabTypeByWorktree' @@ -17,6 +18,7 @@ export function createOpenFileState( > { return { openFiles: [], + editorBuffers: {}, activeFileId: null, activeFileIdByWorktree: {}, activeTabTypeByWorktree: {}, diff --git a/src/renderer/src/store/slices/editor/actions/rekey-open-files-action.ts b/src/renderer/src/store/slices/editor/actions/rekey-open-files-action.ts index b5649fff328..7ed5f123987 100644 --- a/src/renderer/src/store/slices/editor/actions/rekey-open-files-action.ts +++ b/src/renderer/src/store/slices/editor/actions/rekey-open-files-action.ts @@ -2,6 +2,7 @@ import type { EditorGet, EditorSet } from '../types/editor-set-get' import type { EditorSlice } from '../types/editor-slice' import type { OpenFilePathRekey, RekeyOpenFilesResult } from '../types/open-file-path-rekey' import { rekeyFileIdRecord } from '../file-ids/open-file-path-rekey' +import { rekeyEditorBuffers } from '../buffers/editor-buffer-record' import { migrateHydratedEditorTabsAndGroups } from '../file-ids/hydrated-editor-file-ids' import { recordRenamedEditorFilesInLayoutOwner } from '../tabs/editor-tab-layout-owner' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../../shared/layout-owner-switch' @@ -46,6 +47,7 @@ export function createRekeyOpenFilesAction( } } + const echoGatedIds = new Set() const nextOpenFiles = s.openFiles.map((f) => { const rekey = rekeyByOldId.get(f.id) if (!rekey) { @@ -59,6 +61,9 @@ export function createRekeyOpenFilesAction( // A 'changed' tab is already autosave-suspended via externalMutation; gating it would strand the gate (verification skips a 'changed' tab), so leave the banner as terminal. f.externalMutation !== 'changed' && (f.mode === 'edit' || (f.mode === 'diff' && f.diffSource === 'unstaged')) + if (gatesEcho) { + echoGatedIds.add(f.id) + } return { ...f, id: rekey.newFileId, @@ -117,6 +122,9 @@ export function createRekeyOpenFilesAction( return { openFiles: nextOpenFiles, + editorBuffers: rekeyEditorBuffers(s.editorBuffers, s.openFiles, migrations, (f) => + echoGatedIds.has(f.id) ? { pendingLiveDiskVerification: true } : undefined + ), editorDrafts: rekeyFileIdRecord(s.editorDrafts, migrations), editorCursorLine: rekeyFileIdRecord(s.editorCursorLine, migrations), markdownViewMode: rekeyFileIdRecord(s.markdownViewMode, migrations), diff --git a/src/renderer/src/store/slices/editor/actions/restored-editor-owner-destination.ts b/src/renderer/src/store/slices/editor/actions/restored-editor-owner-destination.ts index b487abddbe9..a79f59ca850 100644 --- a/src/renderer/src/store/slices/editor/actions/restored-editor-owner-destination.ts +++ b/src/renderer/src/store/slices/editor/actions/restored-editor-owner-destination.ts @@ -2,6 +2,7 @@ import type { AppState } from '../../../types' import { assertEditorFileOperationCurrent } from '@/lib/editor-file-operation-owner' import { buildOwnedEditorFileId } from '../file-ids/editor-file-ids' import type { OpenFile } from '../types/open-file' +import { writeEditorBuffer } from '../buffers/editor-buffer-record' import type { RestoredEditorOwnerMigration, RestoredEditorOwnerResult @@ -10,7 +11,7 @@ import type { export type RestoredEditorOwnerFail = { ok: false reason: Extract['reason'] - patch: Pick + patch: Pick & Partial> } export type RestoredEditorOwnerReady = { @@ -46,11 +47,7 @@ export function resolveRestoredEditorOwnerDestination( return { ok: false, reason: 'owner-changed', - patch: { - openFiles: s.openFiles.map((file) => - file.id === args.fileId ? { ...file, pendingOwnerMigration: undefined } : file - ) - } + patch: writeEditorBuffer(s, args.fileId, { pendingOwnerMigration: undefined }) } } const operationProvenance = args.targetOperationProvenance @@ -84,11 +81,7 @@ export function resolveRestoredEditorOwnerDestination( return { ok: false, reason: 'collision', - patch: { - openFiles: s.openFiles.map((file) => - file.id === args.fileId ? { ...file, pendingOwnerMigration: undefined } : file - ) - } + patch: writeEditorBuffer(s, args.fileId, { pendingOwnerMigration: undefined }) } } diff --git a/src/renderer/src/store/slices/editor/actions/restored-editor-owner-transition.ts b/src/renderer/src/store/slices/editor/actions/restored-editor-owner-transition.ts index aeda146707d..2069ff41f2e 100644 --- a/src/renderer/src/store/slices/editor/actions/restored-editor-owner-transition.ts +++ b/src/renderer/src/store/slices/editor/actions/restored-editor-owner-transition.ts @@ -9,6 +9,7 @@ import { removeTabIdsFromGroup, rekeyFileIdRecord } from '../file-ids/open-file-path-rekey' +import { rekeyEditorBuffers } from '../buffers/editor-buffer-record' import type { RestoredEditorOwnerMigration, RestoredEditorOwnerResult @@ -226,6 +227,9 @@ export function buildRestoredEditorOwnerTransition( } : file ), + editorBuffers: rekeyEditorBuffers(s.editorBuffers, s.openFiles, migrations, () => ({ + pendingOwnerMigration: undefined + })), editorDrafts: rekeyFileIdRecord(s.editorDrafts, migrations), editorCursorLine: rekeyFileIdRecord(s.editorCursorLine, migrations), markdownViewMode: rekeyFileIdRecord(s.markdownViewMode, migrations), diff --git a/src/renderer/src/store/slices/editor/actions/restored-editor-owner.ts b/src/renderer/src/store/slices/editor/actions/restored-editor-owner.ts index 9fe087406fc..fe38090a8d9 100644 --- a/src/renderer/src/store/slices/editor/actions/restored-editor-owner.ts +++ b/src/renderer/src/store/slices/editor/actions/restored-editor-owner.ts @@ -4,6 +4,7 @@ import type { RestoredEditorOwnerResult } from '../types/restored-editor-owner' import { buildRestoredEditorOwnerTransition } from './restored-editor-owner-transition' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../../shared/layout-owner-switch' import { recordRestoredEditorMoveInLayoutOwner } from '../tabs/editor-tab-layout-owner' +import { writeEditorBuffer } from '../buffers/editor-buffer-record' export function createRestoredEditorOwner( set: EditorSet, @@ -19,11 +20,7 @@ export function createRestoredEditorOwner( return s } changed = true - return { - openFiles: s.openFiles.map((candidate) => - candidate.id === fileId ? { ...candidate, pendingOwnerMigration: next } : candidate - ) - } + return writeEditorBuffer(s, fileId, { pendingOwnerMigration: next }) }) return changed }, diff --git a/src/renderer/src/store/slices/editor/buffers/editor-buffer-record.ts b/src/renderer/src/store/slices/editor/buffers/editor-buffer-record.ts new file mode 100644 index 00000000000..87479c80564 --- /dev/null +++ b/src/renderer/src/store/slices/editor/buffers/editor-buffer-record.ts @@ -0,0 +1,142 @@ +import type { OpenFile } from '../types/open-file' + +/** + * Source of truth for a tab's buffer state, keyed by `OpenFile.id`. The same fields on + * `OpenFile` are a mirror written in the same store update so existing readers keep working. + */ +export type EditorBufferRecord = Pick< + OpenFile, + | 'isDirty' + | 'lastKnownDiskSignature' + | 'externalMutation' + | 'pendingDiskBaselineVerification' + | 'pendingLiveDiskVerification' + | 'pendingOwnerMigration' +> + +export type EditorBufferRecords = Record + +export const EDITOR_BUFFER_FIELDS = [ + 'isDirty', + 'lastKnownDiskSignature', + 'externalMutation', + 'pendingDiskBaselineVerification', + 'pendingLiveDiskVerification', + 'pendingOwnerMigration' +] as const satisfies readonly (keyof EditorBufferRecord)[] + +export function editorBufferRecordFromFile(file: OpenFile): EditorBufferRecord { + return { + isDirty: file.isDirty, + lastKnownDiskSignature: file.lastKnownDiskSignature, + externalMutation: file.externalMutation, + pendingDiskBaselineVerification: file.pendingDiskBaselineVerification, + pendingLiveDiskVerification: file.pendingLiveDiskVerification, + pendingOwnerMigration: file.pendingOwnerMigration + } +} + +/** The file's record; a missing one is seeded from the tab, never from defaults. */ +export function editorBufferOf(records: EditorBufferRecords, file: OpenFile): EditorBufferRecord { + return records[file.id] ?? editorBufferRecordFromFile(file) +} + +export function editorBufferMirrorMismatch( + file: OpenFile, + record: EditorBufferRecord +): keyof EditorBufferRecord | null { + return EDITOR_BUFFER_FIELDS.find((field) => !Object.is(file[field], record[field])) ?? null +} + +/** Patches `file`'s record; callers spread the same patch onto the tab in the same update. */ +export function patchEditorBuffer( + records: EditorBufferRecords, + file: OpenFile, + patch: Partial +): EditorBufferRecords { + return { + ...records, + [file.id]: { ...editorBufferOf(records, file), ...patch } + } +} + +/** Writes `patch` to one file's record and its tab mirror in one update. */ +export function writeEditorBuffer( + state: { openFiles: OpenFile[]; editorBuffers: EditorBufferRecords }, + fileId: string, + patch: Partial, + tabPatch?: Partial +): { openFiles: OpenFile[]; editorBuffers: EditorBufferRecords } { + let editorBuffers = state.editorBuffers + const openFiles = state.openFiles.map((file) => { + if (file.id !== fileId) { + return file + } + editorBuffers = patchEditorBuffer(editorBuffers, file, patch) + return { ...file, ...tabPatch, ...patch } + }) + return { openFiles, editorBuffers } +} + +/** (Re)creates records for tabs whose buffer state comes from outside the store, e.g. a host snapshot. */ +export function createEditorBuffers( + records: EditorBufferRecords, + files: readonly OpenFile[] +): EditorBufferRecords { + if (files.length === 0) { + return records + } + const next = { ...records } + for (const file of files) { + next[file.id] = editorBufferRecordFromFile(file) + } + return next +} + +/** Moves each migrated tab's record to its new id in the same update that re-keys the tab. */ +export function rekeyEditorBuffers( + records: EditorBufferRecords, + openFiles: readonly OpenFile[], + migrations: ReadonlyMap, + patchFor?: (file: OpenFile) => Partial | undefined +): EditorBufferRecords { + const moved = openFiles.filter((file) => migrations.has(file.id)) + if (moved.length === 0) { + return records + } + const next = { ...records } + // Why two passes: a swap of two ids must not delete the record it just wrote. + for (const file of moved) { + delete next[file.id] + } + for (const file of moved) { + next[migrations.get(file.id) ?? file.id] = { + ...editorBufferOf(records, file), + ...patchFor?.(file) + } + } + return next +} + +/** Keeps records keyed exactly by the open tabs: seeds new tabs, drops closed ones. */ +export function reconcileEditorBufferMembership( + records: EditorBufferRecords, + openFiles: readonly OpenFile[] +): EditorBufferRecords { + let next = records + const ids = new Set() + for (const file of openFiles) { + ids.add(file.id) + if (!Object.hasOwn(next, file.id)) { + next = next === records ? { ...records } : next + next[file.id] = editorBufferRecordFromFile(file) + } + } + for (const id of Object.keys(next)) { + if (!ids.has(id)) { + next = next === records ? { ...records } : next + delete next[id] + } + } + return next +} diff --git a/src/renderer/src/store/slices/editor/types/editor-files-slice.ts b/src/renderer/src/store/slices/editor/types/editor-files-slice.ts index 34297d11b40..f062e6914a5 100644 --- a/src/renderer/src/store/slices/editor/types/editor-files-slice.ts +++ b/src/renderer/src/store/slices/editor/types/editor-files-slice.ts @@ -21,6 +21,7 @@ import type { EditorTabSelection, OpenFile } from './open-file' +import type { EditorBufferRecords } from '../buffers/editor-buffer-record' import type { EditorOpenLinkIntent, EditorOpenResult } from './editor-open-link-intent' import type { OpenFilePathRekey, RekeyOpenFilesResult } from './open-file-path-rekey' import type { @@ -42,6 +43,8 @@ export type OpenFileOptions = { export type EditorFilesSlice = { // Open files / editor tabs openFiles: OpenFile[] + /** Buffer state per open tab id; the matching OpenFile fields mirror it. */ + editorBuffers: EditorBufferRecords activeFileId: string | null activeFileIdByWorktree: Record // worktreeId -> last active file activeTabTypeByWorktree: Record // worktreeId -> last active tab type