mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Gate editor autosave store subscriber (#1826)
* Gate editor autosave store subscriber * fix: keep autosave controller lint-clean
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<typeof openFiles.map>) => {
|
||||
mapCalls += 1
|
||||
return originalMap(...args)
|
||||
}
|
||||
})
|
||||
|
||||
const cleanup = attachEditorAutosaveController(store)
|
||||
try {
|
||||
expect(mapCalls).toBe(1)
|
||||
mapCalls = 0
|
||||
|
||||
store.setState({ activeWorktreeId: 'wt-2' } as Partial<AppState>)
|
||||
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()
|
||||
|
||||
@@ -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<StoreApi<AppState>, 'getState' | 'subscribe'>
|
||||
|
||||
function getDuplicateDirtySavePaths(files: OpenFile[]): string[] {
|
||||
const counts = new Map<string, number>()
|
||||
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<string, number>()
|
||||
const autoSaveScheduledContent = new Map<string, string>()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string, number>()
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user