fix: reload external edits after editor self-writes (#2119)
This commit is contained in:
parent
52e737e7db
commit
c005f3d9ee
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
Loading…
Reference in New Issue