From ebc8b06057a6829ba30e4acd0008eb4c013763af Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:01:30 -0700 Subject: [PATCH] Editor tab state is written to its buffer record, not copied into it afterwards (phase 1a) (#31251) * refactor(editor): make the editor buffer record the source of truth for tab buffer state Writers of the six buffer fields read and write the record, with the OpenFile fields written as a mirror in the same store update. The store middleware no longer copies tab values into the record; it only keeps records keyed by open tabs (seeded from the tab on create, dropped on close) and, in dev and test builds, asserts every mirror field equals its record. Host-mirrored web tabs adopt the host's published values explicitly in the session sync patch. Co-Authored-By: Claude * refactor(editor): check the buffer invariant in e2e builds and tidy the record helpers Co-Authored-By: Claude * test(e2e): give session-sync fixtures the now-required editorBuffers Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../editor-autosave-conflict-flow.test.ts | 8 +-- .../editor-restored-tab-conflict-scan.test.ts | 8 +-- ...e-floating-terminal-close-actions.test.tsx | 3 +- ...ab-close-commands.floating-reclaim.test.ts | 3 +- ...-session-empty-worktree-visibility.test.ts | 1 + ...local-structured-session-tabs-sync.test.ts | 1 + ...red-session-unverifiable-inventory.test.ts | 1 + .../src/runtime/remote-server-parity.test.ts | 1 + ...ssion-open-files-batch-equivalence.test.ts | 1 + .../web-session-structured-tab-focus.test.ts | 5 +- ...-session-tabs-sync-mirror-identity.test.ts | 1 + .../web-session-tabs-sync-test-harness.ts | 1 + .../apply-final-patch.ts | 9 ++- .../runtime/web-session-tabs-sync/state.ts | 1 + .../editor-buffer-record-lifecycle.test.ts | 32 ++++++---- .../store/editor-buffer-record-lifecycle.ts | 54 +++++----------- src/renderer/src/store/index.ts | 4 +- .../slices/editor-rekey-open-files.test.ts | 6 +- .../editor/actions/open-file-mutations.ts | 63 ++++++------------- .../editor/actions/restored-editor-owner.ts | 12 ++-- .../editor/buffers/editor-buffer-record.ts | 47 ++++++++++++-- tests/e2e/editor-model-host-isolation.spec.ts | 7 +-- ...emote-terminal-tab-retirement.unit.test.ts | 1 + ...-tabs-decorative-title-fanout.unit.test.ts | 1 + 24 files changed, 133 insertions(+), 138 deletions(-) diff --git a/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts b/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts index 198797639c9..c8b7b576eca 100644 --- a/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts @@ -151,13 +151,7 @@ describe('editor autosave changed-on-disk conflict flow', () => { // Why: mimic hydration — the scan has not yet compared disk against the // persisted baseline, so autosave must hold off (a slow remote read must // not lose a race to this timer). - store.setState({ - openFiles: store - .getState() - .openFiles.map((f) => - f.id === '/repo/file.ts' ? { ...f, pendingDiskBaselineVerification: true } : f - ) - } as never) + store.getState().setPendingDiskBaselineVerification('/repo/file.ts', true) const cleanup = attachEditorAutosaveController(withLoadedBaselines(store)) try { diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 96007652eb9..73a8172b70c 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -47,13 +47,7 @@ function openRestoredDirtyTab( store.getState().markFileDirty(filePath, true) store.getState().setLastKnownDiskSignature(filePath, getDiskBaselineSignature(baselineContent)) // Why: hydration flags restored dirty tabs for verification; tests mimic it. - store.setState({ - openFiles: store - .getState() - .openFiles.map((f) => - f.id === filePath ? { ...f, pendingDiskBaselineVerification: true } : f - ) - } as never) + store.getState().setPendingDiskBaselineVerification(filePath, true) } describe('attachRestoredTabConflictScan', () => { diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx index dc535979f8a..886a546a9cf 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx @@ -141,7 +141,8 @@ describe('floating titlebar close actions', () => { it('routes a dirty last reference through the shared save confirmation', () => { useAppStore.setState({ unifiedTabsByWorktree: { [worktreeId]: [editor] }, - openFiles: [makeOpenFile({ id: sharedFileId, worktreeId, isDirty: true })] + openFiles: [makeOpenFile({ id: sharedFileId, worktreeId, isDirty: true })], + editorBuffers: {} }) actions().closeFloatingItemConfirmed(editor.id) diff --git a/src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts b/src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts index 00743829fb8..c994dcbb4fd 100644 --- a/src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts +++ b/src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts @@ -64,7 +64,8 @@ function requestTerminalClose(): { land: () => void } { function floatingNote({ isDirty, isPinned }: { isDirty: boolean; isPinned: boolean }): Tab { useAppStore.setState({ - openFiles: [makeOpenFile({ id: 'note', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, isDirty })] + openFiles: [makeOpenFile({ id: 'note', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, isDirty })], + editorBuffers: {} }) return { ...terminalTab, id: 'unified-note', entityId: 'note', contentType: 'editor', isPinned } } diff --git a/src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts b/src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts index ad856fbdad8..446a76a5b31 100644 --- a/src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts +++ b/src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts @@ -40,6 +40,7 @@ function emptyState(overrides: Partial): WebSessionTabs groupsByWorktree: {}, layoutByWorktree: {}, openFiles: [], + editorBuffers: {}, ptyIdsByTabId: {}, remoteBrowserPageHandlesByPageId: {}, tabBarOrderByWorktree: {}, diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts index 4ffb406a501..4cbad197876 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -112,6 +112,7 @@ function createSnapshot(): WebSessionTabsSyncState { } }, openFiles: [], + editorBuffers: {}, ptyIdsByTabId: { [TERMINAL_ID]: ['pty-1'] }, remoteBrowserPageHandlesByPageId: {}, tabBarOrderByWorktree: { [WORKTREE_ID]: [TERMINAL_ID, STRUCTURED_ID] }, diff --git a/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts b/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts index cc0cfa9d890..2536a3a4297 100644 --- a/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts +++ b/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts @@ -102,6 +102,7 @@ function hydratedState(): WebSessionTabsSyncState { } }, openFiles: [], + editorBuffers: {}, ptyIdsByTabId: { [TERMINAL_ID]: ['pty-1'] }, remoteBrowserPageHandlesByPageId: {}, tabBarOrderByWorktree: { [WORKTREE_ID]: [TERMINAL_ID, CLAUDE_TAB, CODEX_TAB] }, diff --git a/src/renderer/src/runtime/remote-server-parity.test.ts b/src/renderer/src/runtime/remote-server-parity.test.ts index e4b52da59ce..38492e08cab 100644 --- a/src/renderer/src/runtime/remote-server-parity.test.ts +++ b/src/renderer/src/runtime/remote-server-parity.test.ts @@ -54,6 +54,7 @@ function makeState(overrides: Partial = {}): WebSession groupsByWorktree: {}, layoutByWorktree: {}, openFiles: [], + editorBuffers: {}, ptyIdsByTabId: {}, remoteBrowserPageHandlesByPageId: {}, tabBarOrderByWorktree: {}, diff --git a/src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts b/src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts index 981a52d69f9..bce2d20a247 100644 --- a/src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts +++ b/src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts @@ -60,6 +60,7 @@ function baseState(overrides: Partial): WebSessionTabsS groupsByWorktree: {}, layoutByWorktree: {}, openFiles: [], + editorBuffers: {}, ptyIdsByTabId: {}, remoteBrowserPageHandlesByPageId: {}, tabBarOrderByWorktree: {}, diff --git a/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts index 25a38bf6842..8c25c6ad4c0 100644 --- a/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts +++ b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts @@ -30,7 +30,7 @@ describe('web session structured tab focus', () => { it('keeps the exact active structured tab across a host snapshot', () => { const first = structuredTab('session-1', 0) const second = structuredTab('session-2', 1) - const state = { + const state: WebSessionTabsSyncState = { activeBrowserTabId: null, activeBrowserTabIdByWorktree: {}, activeFileId: null, @@ -58,6 +58,7 @@ describe('web session structured tab focus', () => { }, layoutByWorktree: {}, openFiles: [], + editorBuffers: {}, ptyIdsByTabId: {}, remoteBrowserPageHandlesByPageId: {}, tabBarOrderByWorktree: { [WORKTREE_ID]: [first.id, second.id] }, @@ -66,7 +67,7 @@ describe('web session structured tab focus', () => { unifiedTabsByWorktree: { [WORKTREE_ID]: [first, second] }, unreadTerminalTabs: {}, sortEpoch: 0 - } as WebSessionTabsSyncState + } const snapshot: RuntimeMobileSessionTabsResult = { worktree: WORKTREE_ID, publicationEpoch: 'epoch-1', diff --git a/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts index a1fce98ce57..e13741bf5f3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts @@ -70,6 +70,7 @@ function makeState(overrides: Partial = {}): WebSession groupsByWorktree: {}, layoutByWorktree: {}, openFiles: [], + editorBuffers: {}, ptyIdsByTabId: {}, remoteBrowserPageHandlesByPageId: {}, tabBarOrderByWorktree: {}, diff --git a/src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts b/src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts index 290a42f4923..7c315199a53 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts @@ -59,6 +59,7 @@ export function makeState( groupsByWorktree: {}, layoutByWorktree: {}, openFiles: [], + editorBuffers: {}, ptyIdsByTabId: {}, remoteBrowserPageHandlesByPageId: {}, tabBarOrderByWorktree: {}, 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 c03b72bf52f..aa3a786085a 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 @@ -7,6 +7,7 @@ import { buildRetractedMirroredTabSweepPatch } from './agent-status-primitives' import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence' +import { adoptMirroredEditorBuffers } from '../../store/slices/editor/buffers/editor-buffer-record' type FinalPatchContext = ReturnType @@ -95,8 +96,12 @@ export function buildWebSessionTabsFinalPatch( ...agentStatusPatch, ...retractedTabSweepPatch, ...remirroredClosedTabLiftPatch, - // Why no editorBuffers: the store's buffer middleware adopts the host's mirrored tab state. - ...(nextOpenFiles !== state.openFiles ? { openFiles: nextOpenFiles } : {}), + ...(nextOpenFiles !== state.openFiles + ? { + openFiles: nextOpenFiles, + editorBuffers: adoptMirroredEditorBuffers(state.editorBuffers, nextOpenFiles) + } + : {}), ...(nextTabsByWorktree !== state.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), ...(nextBrowserTabsByWorktree !== state.browserTabsByWorktree ? { browserTabsByWorktree: nextBrowserTabsByWorktree } 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..4a0b30fc746 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -235,6 +235,7 @@ export type WebSessionTabsSyncState = Pick< | 'browserPagesByWorkspace' | 'browserCertificateFailuresByPageId' | 'browserTabsByWorktree' + | 'editorBuffers' | 'groupsByWorktree' | 'layoutByWorktree' | 'openFiles' diff --git a/src/renderer/src/store/editor-buffer-record-lifecycle.test.ts b/src/renderer/src/store/editor-buffer-record-lifecycle.test.ts index e49cc5be009..f101e74cb6e 100644 --- a/src/renderer/src/store/editor-buffer-record-lifecycle.test.ts +++ b/src/renderer/src/store/editor-buffer-record-lifecycle.test.ts @@ -5,7 +5,10 @@ import { assertEditorBufferInvariant, withEditorBufferRecords } from './editor-buffer-record-lifecycle' -import type { EditorBufferRecords } from './slices/editor/buffers/editor-buffer-record' +import { + adoptMirroredEditorBuffers, + type EditorBufferRecords +} from './slices/editor/buffers/editor-buffer-record' import type { OpenFile } from './slices/editor/types/open-file' import { folderWorkspaceKey } from '../../../shared/workspace-scope' @@ -206,18 +209,25 @@ describe('editor buffer records', () => { }) }) - it("adopts a host republish of an open tab's buffer state, as remote session sync writes it", () => { + it("adopts a host republish of a mirrored tab's buffer state, as remote session sync writes it", () => { const id = openEditTab() - useAppStore.setState((state) => ({ - openFiles: state.openFiles.map((file) => - file.id === id ? { ...file, isDirty: true, lastKnownDiskSignature: 'host' } : file - ) - })) + const republish = (mirroredFromRuntimeSession: true | undefined): void => + useAppStore.setState((state) => { + const openFiles = state.openFiles.map((file) => + file.id === id ? { ...file, isDirty: !file.isDirty, mirroredFromRuntimeSession } : file + ) + return { + openFiles, + editorBuffers: adoptMirroredEditorBuffers(state.editorBuffers, openFiles) + } + }) - expect(useAppStore.getState().editorBuffers[id]).toMatchObject({ - isDirty: true, - lastKnownDiskSignature: 'host' - }) + // Why: only host-owned tabs adopt; a local tab's buffer state is written through its record. + expect(() => republish(undefined)).toThrow(/"isDirty" of/) + republish(true) + + expect(useAppStore.getState().editorBuffers[id]?.isDirty).toBe(true) + expect(fileOf(id)?.isDirty).toBe(true) }) it('rejects a slice write that changes a mirror field without its record', () => { diff --git a/src/renderer/src/store/editor-buffer-record-lifecycle.ts b/src/renderer/src/store/editor-buffer-record-lifecycle.ts index c87d82b19e8..ca912b79155 100644 --- a/src/renderer/src/store/editor-buffer-record-lifecycle.ts +++ b/src/renderer/src/store/editor-buffer-record-lifecycle.ts @@ -1,46 +1,28 @@ import type { StateCreator } from 'zustand' -import type { OpenFile } from './slices/editor/types/open-file' import { editorBufferMirrorMismatch, - editorBufferRecordFromFile, reconcileEditorBufferMembership, - type EditorBufferRecords + type EditorBufferState } 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. + * Ties editor buffer records to `openFiles` membership inside the same write: a new tab's record + * is seeded from the tab and a closed tab's record is dropped. Field writes go through the record + * (`writeEditorBuffer`); with `checkInvariant` a write that drifts a tab mirror from it throws. */ function reconcileEditorBuffers( state: TState, write: TState | Partial, - adoptMirrorDrift: boolean + checkInvariant: 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) + const next = reconcileEditorBufferMembership(records, openFiles) + if (checkInvariant) { + assertEditorBufferInvariant({ openFiles, editorBuffers: next }) } return next === records ? write : { ...write, editorBuffers: next } } @@ -60,38 +42,30 @@ export function assertEditorBufferInvariant(state: EditorBufferState): void { } export function withEditorBufferRecords( - createState: StateCreator + createState: StateCreator, + checkInvariant: boolean = import.meta.env.DEV ): 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 wrap = (write: typeof set): 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 + checkInvariant ), 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) + api.setState = wrap(api.setState) + return createState(wrap(set), get, api) } } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index b63b39da563..42253a6de1b 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -78,6 +78,7 @@ const withDevelopmentStoreProbes = (createState: StateCreator) : createState // Why buffer records outermost: the probes must see each caller's own partial and frame. +// Why e2e checks the buffer invariant too: a writer that skips the record must fail there, not drift. export const useAppStore = create()( withEditorBufferRecords( withDevelopmentStoreProbes( @@ -140,7 +141,8 @@ export const useAppStore = create()( ...createWorkspaceAttentionSlice(...a) } }) - ) + ), + import.meta.env.DEV || e2eConfig.exposeStore ) ) diff --git a/src/renderer/src/store/slices/editor-rekey-open-files.test.ts b/src/renderer/src/store/slices/editor-rekey-open-files.test.ts index 5597eb748dd..9390d415eab 100644 --- a/src/renderer/src/store/slices/editor-rekey-open-files.test.ts +++ b/src/renderer/src/store/slices/editor-rekey-open-files.test.ts @@ -205,11 +205,7 @@ describe('rekeyOpenFilesForPathChange', () => { const oldId = useAppStore.getState().openFiles[0]!.id // A dirty tab that already conflicts with disk: autosave is suspended by the // banner; gating it would strand the gate (verification skips a 'changed' tab). - useAppStore.setState((s) => ({ - openFiles: s.openFiles.map((f) => - f.id === oldId ? { ...f, externalMutation: 'changed' as const } : f - ) - })) + useAppStore.getState().setExternalMutation(oldId, 'changed') useAppStore.getState().rekeyOpenFilesForPathChange({ rekeys: [rekeyFor(oldId, '/repo/sub/a.md', 'sub/a.md')], 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 6dc2a4075ba..7cd764c5aaa 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,7 +8,11 @@ import { previewEditorTabsOf, recordPromotedEditorTabsInLayoutOwner } from '../tabs/editor-tab-layout-owner' -import { writeEditorBuffer } from '../buffers/editor-buffer-record' +import { + editorBufferOf, + updateEditorBuffer, + writeEditorBuffer +} from '../buffers/editor-buffer-record' import { getDiskBaselineSignature } from '../../../../../../shared/disk-baseline-signature' function withoutKey(record: Record, key: string): Record { @@ -85,6 +89,7 @@ export function createOpenFileMutations( if (file.readOnly === true || file.csvPreviewOnly === true) { return s } + const buffer = editorBufferOf(s.editorBuffers, file) const needsPreviewClear = dirty && file.isPreview // Why: a clean tab holds no draft of the disk text; a kept one could later restore stale // text into a shared model. A draft that differs from disk (whitespace a Markdown view @@ -93,9 +98,9 @@ export function createOpenFileMutations( const dropDraft = !dirty && draft !== undefined && - (file.lastKnownDiskSignature === undefined || - getDiskBaselineSignature(draft) === file.lastKnownDiskSignature) - if (file.isDirty === dirty && !needsPreviewClear && !dropDraft) { + (buffer.lastKnownDiskSignature === undefined || + getDiskBaselineSignature(draft) === buffer.lastKnownDiskSignature) + if (buffer.isDirty === dirty && !needsPreviewClear && !dropDraft) { return s } if (ORCA_LAYOUT_OWNER_IS_MAIN && needsPreviewClear) { @@ -132,55 +137,23 @@ export function createOpenFileMutations( }, setExternalMutation: (fileId, mutation) => - set((s) => { - const file = s.openFiles.find((f) => f.id === fileId) - if (!file) { - return s - } - const next = mutation ?? undefined - if (file.externalMutation === next) { - return s - } - return writeEditorBuffer(s, fileId, { externalMutation: next }) - }), + set((s) => updateEditorBuffer(s, fileId, { externalMutation: mutation ?? undefined })), setLastKnownDiskSignature: (fileId, signature) => - set((s) => { - const file = s.openFiles.find((f) => f.id === fileId) - if (!file || file.lastKnownDiskSignature === signature) { - return s - } - return writeEditorBuffer(s, fileId, { lastKnownDiskSignature: signature }) - }), + set((s) => updateEditorBuffer(s, fileId, { lastKnownDiskSignature: signature })), clearPendingDiskBaselineVerification: (fileId) => - set((s) => { - const file = s.openFiles.find((f) => f.id === fileId) - if (!file?.pendingDiskBaselineVerification) { - return s - } - return writeEditorBuffer(s, fileId, { pendingDiskBaselineVerification: undefined }) - }), + get().setPendingDiskBaselineVerification(fileId, false), setPendingDiskBaselineVerification: (fileId, value) => - set((s) => { - const file = s.openFiles.find((f) => f.id === fileId) - const next = value || undefined - if (!file || file.pendingDiskBaselineVerification === next) { - return s - } - return writeEditorBuffer(s, fileId, { pendingDiskBaselineVerification: next }) - }), + set((s) => + updateEditorBuffer(s, fileId, { pendingDiskBaselineVerification: value || undefined }) + ), setPendingLiveDiskVerification: (fileId, value) => - set((s) => { - const file = s.openFiles.find((f) => f.id === fileId) - const next = value || undefined - if (!file || file.pendingLiveDiskVerification === next) { - return s - } - return writeEditorBuffer(s, fileId, { pendingLiveDiskVerification: next }) - }), + set((s) => + updateEditorBuffer(s, fileId, { pendingLiveDiskVerification: value || undefined }) + ), clearSelfMoveEcho: (fileId) => set((s) => { 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 fe38090a8d9..cf26e882df4 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,7 +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' +import { updateEditorBuffer } from '../buffers/editor-buffer-record' export function createRestoredEditorOwner( set: EditorSet, @@ -14,13 +14,9 @@ export function createRestoredEditorOwner( setRestoredEditorOwnerMigrationPending: (fileId, pending) => { let changed = false set((s) => { - const file = s.openFiles.find((candidate) => candidate.id === fileId) - const next = pending || undefined - if (!file || file.pendingOwnerMigration === next) { - return s - } - changed = true - return writeEditorBuffer(s, fileId, { pendingOwnerMigration: next }) + const next = updateEditorBuffer(s, fileId, { pendingOwnerMigration: pending || undefined }) + changed = next !== s + return 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 index 69074276612..5b6a658b48d 100644 --- a/src/renderer/src/store/slices/editor/buffers/editor-buffer-record.ts +++ b/src/renderer/src/store/slices/editor/buffers/editor-buffer-record.ts @@ -16,6 +16,8 @@ export type EditorBufferRecord = Pick< export type EditorBufferRecords = Record +export type EditorBufferState = { openFiles: OpenFile[]; editorBuffers: EditorBufferRecords } + export const EDITOR_BUFFER_FIELDS = [ 'isDirty', 'lastKnownDiskSignature', @@ -48,8 +50,7 @@ export function editorBufferMirrorMismatch( 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( +function patchEditorBuffer( records: EditorBufferRecords, file: OpenFile, patch: Partial @@ -62,11 +63,11 @@ export function patchEditorBuffer( /** Writes `patch` to one file's record and its tab mirror in one update. */ export function writeEditorBuffer( - state: { openFiles: OpenFile[]; editorBuffers: EditorBufferRecords }, + state: EditorBufferState, fileId: string, patch: Partial, tabPatch?: Partial -): { openFiles: OpenFile[]; editorBuffers: EditorBufferRecords } { +): EditorBufferState { let editorBuffers = state.editorBuffers const openFiles = state.openFiles.map((file) => { if (file.id !== fileId) { @@ -78,6 +79,44 @@ export function writeEditorBuffer( return { openFiles, editorBuffers } } +/** Writes `patch` through the file's record; returns `state` itself when the record already holds it. */ +export function updateEditorBuffer( + state: TState, + fileId: string, + patch: Partial +): TState | EditorBufferState { + const file = state.openFiles.find((candidate) => candidate.id === fileId) + if (!file) { + return state + } + const record = editorBufferOf(state.editorBuffers, file) + const changes = EDITOR_BUFFER_FIELDS.some( + (field) => Object.hasOwn(patch, field) && !Object.is(record[field], patch[field]) + ) + return changes ? writeEditorBuffer(state, fileId, patch) : state +} + +/** A host-mirrored tab carries the host's buffer state, so its record adopts it in the same write. */ +export function adoptMirroredEditorBuffers( + records: EditorBufferRecords, + openFiles: readonly OpenFile[] +): EditorBufferRecords { + let next = records + for (const file of openFiles) { + const record = records[file.id] + if ( + file.mirroredFromRuntimeSession !== true || + !record || + editorBufferMirrorMismatch(file, record) === null + ) { + continue + } + next = next === records ? { ...records } : next + 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, diff --git a/tests/e2e/editor-model-host-isolation.spec.ts b/tests/e2e/editor-model-host-isolation.spec.ts index b4fb8d83813..4e916cd3e1b 100644 --- a/tests/e2e/editor-model-host-isolation.spec.ts +++ b/tests/e2e/editor-model-host-isolation.spec.ts @@ -61,18 +61,17 @@ test('keeps same-path host models and undo history separate', async ({ }, { selection: 'none' } ) + store.getState().markFileDirty(localId, true) + store.getState().markFileDirty(remoteId, true) store.setState((state) => ({ openFiles: state.openFiles.map((entry) => entry.id === remoteId ? { ...entry, - isDirty: true, runtimeEnvironmentId: undefined, operationProvenance: provenance('ssh:fixture-target', null) } - : entry.id === localId - ? { ...entry, isDirty: true } - : entry + : entry ), editorDrafts: { ...state.editorDrafts, diff --git a/tests/e2e/remote-terminal-tab-retirement.unit.test.ts b/tests/e2e/remote-terminal-tab-retirement.unit.test.ts index bcc27c4eb57..dbe265a50d6 100644 --- a/tests/e2e/remote-terminal-tab-retirement.unit.test.ts +++ b/tests/e2e/remote-terminal-tab-retirement.unit.test.ts @@ -43,6 +43,7 @@ function makeViewerState(): WebSessionTabsSyncState { groupsByWorktree: {}, layoutByWorktree: {}, openFiles: [], + editorBuffers: {}, ptyIdsByTabId: {}, remoteBrowserPageHandlesByPageId: {}, tabBarOrderByWorktree: {}, diff --git a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts index fe8208058ea..d14ec10a677 100644 --- a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts +++ b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts @@ -66,6 +66,7 @@ function makeViewerState(): WebSessionTabsSyncState { groupsByWorktree: {}, layoutByWorktree: {}, openFiles: [], + editorBuffers: {}, ptyIdsByTabId: {}, remoteBrowserPageHandlesByPageId: {}, tabBarOrderByWorktree: {},