diff --git a/config/workspace-record-read-baseline.txt b/config/workspace-record-read-baseline.txt index ef163292580..b0012470263 100644 --- a/config/workspace-record-read-baseline.txt +++ b/config/workspace-record-read-baseline.txt @@ -11,7 +11,6 @@ 1 src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts # P5b-2 must clear: optional state field defaults to empty 4 src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts # P5b-2 must clear: change detection on optional fields never fires when absent 2 src/renderer/src/components/dashboard/useRetainedAgents.ts # reads a passed-in copy -6 src/renderer/src/components/editor/closed-editor-tab-controller.ts # P5b-2 must clear: identity compare of store slices passes when absent 1 src/renderer/src/components/editor/markdown-document-worktree-path-selector.ts # P5b-2 must clear: optional state field 1 src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts # reads a passed-in copy 3 src/renderer/src/components/quick-open-file-list.ts # P5b-2 must clear: store read; file at the max-lines limit diff --git a/src/renderer/src/components/editor/closed-editor-owner-key-reuse.test.ts b/src/renderer/src/components/editor/closed-editor-owner-key-reuse.test.ts new file mode 100644 index 00000000000..c2ea9f5eb41 --- /dev/null +++ b/src/renderer/src/components/editor/closed-editor-owner-key-reuse.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from 'vitest' +import { + createModelLifetimeFixture, + resetModelLifetimeFixtures +} from './editor-model-lifetime-fixture' +import { getEditorModelOwnerKey } from './editor-model-owner' +import type * as EditorModelOwner from './editor-model-owner' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } })) +vi.mock('./editor-model-owner', async (importOriginal) => { + const original = await importOriginal() + return { ...original, getEditorModelOwnerKey: vi.fn(original.getEditorModelOwnerKey) } +}) +afterEach(resetModelLifetimeFixtures) + +it('re-resolves only the changed tab until an owner input changes', async () => { + const { store, attach, add } = createModelLifetimeFixture() + const tabs = Array.from({ length: 20 }, (_, index) => add(`tab-${index}`)) + attach() + const resolve = vi.mocked(getEditorModelOwnerKey) + const markDirty = (id: string): void => + store.setState({ + openFiles: store + .getState() + .openFiles.map((file) => (file.id === id ? { ...file, isDirty: !file.isDirty } : file)) + }) + + markDirty('tab-0') + resolve.mockClear() + markDirty('tab-1') + expect(resolve).toHaveBeenCalledTimes(1) + + resolve.mockClear() + store.setState({ runtimeEnvironments: [] }) + expect(resolve).toHaveBeenCalledTimes(tabs.length) + + store.getState().closeFile('tab-3') + await Promise.resolve() + expect(tabs[3].model.isDisposed()).toBe(true) + expect(tabs[4].model.isDisposed()).toBe(false) +}) diff --git a/src/renderer/src/components/editor/closed-editor-tab-controller.ts b/src/renderer/src/components/editor/closed-editor-tab-controller.ts index fca3dffb786..374bae0d49f 100644 --- a/src/renderer/src/components/editor/closed-editor-tab-controller.ts +++ b/src/renderer/src/components/editor/closed-editor-tab-controller.ts @@ -10,14 +10,14 @@ import { } from './closed-editor-tab-disposal' import type { MonacoModelRegistry, DisposableMonacoModel } from './diff-monaco-model-disposal' import { toEditorModelUri } from './editor-model-uri' -import { getEditorModelOwnerKey } from './editor-model-owner' -import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' +import { createEditorModelOwnerKeyCache } from './editor-model-owner-key-cache' import type { EditorBufferViewIndex } from '@/store/slices/editor/buffers/editor-buffer-views' import type { EditorBufferKeptRecords } from '@/store/slices/editor/buffers/editor-buffer-kept' +import type { EditorBufferRouteState } from '@/store/slices/editor/buffers/editor-buffer-identity-cache' type EditorStore = Pick< StoreApi< - WorktreeOperationRouteState & + EditorBufferRouteState & Partial & { openFiles: OpenFile[] editorBufferKept?: EditorBufferKeptRecords @@ -48,6 +48,8 @@ export function attachClosedEditorTabCleanup( let active = true let scheduled = false let generation = 0 + const { ownerKeyOf, ownerKeyReaderFor, ownerInputsEqual } = + createEditorModelOwnerKeyCache(previousState) const releaseRetainedModel = (model: editor.ITextModel): void => { const retained = retainedModels.get(model) @@ -138,7 +140,7 @@ export function attachClosedEditorTabCleanup( openFiles .filter((openFile) => openFile.mode === 'edit') .map((openFile) => - toEditorModelUri(openFile.filePath, getEditorModelOwnerKey(openFile, currentState)) + toEditorModelUri(openFile.filePath, ownerKeyOf(openFile, currentState)) ) ) } @@ -231,40 +233,28 @@ export function attachClosedEditorTabCleanup( if ( openFiles === previous && // Detach/rejoin can change model ownership without replacing openFiles. - currentState.editorBufferIdOf === closedState.editorBufferIdOf && currentState.editorBufferKept === closedState.editorBufferKept && - currentState.repos === closedState.repos && - currentState.worktreesByRepo === closedState.worktreesByRepo && - currentState.detectedWorktreesByRepo === closedState.detectedWorktreesByRepo && - currentState.folderWorkspaces === closedState.folderWorkspaces && - currentState.projectGroups === closedState.projectGroups && - currentState.restoredRuntimeHostIdByWorkspaceSessionKey === - closedState.restoredRuntimeHostIdByWorkspaceSessionKey && - currentState.runtimeEnvironmentCatalogHydrated === - closedState.runtimeEnvironmentCatalogHydrated && - currentState.removedRuntimeEnvironmentIds === closedState.removedRuntimeEnvironmentIds && - currentState.runtimeEnvironments === closedState.runtimeEnvironments && - currentState.settings === closedState.settings + ownerInputsEqual(currentState, closedState) ) { return } + const priorOwnerOf = ownerKeyReaderFor(closedState) const liveFiles = new Map(openFiles.map((file) => [file.id, file])) let removed = false let removedDiff = false for (const file of previous) { - const priorOwner = getEditorModelOwnerKey(file, closedState) const liveFile = liveFiles.get(file.id) if ( !liveFile || liveFile.mode !== file.mode || liveFile.filePath !== file.filePath || - (file.mode === 'edit' && getEditorModelOwnerKey(liveFile, currentState) !== priorOwner) + (file.mode === 'edit' && ownerKeyOf(liveFile, currentState) !== priorOwnerOf(file)) ) { const descriptor = { id: file.id, mode: file.mode, filePath: file.filePath, - modelOwnerKey: priorOwner + modelOwnerKey: priorOwnerOf(file) } pendingFiles.set(ownerKey(descriptor), descriptor) removed = true diff --git a/src/renderer/src/components/editor/editor-model-owner-key-cache.ts b/src/renderer/src/components/editor/editor-model-owner-key-cache.ts new file mode 100644 index 00000000000..f4e68447df7 --- /dev/null +++ b/src/renderer/src/components/editor/editor-model-owner-key-cache.ts @@ -0,0 +1,50 @@ +import type { OpenFile } from '@/store/slices/editor' +import type { EditorBufferViewIndex } from '@/store/slices/editor/buffers/editor-buffer-views' +import { + EDITOR_BUFFER_ROUTE_INPUT_KEYS, + type EditorBufferRouteState +} from '@/store/slices/editor/buffers/editor-buffer-identity-cache' +import { getEditorModelOwnerKey } from './editor-model-owner' + +type OwnerKeyState = EditorBufferRouteState & Partial + +function ownerInputsEqual(a: OwnerKeyState, b: OwnerKeyState): boolean { + return ( + a.editorBufferIdOf === b.editorBufferIdOf && + EDITOR_BUFFER_ROUTE_INPUT_KEYS.every((key) => a[key] === b[key]) + ) +} + +function cachedOwnerKey( + keys: WeakMap, + file: OpenFile, + state: OwnerKeyState +): string { + let key = keys.get(file) + if (key === undefined) { + key = getEditorModelOwnerKey(file, state) + keys.set(file, key) + } + return key +} + +/** Keys stay valid until an owner input changes, so a tab edit re-resolves that tab, not every tab. */ +export function createEditorModelOwnerKeyCache(initialState: OwnerKeyState) { + let keyState = initialState + let keys = new WeakMap() + const ownerKeyOf = (file: OpenFile, state: OwnerKeyState): string => { + if (state !== keyState) { + if (!ownerInputsEqual(state, keyState)) { + keys = new WeakMap() + } + keyState = state + } + return cachedOwnerKey(keys, file, state) + } + /** Resolver for `state` that never evicts the current keys, for reading a superseded state. */ + const ownerKeyReaderFor = (state: OwnerKeyState): ((file: OpenFile) => string) => { + const stateKeys = ownerInputsEqual(state, keyState) ? keys : new WeakMap() + return (file) => cachedOwnerKey(stateKeys, file, state) + } + return { ownerKeyOf, ownerKeyReaderFor, ownerInputsEqual } +}