mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
8e88fdae33
commit
db96479a5d
5 changed files with 134 additions and 6 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue