feat(editor): move-echo provenance + autosave gate on OpenFile (stage 3 store)

Adds pendingSelfMoveEcho {operationId,targetPath} to OpenFile and has the
rekey action install it + pendingLiveDiskVerification on dirty autosave-capable
destinations (moveOperationId arg), atomically in the same commit that re-homes
the tab — so the verify gate survives the rekey and its op-id token supersedes a
stale in-flight verification. Replaces the module-scoped generation map (which
broke under rekey). Verification-reader wiring + coordinator follow.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-20 17:12:40 -07:00
co-authored by Orca
parent 2397566ffa
commit e82ed76ea5
2 changed files with 61 additions and 2 deletions
@@ -189,6 +189,39 @@ describe('rekeyOpenFilesForPathChange', () => {
expect(moved.filePath).toBe('/repo/named.md')
})
it('installs the move-echo gate + provenance on a dirty destination when moveOperationId is set', () => {
seedEditTab()
const oldId = useAppStore.getState().openFiles[0]!.id
const newPath = '/repo/sub/a.md'
useAppStore.getState().rekeyOpenFilesForPathChange({
worktreeId: 'wt-1',
rekeys: [rekeyFor(oldId, newPath, 'sub/a.md')],
moveOperationId: 'op-42'
})
const moved = useAppStore.getState().openFiles[0]!
// Autosave is gated synchronously by the same commit that re-homes the tab.
expect(moved.pendingLiveDiskVerification).toBe(true)
expect(moved.pendingSelfMoveEcho).toEqual({ operationId: 'op-42', targetPath: newPath })
})
it('does not gate a clean destination', () => {
seedEditTab()
const oldId = useAppStore.getState().openFiles[0]!.id
useAppStore.getState().markFileDirty(oldId, false)
useAppStore.getState().rekeyOpenFilesForPathChange({
worktreeId: 'wt-1',
rekeys: [rekeyFor(oldId, '/repo/sub/a.md', 'sub/a.md')],
moveOperationId: 'op-42'
})
const moved = useAppStore.getState().openFiles[0]!
expect(moved.pendingLiveDiskVerification).toBeUndefined()
expect(moved.pendingSelfMoveEcho).toBeUndefined()
})
it('migrates a pending editor reveal to the new path', () => {
seedEditTab()
const oldId = useAppStore.getState().openFiles[0]!.id
+28 -2
View File
@@ -273,6 +273,11 @@ export type OpenFile = {
* Separate owner from the restored scan's flag so the two can't clear each
* other's gate. Transient, not persisted, not carried across a re-home. */
pendingLiveDiskVerification?: boolean
/** Routes the destination watcher echo of an Orca-owned move into content
* verification. Stored ON the tab so it (and its operationId token) survive
* the atomic rekey; the token also supersedes a stale in-flight verification
* when the tab is moved again. Transient, not persisted. */
pendingSelfMoveEcho?: { operationId: string; targetPath: string }
/** Why: diff bodies are cached in EditorPanel. Re-selecting an existing diff
* tab from the tree bumps this so the panel refetches instead of reusing a
* stale snapshot. */
@@ -540,6 +545,9 @@ export type EditorSlice = {
rekeyOpenFilesForPathChange: (args: {
worktreeId: string
rekeys: readonly OpenFilePathRekey[]
/** When set, dirty autosave-capable destinations get a move-echo provenance
* + a synchronous autosave gate so the watcher can content-verify the echo. */
moveOperationId?: string
}) => RekeyOpenFilesResult
clearUntitled: (fileId: string) => void
openDiff: (
@@ -2633,7 +2641,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
}),
rekeyOpenFilesForPathChange: ({ worktreeId, rekeys }) => {
rekeyOpenFilesForPathChange: ({ worktreeId, rekeys, moveOperationId }) => {
if (rekeys.length === 0) {
return { ok: true }
}
@@ -2674,6 +2682,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
// Spread the whole OpenFile so every field — including ones this action
// doesn't know about — survives; change only the path-derived ones.
// Install the move-echo gate atomically here so autosave is suspended
// before any watcher echo can be verified (only a dirty autosave-capable
// tab can be clobbered by autosave, so others need no gate).
const gatesEcho =
moveOperationId !== undefined &&
f.isDirty &&
(f.mode === 'edit' || (f.mode === 'diff' && f.diffSource === 'unstaged'))
return {
...f,
id: rekey.newFileId,
@@ -2683,7 +2698,18 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
...(rekey.newMarkdownPreviewSourceFileId !== undefined
? { markdownPreviewSourceFileId: rekey.newMarkdownPreviewSourceFileId }
: {}),
...(rekey.consumeUntitled ? { isUntitled: false, deleteUntouchedOnClose: undefined } : {})
...(rekey.consumeUntitled
? { isUntitled: false, deleteUntouchedOnClose: undefined }
: {}),
...(gatesEcho
? {
pendingLiveDiskVerification: true,
pendingSelfMoveEcho: {
operationId: moveOperationId,
targetPath: rekey.newFilePath
}
}
: {})
}
})