fix: reload external edits after editor self-writes (#2119)

This commit is contained in:
Neil 2026-05-16 16:03:20 -07:00 committed by GitHub
parent 52e737e7db
commit c005f3d9ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 125 additions and 20 deletions

View File

@ -83,7 +83,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
// round-tripping back into a setContent that jumps the cursor to the
// end (and, under round-trip drift, can drop keystrokes typed in the
// debounce window). See editor-self-write-registry.
recordSelfWrite(liveFile.filePath)
recordSelfWrite(liveFile.filePath, contentToSave)
try {
await writeRuntimeFile(
{

View File

@ -13,27 +13,42 @@ import { normalizeAbsolutePath } from '@/components/right-sidebar/file-explorer-
// gets picked up.
const SELF_WRITE_TTL_MS = 750
const stamps = new Map<string, number>()
export type RecentSelfWrite = {
content: string | null
}
export function recordSelfWrite(absolutePath: string): void {
stamps.set(normalizeAbsolutePath(absolutePath), Date.now() + SELF_WRITE_TTL_MS)
type SelfWriteStamp = RecentSelfWrite & {
expiresAt: number
}
const stamps = new Map<string, SelfWriteStamp>()
export function recordSelfWrite(absolutePath: string, content?: string): void {
stamps.set(normalizeAbsolutePath(absolutePath), {
content: content ?? null,
expiresAt: Date.now() + SELF_WRITE_TTL_MS
})
}
export function clearSelfWrite(absolutePath: string): void {
stamps.delete(normalizeAbsolutePath(absolutePath))
}
export function hasRecentSelfWrite(absolutePath: string): boolean {
export function getRecentSelfWrite(absolutePath: string): RecentSelfWrite | null {
const key = normalizeAbsolutePath(absolutePath)
const expiry = stamps.get(key)
if (expiry === undefined) {
return false
const stamp = stamps.get(key)
if (!stamp) {
return null
}
if (Date.now() > expiry) {
if (Date.now() > stamp.expiresAt) {
stamps.delete(key)
return false
return null
}
return true
return { content: stamp.content }
}
export function hasRecentSelfWrite(absolutePath: string): boolean {
return getRecentSelfWrite(absolutePath) !== null
}
export function __clearSelfWriteRegistryForTests(): void {

View File

@ -24,6 +24,10 @@ import {
getOpenFilesForExternalFileChange,
notifyEditorExternalFileChange
} from '@/components/editor/editor-autosave'
import {
__clearSelfWriteRegistryForTests,
recordSelfWrite
} from '@/components/editor/editor-self-write-registry'
describe('getWatchedTargetKey', () => {
it('changes when a worktree gains an SSH connection id', () => {
@ -161,6 +165,8 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
__clearSelfWriteRegistryForTests()
})
function payload(events: FsChangedPayload['events']): FsChangedPayload {
@ -284,4 +290,44 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => {
})
dispose()
})
it('does not drop external edits that arrive inside the self-write TTL', async () => {
vi.mocked(useAppStore.getState).mockReturnValue({
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 } } })
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
recordSelfWrite('/repo/notes.md', 'orca save')
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
await vi.advanceTimersByTimeAsync(100)
expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
worktreeId: 'wt-1',
worktreePath: '/repo',
relativePath: 'notes.md'
})
dispose()
})
it('still suppresses the watcher echo from Orca self-writes', async () => {
vi.mocked(useAppStore.getState).mockReturnValue({
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 } } })
const { handleFsChanged, dispose } = createExternalWatchEventHandler(findTarget)
recordSelfWrite('/repo/notes.md', 'orca save')
handleFsChanged(payload([{ kind: 'update', absolutePath: '/repo/notes.md' }]))
await vi.advanceTimersByTimeAsync(100)
expect(notifyEditorExternalFileChange).not.toHaveBeenCalled()
dispose()
})
})

View File

@ -11,11 +11,15 @@ import {
getOpenFilesForExternalFileChange,
notifyEditorExternalFileChange
} from '@/components/editor/editor-autosave'
import { hasRecentSelfWrite } from '@/components/editor/editor-self-write-registry'
import {
clearSelfWrite,
getRecentSelfWrite,
type RecentSelfWrite
} from '@/components/editor/editor-self-write-registry'
import type { FsChangedPayload } from '../../../shared/types'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { OpenFile } from '@/store/slices/editor'
import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
import { readRuntimeFileContent, subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
// Why: atomic-write patterns (Claude Code's Edit tool, editors like vim,
// VSCode) land as a short burst of `update` events — or `delete + create` on
@ -475,14 +479,10 @@ export function createExternalWatchEventHandler(
if (matching.some((f) => f.isDirty)) {
continue
}
// Why: our own save path stamps the registry right before writeFile, so
// a fs:changed event arriving within the TTL is the echo of that write
// rather than a real external edit. Skipping the reload avoids the
// setContent round-trip that would otherwise reset the TipTap cursor
// to the end of the document mid-typing. A genuinely external edit
// after the TTL still reaches the editor via the next fs event.
const absolutePath = joinPath(notification.worktreePath, notification.relativePath)
if (hasRecentSelfWrite(absolutePath)) {
const recentSelfWrite = getRecentSelfWrite(absolutePath)
if (recentSelfWrite) {
scheduleSelfWriteAwareExternalReload(target, notification, matching[0], recentSelfWrite)
continue
}
scheduleDebouncedExternalReload(notification)
@ -501,6 +501,50 @@ export function createExternalWatchEventHandler(
return { handleFsChanged, dispose }
}
function scheduleSelfWriteAwareExternalReload(
target: WatchedTarget,
notification: ExternalWatchNotification,
file: OpenFile,
recentSelfWrite: RecentSelfWrite
): void {
if (recentSelfWrite.content === null) {
scheduleDebouncedExternalReload(notification)
return
}
const runtimeEnvironmentId = file.runtimeEnvironmentId ?? target.runtimeEnvironmentId
// Why: a recent self-write stamp only proves the path changed recently; an
// agent can write a newer version inside the same TTL. Compare disk content
// with the saved text so we suppress only the echo of Orca's own write.
void readRuntimeFileContent({
settings: runtimeEnvironmentId ? { activeRuntimeEnvironmentId: runtimeEnvironmentId } : null,
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
connectionId: target.connectionId
})
.then((result) => {
if (
(result.isBinary || result.content !== recentSelfWrite.content) &&
hasCleanExternalReloadTarget(notification)
) {
clearSelfWrite(file.filePath)
scheduleDebouncedExternalReload(notification)
}
})
.catch(() => {
if (hasCleanExternalReloadTarget(notification)) {
clearSelfWrite(file.filePath)
scheduleDebouncedExternalReload(notification)
}
})
}
function hasCleanExternalReloadTarget(notification: ExternalWatchNotification): boolean {
const matching = getOpenFilesForExternalFileChange(useAppStore.getState().openFiles, notification)
return matching.length > 0 && matching.every((file) => !file.isDirty)
}
export function getOverflowExternalReloadTargets(
target: Pick<WatchedTarget, 'worktreeId' | 'worktreePath'>
): ExternalWatchNotification[] {