diff --git a/src/renderer/src/components/editor/EditorAutosaveController.tsx b/src/renderer/src/components/editor/EditorAutosaveController.tsx index fcce2868bb2..0ba4358d7e8 100644 --- a/src/renderer/src/components/editor/EditorAutosaveController.tsx +++ b/src/renderer/src/components/editor/EditorAutosaveController.tsx @@ -1,12 +1,13 @@ import { useEffect } from 'react' -import { attachAppEditorAutosaveController } from './editor-autosave-controller' +import { useAppStore } from '@/store' +import { attachEditorAutosaveController } from './editor-autosave-controller' export default function EditorAutosaveController(): null { useEffect(() => { // Why: autosave and quit coordination need to survive editor tab switches, // but keeping the full EditorPanel mounted while hidden widened the restart // surface too far. Keep only this narrow controller alive between mounts. - return attachAppEditorAutosaveController() + return attachEditorAutosaveController(useAppStore) }, []) return null diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index c5653f68599..b36b5b472e9 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -193,6 +193,57 @@ describe('attachEditorAutosaveController', () => { } }) + it('skips the open-file scan for unrelated store mutations', () => { + const writeFile = vi.fn().mockResolvedValue(undefined) + const eventTarget = new EventTarget() + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { + fs: { + writeFile + } + } + } satisfies WindowStub) + + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + + const openFiles = store.getState().openFiles + const originalMap = openFiles.map.bind(openFiles) + let mapCalls = 0 + Object.defineProperty(openFiles, 'map', { + configurable: true, + value: (...args: Parameters) => { + mapCalls += 1 + return originalMap(...args) + } + }) + + const cleanup = attachEditorAutosaveController(store) + try { + expect(mapCalls).toBe(1) + mapCalls = 0 + + store.setState({ activeWorktreeId: 'wt-2' } as Partial) + expect(mapCalls).toBe(0) + + store.getState().setEditorDraft('/repo/file.ts', 'edited') + expect(mapCalls).toBe(1) + } finally { + cleanup() + } + }) + it('flushes mounted rich-editor changes before quiescing or direct saves', async () => { const writeFile = vi.fn().mockResolvedValue(undefined) const eventTarget = new EventTarget() diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 7e8a85049d0..1d94c6955ed 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -1,5 +1,4 @@ import type { StoreApi } from 'zustand' -import { useAppStore } from '@/store' import type { AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { getConnectionId } from '@/lib/connection-context' @@ -19,6 +18,11 @@ import { } from './editor-autosave' import { flushPendingEditorChange } from './editor-pending-flush' import { clearSelfWrite, recordSelfWrite } from './editor-self-write-registry' +import { + autosaveSubscriberInputsEqual, + getAutosaveSubscriberInputs, + getDuplicateDirtySavePaths +} from './editor-autosave-state-projections' import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, type EditorSaveDirtyFilesDetail @@ -26,16 +30,6 @@ import { type AppStoreApi = Pick, 'getState' | 'subscribe'> -function getDuplicateDirtySavePaths(files: OpenFile[]): string[] { - const counts = new Map() - for (const file of files) { - counts.set(file.filePath, (counts.get(file.filePath) ?? 0) + 1) - } - return Array.from(counts.entries()) - .filter(([, count]) => count > 1) - .map(([filePath]) => filePath) -} - export function attachEditorAutosaveController(store: AppStoreApi): () => void { const autoSaveTimers = new Map() const autoSaveScheduledContent = new Map() @@ -319,7 +313,18 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { state.clearEditorDrafts(matchingFiles.map((file) => file.id)) } - const unsubscribe = store.subscribe(syncAutoSave) + // Why: the root store subscriber fires for every terminal title/focus tick. + // Autosave only reads these four inputs, so skip the open-files scan when + // unrelated store slices change. + let previousAutosaveInputs = getAutosaveSubscriberInputs(store.getState()) + const unsubscribe = store.subscribe(() => { + const nextAutosaveInputs = getAutosaveSubscriberInputs(store.getState()) + if (autosaveSubscriberInputsEqual(previousAutosaveInputs, nextAutosaveInputs)) { + return + } + previousAutosaveInputs = nextAutosaveInputs + syncAutoSave() + }) syncAutoSave() window.addEventListener(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, handleSaveDirtyFiles as EventListener) @@ -356,7 +361,3 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { saveGeneration.clear() } } - -export function attachAppEditorAutosaveController(): () => void { - return attachEditorAutosaveController(useAppStore) -} diff --git a/src/renderer/src/components/editor/editor-autosave-state-projections.ts b/src/renderer/src/components/editor/editor-autosave-state-projections.ts new file mode 100644 index 00000000000..55a1525075c --- /dev/null +++ b/src/renderer/src/components/editor/editor-autosave-state-projections.ts @@ -0,0 +1,40 @@ +import type { AppState } from '@/store' +import type { OpenFile } from '@/store/slices/editor' + +export type AutosaveSubscriberInputs = { + openFiles: AppState['openFiles'] + editorDrafts: AppState['editorDrafts'] + editorAutoSave: boolean | undefined + editorAutoSaveDelayMs: number | undefined +} + +export function getAutosaveSubscriberInputs(state: AppState): AutosaveSubscriberInputs { + return { + openFiles: state.openFiles, + editorDrafts: state.editorDrafts, + editorAutoSave: state.settings?.editorAutoSave, + editorAutoSaveDelayMs: state.settings?.editorAutoSaveDelayMs + } +} + +export function autosaveSubscriberInputsEqual( + a: AutosaveSubscriberInputs, + b: AutosaveSubscriberInputs +): boolean { + return ( + a.openFiles === b.openFiles && + a.editorDrafts === b.editorDrafts && + a.editorAutoSave === b.editorAutoSave && + a.editorAutoSaveDelayMs === b.editorAutoSaveDelayMs + ) +} + +export function getDuplicateDirtySavePaths(files: OpenFile[]): string[] { + const counts = new Map() + for (const file of files) { + counts.set(file.filePath, (counts.get(file.filePath) ?? 0) + 1) + } + return Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([filePath]) => filePath) +}