diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 73c2817d7..8666562b7 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -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( { diff --git a/src/renderer/src/components/editor/editor-self-write-registry.ts b/src/renderer/src/components/editor/editor-self-write-registry.ts index 7140e0dd3..4d87dd136 100644 --- a/src/renderer/src/components/editor/editor-self-write-registry.ts +++ b/src/renderer/src/components/editor/editor-self-write-registry.ts @@ -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() +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() + +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 { diff --git a/src/renderer/src/hooks/useEditorExternalWatch.test.ts b/src/renderer/src/hooks/useEditorExternalWatch.test.ts index 1b0b823f6..61641c55e 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.test.ts @@ -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() + }) }) diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index d6d108183..5db3dbe3e 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -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 ): ExternalWatchNotification[] {