mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
refactor(editor): keep tab buffer state in a per-tab store record mirrored onto OpenFile (#30885)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user