Restored-tab disk re-check can't loop or raise a false conflict; drop test-only editor-state guard (#31250)

* refactor(editor): drop isWholeEditorState and the diff-signature re-export; bound superseded restore re-reads

- Remove isWholeEditorState; its callers take the real AppState. Tests that
  mocked partial stores now build full states from createTestStore.
- Import getDiskBaselineSignature from shared/disk-baseline-signature
  directly and delete the re-export from diff-content-signature.ts.
- Restored-tab conflict scan: count superseded re-reads toward
  MAX_VERIFY_ATTEMPTS, so a read slower than the re-verify interval gives up
  with the conflict banner instead of looping forever. Adds a regression test.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): keep the last read at the superseded re-read cap instead of giving up

A matching read at the cap cleared nothing and showed a false changed-on-disk
banner; it now resolves like any read. Only read failures give up.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): back off on superseded restore re-reads instead of capping them

A superseded read may predate the newer check, so it never resolves the
verification, even at the attempt cap. Overlapping checks now delay the next
fresh read (bounded, one timer per tab) and keep verification pending; only
failed reads count toward giving up.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(editor): address the restored tab by its owned id in the overlapping-check tests

After #31196 a server's tab is no longer keyed by its path, so these tests
flagged a tab that does not exist and no read was ever issued.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-20 16:21:42 -07:00
committed by GitHub
co-authored by Claude
parent ef950401d3
commit b5f97ca533
23 changed files with 165 additions and 111 deletions
@@ -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<string, string>, openFiles: OpenFile[] = [file]) {
vi.mocked(useAppStore.getState).mockReturnValue({
...createTestStore().getState(),
clearEditorDraft,
markFileDirty,
setExternalMutation,
setEditorDraft,
setLastKnownDiskSignature,
editorDrafts,
openFiles,
settings: {}
} as never)
openFiles
})
}
beforeEach(() => {
@@ -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 {
@@ -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'
@@ -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
}
@@ -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,
@@ -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<typeof createTestStore>
@@ -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 }])
@@ -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<AppState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createStore<any>()((...args: any[]) => ({
settings: {},
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
})) as unknown as StoreApi<AppState>
}
function openRestoredDirtyTab(
store: StoreApi<AppState>,
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')
@@ -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<string>()
const attemptsByFileId = new Map<string, number>()
const supersededReadsByFileId = new Map<string, number>()
const retryTimers = new Set<ReturnType<typeof setTimeout>>()
// 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
@@ -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.
@@ -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'
@@ -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.
@@ -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,
@@ -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<AppState> | null } => ({ store: null }))
@@ -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<AppState> } => ({}))
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({
@@ -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<T> = {
@@ -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
@@ -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<typeof useAppStore.getState>['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
@@ -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<T> = {
promise: Promise<T>
@@ -161,6 +162,7 @@ describe('useEditorPanelContentState visibility', () => {
mocks.readRuntimeFileContent.mockReset()
mocks.getState.mockReset()
mocks.getState.mockReturnValue({
...createTestStore().getState(),
settings: null,
openFiles: [],
setLastKnownDiskSignature: vi.fn()
@@ -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,
@@ -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<string, unknown> = {}): Record<string, unknown> {
function movedDirtyTab(overrides: Partial<OpenFile> = {}): 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<string, unknown>): 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(() => {
@@ -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
@@ -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')
@@ -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 = {
@@ -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