fix(editor): prevent cursor jump to end when typing during autosave in markdown editor (#855)

Autosave writes to disk echo back as fs:changed events that were treated as
external edits, triggering a setContent reload mid-typing that reset the TipTap
selection to the document end (and could drop unsaved keystrokes). Stamp each
self-write in a registry so useEditorExternalWatch ignores its own echo, and
preserve selection when genuine external edits do arrive.
This commit is contained in:
Jinjing 2026-04-19 22:05:52 -07:00 committed by GitHub
parent 8e88fdae33
commit db96479a5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 134 additions and 6 deletions

View file

@ -438,6 +438,17 @@ export default function RichMarkdownEditor({
// triggers a re-sync attempt instead of being short-circuited by the
// `content === lastCommittedMarkdownRef.current` guard above.
try {
// Why: TipTap's setContent collapses the selection to the end of the
// new document by default. When the editor is focused (user is
// actively typing), that reads as a spontaneous cursor jump to EOF.
// Snapshot the current selection bounds and restore them clamped to
// the new doc length after the content swap so the caret stays put
// for any genuinely external edit that lands during a typing session.
// The old doc's offsets are a best-effort heuristic — for a real
// external rewrite they won't map to the semantically equivalent
// position, but this is still strictly better than jumping to EOF.
const hadFocus = editor.isFocused
const { from: prevFrom, to: prevTo } = editor.state.selection
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(content), {
contentType: 'markdown',
emitUpdate: false
@ -446,6 +457,18 @@ export default function RichMarkdownEditor({
// may re-introduce paragraphs with embedded `\n` characters.
normalizeSoftBreaks(editor)
lastCommittedMarkdownRef.current = content
if (hadFocus) {
// Why: setContent can blur the editor via ProseMirror's focus
// handling, so restoring selection alone would leave subsequent
// keystrokes going to the browser. Chain focus() after the
// selection restore to keep the typing session intact.
const docSize = editor.state.doc.content.size
editor
.chain()
.setTextSelection({ from: Math.min(prevFrom, docSize), to: Math.min(prevTo, docSize) })
.focus()
.run()
}
} catch (err) {
console.error('[RichMarkdownEditor] failed to apply external content update', err)
}

View file

@ -6,6 +6,7 @@ import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT } from '../../../../shared/editor-sa
import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave'
import { attachEditorAutosaveController } from './editor-autosave-controller'
import { registerPendingEditorFlush } from './editor-pending-flush'
import { __clearSelfWriteRegistryForTests, hasRecentSelfWrite } from './editor-self-write-registry'
type WindowStub = {
addEventListener: Window['addEventListener']
@ -61,6 +62,7 @@ describe('attachEditorAutosaveController', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
__clearSelfWriteRegistryForTests()
})
it('saves dirty files even when the visible EditorPanel is not mounted', async () => {
@ -241,4 +243,40 @@ describe('attachEditorAutosaveController', () => {
unregisterFlush()
}
})
it('clears the self-write stamp when a save fails before touching disk', async () => {
const writeFile = vi.fn().mockRejectedValue(new Error('disk full'))
const eventTarget = new EventTarget()
vi.stubGlobal('window', {
addEventListener: eventTarget.addEventListener.bind(eventTarget),
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
api: {
fs: {
writeFile
}
}
} satisfies WindowStub)
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/file.md',
relativePath: 'file.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit'
})
store.getState().setEditorDraft('/repo/file.md', 'edited')
store.getState().markFileDirty('/repo/file.md', true)
const cleanup = attachEditorAutosaveController(store)
try {
await expect(requestEditorFileSave({ fileId: '/repo/file.md' })).rejects.toThrow('disk full')
expect(hasRecentSelfWrite('/repo/file.md')).toBe(false)
} finally {
cleanup()
}
})
})

View file

@ -18,6 +18,7 @@ import {
type EditorSaveQuiesceDetail
} from './editor-autosave'
import { flushPendingEditorChange } from './editor-pending-flush'
import { clearSelfWrite, recordSelfWrite } from './editor-self-write-registry'
import {
ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT,
type EditorSaveDirtyFilesDetail
@ -74,11 +75,25 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
const contentToSave = state.editorDrafts[file.id] ?? fallbackContent
const connectionId = getConnectionId(liveFile.worktreeId) ?? undefined
await window.api.fs.writeFile({
filePath: liveFile.filePath,
content: contentToSave,
connectionId
})
// Why: stamp before the write so the fs:changed event that our own
// write produces is ignored by useEditorExternalWatch instead of
// 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)
try {
await window.api.fs.writeFile({
filePath: liveFile.filePath,
content: contentToSave,
connectionId
})
} catch (error) {
// Why: the self-write stamp is only valid if a disk write actually
// happened. Clearing it on failure keeps the external watcher from
// suppressing a real third-party update that lands during the TTL.
clearSelfWrite(liveFile.filePath)
throw error
}
if ((saveGeneration.get(file.id) ?? 0) !== queuedGeneration) {
return

View file

@ -0,0 +1,41 @@
import { normalizeAbsolutePath } from '@/components/right-sidebar/file-explorer-paths'
// Why: the editor's own save path writes to disk, which fans out as an
// fs:changed event back to useEditorExternalWatch a few ms later. Treating
// our own write as an "external" change schedules a setContent reload that
// resets the TipTap selection to the end of the document mid-typing — and,
// because the RichMarkdownEditor guards (lastCommittedMarkdownRef + current
// getMarkdown() round-trip) can drift by a trailing newline or soft-break,
// the reload can silently drop unsaved keystrokes as well. Stamping a path
// right before writeFile lets the watch hook ignore the echo event without
// touching the editor at all. Keyed by normalized absolute path, bounded by
// a short TTL so a genuinely external edit that lands after the window still
// gets picked up.
const SELF_WRITE_TTL_MS = 750
const stamps = new Map<string, number>()
export function recordSelfWrite(absolutePath: string): void {
stamps.set(normalizeAbsolutePath(absolutePath), Date.now() + SELF_WRITE_TTL_MS)
}
export function clearSelfWrite(absolutePath: string): void {
stamps.delete(normalizeAbsolutePath(absolutePath))
}
export function hasRecentSelfWrite(absolutePath: string): boolean {
const key = normalizeAbsolutePath(absolutePath)
const expiry = stamps.get(key)
if (expiry === undefined) {
return false
}
if (Date.now() > expiry) {
stamps.delete(key)
return false
}
return true
}
export function __clearSelfWriteRegistryForTests(): void {
stamps.clear()
}

View file

@ -5,13 +5,14 @@
import { useEffect, useMemo, useRef } from 'react'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { basename } from '@/lib/path'
import { basename, joinPath } from '@/lib/path'
import { normalizeAbsolutePath } from '@/components/right-sidebar/file-explorer-paths'
import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch'
import {
getOpenFilesForExternalFileChange,
notifyEditorExternalFileChange
} from '@/components/editor/editor-autosave'
import { hasRecentSelfWrite } 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'
@ -388,6 +389,16 @@ 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)) {
continue
}
scheduleDebouncedExternalReload(notification)
}
}