diff --git a/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx index d48cf186e6d..9021244b904 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx @@ -30,8 +30,9 @@ import { keepTabEditsOverExternalChange, reloadTabContentFromDisk } from './ExternalFileChangeBanner' -import { getDiskBaselineSignature } from './diff-content-signature' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' import { useAppStore } from '@/store' +import { createTestStore } from '@/store/slices/store-test-helpers' const file = { id: 'file-1', @@ -52,15 +53,15 @@ describe('ExternalFileChangeBanner', () => { function mockStoreState(editorDrafts: Record, openFiles: OpenFile[] = [file]) { vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), clearEditorDraft, markFileDirty, setExternalMutation, setEditorDraft, setLastKnownDiskSignature, editorDrafts, - openFiles, - settings: {} - } as never) + openFiles + }) } beforeEach(() => { diff --git a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx index bf91d5e1513..cbc840ba641 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx @@ -10,7 +10,7 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import type { OpenFile } from '@/store/slices/editor' import { ExternalFileChangeCompareDialog } from './ExternalFileChangeCompareDialog' -import { getDiskBaselineSignature } from './diff-content-signature' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' import { trackExternalChangeConflictAction } from './editor-external-change-telemetry' import { attemptEditorFileSave } from './editor-file-save-attempt' import { diff --git a/src/renderer/src/components/editor/diff-content-signature.ts b/src/renderer/src/components/editor/diff-content-signature.ts index 9c74a207818..927d9711d89 100644 --- a/src/renderer/src/components/editor/diff-content-signature.ts +++ b/src/renderer/src/components/editor/diff-content-signature.ts @@ -9,6 +9,3 @@ export function getDiffContentSignature(content: string): string { } return (hash >>> 0).toString(16) } - -// Why shared: the writer that refuses a stale save hashes disk with this same function. -export { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' diff --git a/src/renderer/src/components/editor/editor-changed-on-disk-mark.ts b/src/renderer/src/components/editor/editor-changed-on-disk-mark.ts index f8c49331b1d..bbb54cdac58 100644 --- a/src/renderer/src/components/editor/editor-changed-on-disk-mark.ts +++ b/src/renderer/src/components/editor/editor-changed-on-disk-mark.ts @@ -1,12 +1,9 @@ +import type { AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { canAutoSaveOpenFile } from './editor-autosave' -import { editorModelSiblingTabs, isWholeEditorState } from './editor-save-disk-precondition' +import { editorModelSiblingTabs } from './editor-save-disk-precondition' import { trackExternalChangeConflictShown } from './editor-external-change-telemetry' -type ChangedOnDiskMarkState = { - setExternalMutation: (fileId: string, mutation: 'deleted' | 'renamed' | 'changed' | null) => void -} - // Why: the changed-on-disk mark has one rule — dirty, banner-capable tab, // telemetry only on the first surfacing — but three writers (the watch hook // at fs-event time, the autosave controller's notification backstop, and the @@ -14,15 +11,13 @@ type ChangedOnDiskMarkState = { // each writer keeps its own echo/eligibility guard at the call site. A code // model is dirty when any of its tabs is, and the mark reaches all of them. export function markFileChangedOnDisk( - state: ChangedOnDiskMarkState, + state: AppState, file: OpenFile, options: { connectionId: string | undefined; origin: 'live' | 'restore' } ): void { - // Why: some writers pass only the setter; the model's other tabs are known only from the whole store. - const tabs = - isWholeEditorState(state) && state.openFiles.some((tab) => tab.id === file.id) - ? editorModelSiblingTabs(state, file) - : [file] + const tabs = state.openFiles.some((tab) => tab.id === file.id) + ? editorModelSiblingTabs(state, file) + : [file] if (!tabs.some((tab) => tab.isDirty) || !canAutoSaveOpenFile(file)) { return } diff --git a/src/renderer/src/components/editor/editor-clean-tab-disk-baseline.ts b/src/renderer/src/components/editor/editor-clean-tab-disk-baseline.ts index b9dde358223..05a32b3499b 100644 --- a/src/renderer/src/components/editor/editor-clean-tab-disk-baseline.ts +++ b/src/renderer/src/components/editor/editor-clean-tab-disk-baseline.ts @@ -1,7 +1,7 @@ import { useAppStore, type AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { isEditorContentUnchanged } from './editor-content-dirty-state' -import { getDiskBaselineSignature } from './diff-content-signature' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' import type { FileContent } from './editor-panel-content-types' import { editorModelSiblingTabs, diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts index f852f793b1c..d4e3abb235a 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts @@ -27,7 +27,7 @@ import { MAX_VERIFY_ATTEMPTS, requestRestoredTabReverification } from './editor-restored-tab-conflict-scan' -import { getDiskBaselineSignature } from './diff-content-signature' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' const FILE = '/repo/a.ts' let store: ReturnType @@ -84,6 +84,35 @@ it('reads once more when a new check is requested while a read is in flight', as expect(tab().pendingDiskBaselineVerification).toBeFalsy() }) +it.each([ + { disk: 'v1', banner: undefined }, + { disk: 'v2 external write', banner: 'changed' } +])('waits out overlapping checks, then judges a fresh read ($disk)', async ({ disk, banner }) => { + const replies: ((content: string) => void)[] = [] + mocks.readRuntimeFileContent.mockImplementation( + () => new Promise((resolve) => replies.push((content) => resolve({ content, isBinary: false }))) + ) + store.getState().setPendingDiskBaselineVerification(fileId, true) + detach = attachRestoredTabConflictScan(store) + await vi.advanceTimersByTimeAsync(0) + expect(replies).toHaveLength(1) + + // Every read is slower than the re-verify interval, so a newer check always lands first. + for (let i = 0; i < MAX_VERIFY_ATTEMPTS + 5; i += 1) { + requestRestoredTabReverification(fileId) + replies[i]('v1') + await vi.advanceTimersByTimeAsync(60_000) + expect(replies).toHaveLength(i + 2) + } + expect(tab().pendingDiskBaselineVerification).toBe(true) + expect(tab().externalMutation).toBeUndefined() + + replies.at(-1)!(disk) + await vi.advanceTimersByTimeAsync(0) + expect(tab().pendingDiskBaselineVerification).toBeFalsy() + expect(tab().externalMutation).toBe(banner) +}) + it('marks a dirty runtime-owned tab deleted when its host says the file is gone', async () => { mocks.readRuntimeFileContent.mockRejectedValue(new Error('read failed')) mocks.runtimePathsExist.mockResolvedValue([{ exists: false }]) diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 73a8172b70c..922d1dd2777 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createStore, type StoreApi } from 'zustand/vanilla' -import { createEditorSlice } from '@/store/slices/editor' +import type { StoreApi } from 'zustand/vanilla' +import { createTestStore } from '@/store/slices/store-test-helpers' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { AppState } from '@/store' import { attachRestoredTabConflictScan } from './editor-restored-tab-conflict-scan' -import { getDiskBaselineSignature } from './diff-content-signature' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' const mocks = vi.hoisted(() => ({ readRuntimeFileContent: vi.fn(), @@ -20,14 +20,6 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: mocks.getConnectionIdForFile })) -function createEditorStore(): StoreApi { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return createStore()((...args: any[]) => ({ - settings: {}, - ...createEditorSlice(...(args as Parameters)) - })) as unknown as StoreApi -} - function openRestoredDirtyTab( store: StoreApi, filePath: string, @@ -71,7 +63,7 @@ describe('attachRestoredTabConflictScan', () => { content: 'agent rewrote this offline', isBinary: false }) - const store = createEditorStore() + const store = createTestStore() openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') const detach = attachRestoredTabConflictScan(store) @@ -88,7 +80,7 @@ describe('attachRestoredTabConflictScan', () => { content: 'original baseline', isBinary: false }) - const store = createEditorStore() + const store = createTestStore() openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') const detach = attachRestoredTabConflictScan(store) @@ -102,7 +94,7 @@ describe('attachRestoredTabConflictScan', () => { }) it('does not read files for clean tabs or tabs without a baseline', async () => { - const store = createEditorStore() + const store = createTestStore() store.getState().openFile({ filePath: '/repo/clean.ts', relativePath: 'clean.ts', @@ -134,7 +126,7 @@ describe('attachRestoredTabConflictScan', () => { mocks.readRuntimeFileContent .mockRejectedValueOnce(new Error('connection not ready')) .mockResolvedValue({ content: 'agent rewrote this offline', isBinary: false }) - const store = createEditorStore() + const store = createTestStore() openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') const detach = attachRestoredTabConflictScan(store) @@ -153,7 +145,7 @@ describe('attachRestoredTabConflictScan', () => { content: 'original baseline', isBinary: false }) - const store = createEditorStore() + const store = createTestStore() openRestoredDirtyTab(store, '/repo/match.ts', 'original baseline') const detach = attachRestoredTabConflictScan(store) @@ -178,7 +170,7 @@ describe('attachRestoredTabConflictScan', () => { }) it('does not re-verify live dirty tabs that were never flagged at hydration', async () => { - const store = createEditorStore() + const store = createTestStore() store.getState().openFile({ filePath: '/repo/live.ts', relativePath: 'live.ts', @@ -207,7 +199,7 @@ describe('attachRestoredTabConflictScan', () => { // suspended for the whole session. mocks.readRuntimeFileContent.mockRejectedValue(new Error('ENOENT')) mocks.pathExists.mockResolvedValue(false) - const store = createEditorStore() + const store = createTestStore() openRestoredDirtyTab(store, '/repo/deleted.ts', 'original baseline') const detach = attachRestoredTabConflictScan(store) @@ -230,7 +222,7 @@ describe('attachRestoredTabConflictScan', () => { // transport recovers. mocks.readRuntimeFileContent.mockRejectedValue(new Error('connection not ready')) mocks.pathExists.mockRejectedValue(new Error('connection not ready')) - const store = createEditorStore() + const store = createTestStore() openRestoredDirtyTab(store, '/repo/unreachable.ts', 'original baseline') const detach = attachRestoredTabConflictScan(store) @@ -254,7 +246,7 @@ describe('attachRestoredTabConflictScan', () => { }) mocks.pathExists.mockResolvedValue(true) mocks.getConnectionIdForFile.mockReturnValue(null) - const store = createEditorStore() + const store = createTestStore() openRestoredDirtyTab(store, '/Users/me/notes.txt', 'original baseline', { relativePath: 'notes.txt', worktreeId: FLOATING_TERMINAL_WORKTREE_ID @@ -282,7 +274,7 @@ describe('attachRestoredTabConflictScan', () => { isBinary: false }) mocks.getConnectionIdForFile.mockReturnValue(null) - const store = createEditorStore() + const store = createTestStore() openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') const detach = attachRestoredTabConflictScan(store) @@ -297,7 +289,7 @@ describe('attachRestoredTabConflictScan', () => { }) it('does not verify an external SSH file through a replacement target', async () => { - const store = createEditorStore() + const store = createTestStore() openRestoredDirtyTab(store, '/tmp/external.ts', 'original baseline') store.setState({ openFiles: store @@ -328,7 +320,7 @@ describe('attachRestoredTabConflictScan', () => { pendingReads.push(resolve) }) ) - const store = createEditorStore() + const store = createTestStore() for (let i = 0; i < 6; i++) { openRestoredDirtyTab(store, `/repo/file-${i}.ts`, 'original baseline') } @@ -361,7 +353,7 @@ describe('attachRestoredTabConflictScan', () => { resolveRead = resolve }) ) - const store = createEditorStore() + const store = createTestStore() openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') const detach = attachRestoredTabConflictScan(store) @@ -390,7 +382,7 @@ describe('attachRestoredTabConflictScan', () => { pending.push({ filePath, resolve }) }) ) - const store = createEditorStore() + const store = createTestStore() // Three reads fill the concurrency cap; the fourth waits in the queue. for (let i = 0; i < 4; i++) { openRestoredDirtyTab(store, `/repo/file-${i}.ts`, 'stale baseline') diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index ea889325a80..8b5943f03b6 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -10,7 +10,7 @@ import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { canAutoSaveOpenFile } from './editor-autosave' -import { getDiskBaselineSignature } from './diff-content-signature' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' import { markFileChangedOnDisk } from './editor-changed-on-disk-mark' import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch' @@ -60,6 +60,7 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { // Dedupes queued + in-flight verifications; the store's pending flag is the durable "needs verification" signal. const inFlightFileIds = new Set() const attemptsByFileId = new Map() + const supersededReadsByFileId = new Map() const retryTimers = new Set>() // Queue ids, not OpenFile snapshots: a snapshot can go stale (close/reopen or same-path save) while it waits for a slot. const verifyQueue: string[] = [] @@ -129,10 +130,18 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { if (verifyRequestGeneration(fileId) === requestGeneration) { return false } - queueMicrotask(() => { - inFlightFileIds.delete(fileId) - scan() - }) + // Why back off, not cap: a superseded answer may predate the newer check, so it never resolves; churn only delays the fresh read. + const superseded = (supersededReadsByFileId.get(fileId) ?? 0) + 1 + supersededReadsByFileId.set(fileId, superseded) + const timer = setTimeout( + () => { + retryTimers.delete(timer) + inFlightFileIds.delete(fileId) + scan() + }, + Math.min((superseded - 1) * VERIFY_RETRY_MS, VERIFY_SLOW_RETRY_MS) + ) + retryTimers.add(timer) return true } @@ -163,6 +172,7 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { return } attemptsByFileId.delete(file.id) + supersededReadsByFileId.delete(file.id) // Verification resolved: lift autosave suspension regardless of outcome; wasPending flags a save that already re-baselined. const wasPending = liveFile.pendingDiskBaselineVerification === true store.getState().clearPendingDiskBaselineVerification(file.id) @@ -233,6 +243,7 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { // Why: still unverifiable after every retry; resume nothing silently — the banner makes the user choose. const giveUp = (file: OpenFile): void => { attemptsByFileId.delete(file.id) + supersededReadsByFileId.delete(file.id) const liveFile = store.getState().openFiles.find((f) => f.id === file.id) if (!liveFile?.pendingDiskBaselineVerification) { return diff --git a/src/renderer/src/components/editor/editor-save-disk-precondition.ts b/src/renderer/src/components/editor/editor-save-disk-precondition.ts index b6adeba877b..8112f02a690 100644 --- a/src/renderer/src/components/editor/editor-save-disk-precondition.ts +++ b/src/renderer/src/components/editor/editor-save-disk-precondition.ts @@ -85,17 +85,6 @@ export function editorModelDraftHolder(state: AppState, file: OpenFile): OpenFil ) } -/** Whether a store snapshot is whole enough to resolve a tab's model siblings. */ -export function isWholeEditorState(state: unknown): state is AppState { - return ( - typeof state === 'object' && - state !== null && - 'editorDrafts' in state && - 'worktreesByRepo' in state && - 'setLastKnownDiskSignature' in state - ) -} - /** * The one disk version of a code-view model: every tab sharing the model holds the same value, set * only here. Other views keep their own. Returns the sibling tabs whose value moved. diff --git a/src/renderer/src/components/editor/editor-save-queue.ts b/src/renderer/src/components/editor/editor-save-queue.ts index 7bb5cde7f82..de0aa062673 100644 --- a/src/renderer/src/components/editor/editor-save-queue.ts +++ b/src/renderer/src/components/editor/editor-save-queue.ts @@ -16,7 +16,7 @@ import { recordSelfWrite, SELF_WRITE_REMOTE_TTL_MS } from './editor-self-write-registry' -import { getDiskBaselineSignature } from './diff-content-signature' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' import { trackExternalChangeConflictAction } from './editor-external-change-telemetry' import { editorTabFileAccess } from '@/lib/local-file-access' import { isEditorLargePasteInFlight } from './editor-large-paste-in-flight' diff --git a/src/renderer/src/components/editor/editor-stale-mount-guard.ts b/src/renderer/src/components/editor/editor-stale-mount-guard.ts index afd3241de59..b8b5f2a4e05 100644 --- a/src/renderer/src/components/editor/editor-stale-mount-guard.ts +++ b/src/renderer/src/components/editor/editor-stale-mount-guard.ts @@ -1,7 +1,7 @@ import { useAppStore } from '@/store' import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' import { notifyEditorFileCacheStale } from './editor-autosave' -import { isEditorModelDirty, isWholeEditorState } from './editor-save-disk-precondition' +import { isEditorModelDirty } from './editor-save-disk-precondition' /** * A clean tab's text (cache, or a leftover draft) that no longer matches its disk version, such as @@ -49,9 +49,6 @@ export function settleStaleCacheReload(fileId: string): void { */ export function mayPushTabTextIntoModel(fileId: string): boolean { const state = useAppStore.getState() - if (!isWholeEditorState(state)) { - return true - } const file = state.openFiles.find((openFile) => openFile.id === fileId) if (!file || file.isDirty) { // Why: a dirty tab's text is its own edits. diff --git a/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx b/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx index ece6409c8eb..25d563448a4 100644 --- a/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx +++ b/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx @@ -8,7 +8,7 @@ import { createUntitledNoteStore, type FakeEditorDisk } from './editor-autosave-controller-test-fixture' -import { getDiskBaselineSignature } from './diff-content-signature' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' import { useTerminalSaveDialog } from '../terminal/useTerminalSaveDialog' import { useTerminalEditorCloseDialogActions, diff --git a/src/renderer/src/components/editor/editor-untitled-save-lifecycle.test.ts b/src/renderer/src/components/editor/editor-untitled-save-lifecycle.test.ts index 38709333368..7afa7d76f00 100644 --- a/src/renderer/src/components/editor/editor-untitled-save-lifecycle.test.ts +++ b/src/renderer/src/components/editor/editor-untitled-save-lifecycle.test.ts @@ -12,7 +12,7 @@ import { } from './editor-autosave-controller-test-fixture' import { discardEditorFileChangesAndClose } from './discard-editor-file-changes' import { __clearSelfWriteRegistryForTests } from './editor-self-write-registry' -import { getDiskBaselineSignature } from './diff-content-signature' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' const storeHolder = vi.hoisted((): { store: StoreApi | null } => ({ store: null })) diff --git a/src/renderer/src/components/editor/use-monaco-editor-mount.cursor-owner.test.tsx b/src/renderer/src/components/editor/use-monaco-editor-mount.cursor-owner.test.tsx index 667e3be7530..f430bc32562 100644 --- a/src/renderer/src/components/editor/use-monaco-editor-mount.cursor-owner.test.tsx +++ b/src/renderer/src/components/editor/use-monaco-editor-mount.cursor-owner.test.tsx @@ -4,13 +4,16 @@ import { Suspense } from 'react' import { afterEach, expect, it, vi } from 'vitest' import type { editor } from 'monaco-editor' import type { OnMount } from '@monaco-editor/react' +import type { StoreApi } from 'zustand' +import type { AppState } from '@/store' import { createEditorTabsStore } from '@/store/slices/editor-slice-test-harness' import { captureEditorFileOperationProvenance } from '@/lib/editor-file-operation-owner' import type { MonacoEditorMountParams } from './monaco-editor-mount-params' import { useMonacoEditorMount } from './use-monaco-editor-mount' +const storeRef = vi.hoisted((): { current?: StoreApi } => ({})) vi.mock('@/store', () => ({ - useAppStore: { getState: () => ({ pendingEditorReveal: null, pendingEditorFocusRequest: null }) } + useAppStore: { getState: () => storeRef.current?.getState() } })) vi.mock('./monaco-markdown-doc-link-decorations', () => ({ createMarkdownDocLinkDecorationController: vi.fn() @@ -27,6 +30,7 @@ afterEach(cleanup) it('keeps a retained cursor listener on the current file ID after same-path owner migration', () => { const store = createEditorTabsStore() + storeRef.current = store const filePath = '/repo/file.ts' const oldId = 'restored-file' store.setState({ diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index d7c84e81ee5..1615e34056b 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -68,7 +68,8 @@ vi.mock('@/store', () => ({ })) import { useEditorPanelContentState } from './useEditorPanelContentState' -import { getDiskBaselineSignature } from './diff-content-signature' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT } from './editor-autosave' type Deferred = { @@ -166,6 +167,7 @@ describe('useEditorPanelContentState', () => { mocks.isWorktreeConnectionResolved.mockReturnValue(true) mocks.getState.mockReset() mocks.getState.mockReturnValue({ + ...createTestStore().getState(), settings: null, openFiles: [], setLastKnownDiskSignature: vi.fn() @@ -315,6 +317,7 @@ describe('useEditorPanelContentState', () => { }) mocks.getConnectionIdForFile.mockReturnValue(undefined) mocks.getState.mockReturnValue({ + ...createTestStore().getState(), settings: { activeRuntimeEnvironmentId: 'runtime-1' }, openFiles: [], setLastKnownDiskSignature: vi.fn() @@ -883,6 +886,7 @@ describe('useEditorPanelContentState', () => { const activeFile = createOpenFile() const setLastKnownDiskSignature = vi.fn() mocks.getState.mockReturnValue({ + ...createTestStore().getState(), settings: null, openFiles: [activeFile], setLastKnownDiskSignature @@ -911,6 +915,7 @@ describe('useEditorPanelContentState', () => { const activeFile = createOpenFile({ isDirty: true }) const setLastKnownDiskSignature = vi.fn() mocks.getState.mockReturnValue({ + ...createTestStore().getState(), settings: null, openFiles: [activeFile], setLastKnownDiskSignature diff --git a/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts index a7205c10ca0..6a4f7c75135 100644 --- a/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts +++ b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts @@ -15,11 +15,7 @@ import { isReloadableSingleFileDiffTab } from './editor-panel-diff-reload' import { createEditorWatcherReloadCoalescer } from './editor-watcher-reload-coalescer' import { markFileChangedOnDisk } from './editor-changed-on-disk-mark' import { hasRecentSelfWrite } from './editor-self-write-registry' -import { - editorModelSiblingTabs, - isEditorModelDirty, - isWholeEditorState -} from './editor-save-disk-precondition' +import { editorModelSiblingTabs, isEditorModelDirty } from './editor-save-disk-precondition' type EditorViewModeByFile = ReturnType['editorViewMode'] @@ -93,7 +89,7 @@ export function useEditorPanelExternalContentEvents({ return 'stale' } // Why: a clean tab of a model another tab is editing shows that buffer; never reload it alone. - if (isWholeEditorState(state) && isEditorModelDirty(state, file)) { + if (isEditorModelDirty(state, file)) { return null } return isVisibleRef.current && file.id === activeContentFileIdRef.current ? file : 'stale' @@ -142,8 +138,7 @@ export function useEditorPanelExternalContentEvents({ // clean tab of a model another tab is editing shows that buffer too. if ( file.isDirty || - (isWholeEditorState(storeState) && - storeState.openFiles.some((tab) => tab.id === file.id) && + (storeState.openFiles.some((tab) => tab.id === file.id) && isEditorModelDirty(storeState, file)) ) { continue diff --git a/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx index 62c129a7203..7f297c093a1 100644 --- a/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx @@ -42,6 +42,7 @@ vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT } from './editor-autosave' import { useEditorPanelContentState } from './useEditorPanelContentState' +import { createTestStore } from '@/store/slices/store-test-helpers' type Deferred = { promise: Promise @@ -161,6 +162,7 @@ describe('useEditorPanelContentState visibility', () => { mocks.readRuntimeFileContent.mockReset() mocks.getState.mockReset() mocks.getState.mockReturnValue({ + ...createTestStore().getState(), settings: null, openFiles: [], setLastKnownDiskSignature: vi.fn() diff --git a/src/renderer/src/hooks/editor-external-watch-disk-verification.ts b/src/renderer/src/hooks/editor-external-watch-disk-verification.ts index 69fb7933628..ca402b5b88e 100644 --- a/src/renderer/src/hooks/editor-external-watch-disk-verification.ts +++ b/src/renderer/src/hooks/editor-external-watch-disk-verification.ts @@ -6,7 +6,7 @@ import { notifyEditorExternalFileChange } from '@/components/editor/editor-autosave' import { markFileChangedOnDisk } from '@/components/editor/editor-changed-on-disk-mark' -import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' +import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' import { clearSelfWrite, getRecentSelfWrite, diff --git a/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts index 48832cfad5b..4de08af22b8 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts @@ -20,6 +20,8 @@ import { } from './editor-external-watch-event-reconciliation' import { verifyLatchedMoveDestinations } from './useEditorExternalWatch' import { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import { createTestStore } from '@/store/slices/store-test-helpers' import { getOpenFilesForExternalFileChange } from '@/components/editor/editor-autosave' import { __clearSelfWriteRegistryForTests } from '@/components/editor/editor-self-write-registry' import { @@ -27,7 +29,7 @@ import { beginEditorPathMove, settleEditorPathMove } from '@/components/editor/editor-path-move-inflight' -import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' +import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' const findTarget = (worktreePath: string, runtimeEnvironmentId: string | null = null) => worktreePath === '/repo' @@ -46,6 +48,7 @@ describe('self-move source suppression (in-flight)', () => { worktreePath: '/repo', filePath: '/repo/notes.md', relativePath: 'notes.md', + language: 'markdown', mode: 'edit' as const, isDirty: false } @@ -54,9 +57,10 @@ describe('self-move source suppression (in-flight)', () => { vi.clearAllMocks() vi.useFakeTimers() vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [fileNotes], setExternalMutation - } as never) + }) }) afterEach(() => { @@ -97,13 +101,15 @@ describe('self-move source suppression (in-flight)', () => { worktreePath: '/repo', filePath: '/repo/src/late.md', relativePath: 'src/late.md', + language: 'markdown', mode: 'edit' as const, isDirty: true } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [lateTab], setExternalMutation - } as never) + }) beginEditorPathMove({ operationId: 'op-dir', worktreeId: 'wt-1', @@ -148,13 +154,13 @@ describe('self-move echo verification (content identity)', () => { const baselineSignature = getDiskBaselineSignature(BASELINE_CONTENT) const TARGET_PATH = '/repo/subdir/notes.md' - function movedDirtyTab(overrides: Record = {}): Record { + function movedDirtyTab(overrides: Partial = {}): OpenFile { return { id: 'file-notes', worktreeId: 'wt-1', - worktreePath: '/repo', filePath: TARGET_PATH, relativePath: 'subdir/notes.md', + language: 'markdown', mode: 'edit' as const, isDirty: true, lastKnownDiskSignature: baselineSignature, @@ -164,14 +170,15 @@ describe('self-move echo verification (content identity)', () => { } } - function mockState(file: Record): void { + function mockState(file: OpenFile): void { vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [file], setExternalMutation, setPendingLiveDiskVerification, clearSelfMoveEcho - } as never) - vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never) + }) + vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file]) } function payloadFor(): FsChangedPayload { @@ -293,6 +300,7 @@ describe('verifyLatchedMoveDestinations (proactive, cross-worktree path)', () => filePath: FLOATING_FILE_PATH, relativePath: '../notes/readme.md', runtimeEnvironmentId: null, + language: 'markdown', mode: 'edit' as const, isDirty: true, lastKnownDiskSignature: baseline, @@ -306,11 +314,12 @@ describe('verifyLatchedMoveDestinations (proactive, cross-worktree path)', () => readFile = vi.fn().mockResolvedValue({ isBinary: false, content: CONTENT }) vi.stubGlobal('window', { api: { fs: { readFile } } }) vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [floatingTab], setPendingLiveDiskVerification, clearSelfMoveEcho, setExternalMutation - } as never) + }) }) afterEach(() => { diff --git a/src/renderer/src/hooks/useEditorExternalWatch-switch-back.test.tsx b/src/renderer/src/hooks/useEditorExternalWatch-switch-back.test.tsx index abbe04c3fa4..95045b22b1d 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-switch-back.test.tsx +++ b/src/renderer/src/hooks/useEditorExternalWatch-switch-back.test.tsx @@ -48,7 +48,7 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => undef import { createTestStore } from '@/store/slices/store-test-helpers' import { useEditorExternalWatch } from './useEditorExternalWatch' import { attachRestoredTabConflictScan } from '@/components/editor/editor-restored-tab-conflict-scan' -import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' +import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' import { isAutosaveSuspendedForFile, ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT diff --git a/src/renderer/src/hooks/useEditorExternalWatch.test.ts b/src/renderer/src/hooks/useEditorExternalWatch.test.ts index f249f322eef..8ff9b882796 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.test.ts @@ -25,6 +25,7 @@ import { } from './editor-external-watch-event-reconciliation' import { getEditorExternalWatchTargetKey as getWatchedTargetKey } from './editor-external-watch-targets' import { useAppStore } from '@/store' +import { createTestStore } from '@/store/slices/store-test-helpers' import { getOpenFilesForExternalFileChange, notifyEditorExternalFileChange @@ -101,6 +102,7 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { worktreePath: '/repo', filePath: '/repo/notes.md', relativePath: 'notes.md', + language: 'markdown', mode: 'edit' as const, isDirty: false } @@ -109,9 +111,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { vi.clearAllMocks() vi.useFakeTimers() vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [fileNotes], setExternalMutation - } as never) + }) }) afterEach(() => { @@ -178,14 +181,16 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { worktreePath: 'C:\\Repo', filePath: 'C:\\Repo\\notes.md', relativePath: 'notes.md', + language: 'markdown', mode: 'edit' as const, isDirty: false, runtimeEnvironmentId: 'env-1' } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [file], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never) const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ worktreeId: 'wt-win', @@ -216,14 +221,16 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { worktreePath: '//Server/Share/Repo', filePath: '//Server/Share/Repo/notes.md', relativePath: 'notes.md', + language: 'markdown', mode: 'edit' as const, isDirty: false, runtimeEnvironmentId: 'env-1' } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [file], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never) const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ worktreeId: 'wt-unc', @@ -249,9 +256,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { it('does not drop external edits that arrive inside the self-write TTL', async () => { vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [fileNotes], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([fileNotes] as never) const readFile = vi.fn().mockResolvedValue({ content: 'agent edit', isBinary: false }) vi.stubGlobal('window', { api: { fs: { readFile } } }) @@ -272,9 +280,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { it('still suppresses the watcher echo from Orca self-writes', async () => { vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [fileNotes], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([fileNotes] as never) const readFile = vi.fn().mockResolvedValue({ content: 'orca save', isBinary: false }) vi.stubGlobal('window', { api: { fs: { readFile } } }) @@ -294,9 +303,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { runtimeEnvironmentId: 'env-1' } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [runtimeFile], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([runtimeFile] as never) const readFile = vi.fn().mockResolvedValue({ content: 'local save', isBinary: false }) vi.stubGlobal('window', { api: { fs: { readFile } } }) @@ -323,9 +333,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { runtimeEnvironmentId: 'env-1' } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [localFile, runtimeFile], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockImplementation((_files, notification) => notification.runtimeEnvironmentId === 'env-1' ? ([runtimeFile] as never) @@ -359,14 +370,16 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { worktreePath: '/repo', filePath: '/repo', relativePath: 'Changes', + language: 'markdown', mode: 'diff' as const, diffSource: 'combined-uncommitted' as const, isDirty: false } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [combinedDiffTab], setExternalMutation - } as never) + }) // Why: combined tabs match no single path, so the per-path lookup stays empty. vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([]) const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) @@ -394,14 +407,16 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { worktreePath: '/repo', filePath: '/repo', relativePath: 'Changes', + language: 'markdown', mode: 'diff' as const, diffSource: 'combined-uncommitted' as const, isDirty: false } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [dirtyFile, combinedDiffTab], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never) const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) @@ -423,9 +438,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { isDirty: true } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [dirtyFile], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never) const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) @@ -448,14 +464,16 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { worktreePath: '/repo', filePath: '/repo/notes.md', relativePath: 'notes.md', + language: 'markdown', mode: 'diff' as const, diffSource: 'unstaged' as const, isDirty: false } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [dirtyFile, cleanDiffTab], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile, cleanDiffTab] as never) const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) @@ -478,9 +496,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { isDirty: true } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [dirtyFile], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never) const readFile = vi.fn().mockResolvedValue({ content: 'orca save', isBinary: false }) vi.stubGlobal('window', { api: { fs: { readFile } } }) @@ -500,9 +519,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { isDirty: true } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [dirtyFile], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never) const readFile = vi.fn().mockResolvedValue({ content: 'agent content', isBinary: false }) vi.stubGlobal('window', { api: { fs: { readFile } } }) @@ -522,9 +542,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { isDirty: true } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [dirtyFile], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never) const readFile = vi.fn().mockResolvedValue({ content: 'agent content', isBinary: false }) vi.stubGlobal('window', { api: { fs: { readFile } } }) @@ -550,9 +571,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { isDirty: true } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [dirtyFile], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyFile] as never) let resolveEchoRead: (value: { content: string; isBinary: boolean }) => void = () => {} const readFile = vi @@ -587,14 +609,16 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { worktreePath: '/repo', filePath: '/repo/notes.md', relativePath: 'notes.md', + language: 'markdown', mode: 'diff' as const, diffSource: 'unstaged' as const, isDirty: true } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [dirtyDiffTab], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyDiffTab] as never) const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) @@ -613,9 +637,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { externalMutation: 'changed' as const } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [dirtyChangedFile], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([dirtyChangedFile] as never) const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) @@ -633,14 +658,16 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { worktreePath: '/repo', filePath: '/repo', relativePath: 'Branch Changes', + language: 'markdown', mode: 'diff' as const, diffSource: 'combined-branch' as const, isDirty: false } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [branchDiffTab], setExternalMutation - } as never) + }) vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([]) const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) @@ -659,9 +686,10 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { runtimeEnvironmentId: 'env-1' } vi.mocked(useAppStore.getState).mockReturnValue({ + ...createTestStore().getState(), openFiles: [localFile, runtimeFile], setExternalMutation - } as never) + }) const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget) handleFsChanged(payload([{ kind: 'delete', absolutePath: '/repo/notes.md' }]), 'env-1') diff --git a/src/renderer/src/lib/execute-open-editor-path-move.test.ts b/src/renderer/src/lib/execute-open-editor-path-move.test.ts index cf3c1c433db..b85e1e44868 100644 --- a/src/renderer/src/lib/execute-open-editor-path-move.test.ts +++ b/src/renderer/src/lib/execute-open-editor-path-move.test.ts @@ -15,7 +15,7 @@ vi.mock('@/components/editor/editor-autosave', async (importOriginal) => { import { useAppStore } from '@/store' import { executeOpenEditorPathMove } from './execute-open-editor-path-move' import { __activeEditorPathMoveCountForTests } from '@/components/editor/editor-path-move-inflight' -import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' +import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' import { buildEditorExternalWatchEventHandler as createExternalWatchEventHandler } from '@/hooks/editor-external-watch-event-reconciliation' const CONTEXT = { diff --git a/src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts b/src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts index 4ac81fab497..abf08ba2cb7 100644 --- a/src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts +++ b/src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts @@ -3,7 +3,7 @@ import type { OpenFile } from '../types/open-file' import { mayShareEditorBackingFile } from '../file-ids/editor-file-ids' import { findWorktreeById } from '../../worktree-helpers' import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' -import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' +import { getDiskBaselineSignature } from '../../../../../../shared/disk-baseline-signature' import { deleteRuntimePath, deleteRuntimeRelativePath