refactor(editor): keep tab buffer state in a per-tab store record mirrored onto OpenFile (#30885)

This commit is contained in:
Neil
2026-10-19 15:54:00 -07:00
committed by GitHub
parent ff70e9a5e4
commit c8a4fc6047
16 changed files with 700 additions and 107 deletions
@@ -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()
}
})
})
@@ -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 }
@@ -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
}
@@ -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'
@@ -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<State>()(
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)
})
})
@@ -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<TState extends EditorBufferState>(
state: TState,
write: TState | Partial<TState>,
adoptMirrorDrift: boolean
): TState | Partial<TState> {
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<TState extends EditorBufferState>(
createState: StateCreator<TState, [], []>
): StateCreator<TState, [], []> {
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<TState> | ((state: TState) => TState | Partial<TState>),
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<TState>, 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)
}
}
+62 -59
View File
@@ -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<AppState, [], []>)
export const useAppStore = create<AppState>()(
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)
}
})
)
)
)
@@ -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,
@@ -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) =>
@@ -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: {},
@@ -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<string>()
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),
@@ -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<RestoredEditorOwnerResult, { ok: false }>['reason']
patch: Pick<AppState, 'openFiles'>
patch: Pick<AppState, 'openFiles'> & Partial<Pick<AppState, 'editorBuffers'>>
}
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 })
}
}
@@ -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),
@@ -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
},
@@ -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<string, EditorBufferRecord>
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<EditorBufferRecord>
): 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<EditorBufferRecord>,
tabPatch?: Partial<OpenFile>
): { 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<string, string>,
patchFor?: (file: OpenFile) => Partial<EditorBufferRecord> | 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<string>()
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
}
@@ -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<string, string | null> // worktreeId -> last active file
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType> // worktreeId -> last active tab type