diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 20f3b6c0..d909c82d 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -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) } diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index aa1479ea..c5653f68 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -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() + } + }) }) diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index baddf3a1..7e8a8504 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -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 diff --git a/src/renderer/src/components/editor/editor-self-write-registry.ts b/src/renderer/src/components/editor/editor-self-write-registry.ts new file mode 100644 index 00000000..7140e0dd --- /dev/null +++ b/src/renderer/src/components/editor/editor-self-write-registry.ts @@ -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() + +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() +} diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 6b11a29a..18243d87 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -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) } }