From ed2135d248936d54d5e5bf2c6f4f86f1364899ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:33:11 -0700 Subject: [PATCH] Preserve original markdown style when editing in the rich editor (#8862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Preserve original markdown style when editing in the rich editor The rich markdown editor re-serialized the whole document into a canonical style on the first edit, so a one-character change rewrote every non-canonical construct (`_x_`->`*x*`, `__x__`->`**x**`, `* item`->`- item`, dropped trailing newline) and produced a large, unexpected diff. Reconcile the canonical getMarkdown() output back toward the original source bytes at every disk-bound serialize site (debounced autosave, flush, Cmd/Ctrl+S) so untouched regions keep their markup and only the edited region changes. A divergent-base fuzzy patch (diff-match-patch) carries the user's edit onto the original style; a safety re-parse requires the result to render-equal what the editor shows, else it falls back to today's canonical output — so content semantics can never change. The rich editor stays the default for markdown. Fixes #6080 * Bound markdown reconciliation diff work --- package.json | 1 + pnpm-lock.yaml | 9 + .../components/editor/RichMarkdownEditor.tsx | 30 +- .../rich-markdown-editor-config.test.ts | 3 + .../editor/rich-markdown-editor-config.ts | 26 +- .../editor/rich-markdown-key-handler.test.ts | 3 + .../editor/rich-markdown-key-handler.ts | 15 +- .../rich-markdown-reconcile-serializer.ts | 61 +++ .../editor/rich-markdown-save-shortcut.ts | 31 ++ ...rich-markdown-serialization-commit.test.ts | 149 +++++ .../rich-markdown-serialization-commit.ts | 59 ++ .../rich-markdown-source-reconcile.test.ts | 516 ++++++++++++++++++ .../editor/rich-markdown-source-reconcile.ts | 195 +++++++ .../rich-markdown-tab-key-handler.test.ts | 3 + .../useRichMarkdownProgrammaticSync.test.ts | 90 +++ .../editor/useRichMarkdownProgrammaticSync.ts | 33 +- .../useRichMarkdownReconcileRoundTrip.ts | 42 ++ 17 files changed, 1247 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-reconcile-serializer.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-save-shortcut.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-serialization-commit.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-source-reconcile.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts diff --git a/package.json b/package.json index 9dcd590ea..cc965af94 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "@electron/rebuild": "^4.2.0", "@monaco-editor/react": "^4.7.0", "@playwright/test": "^1.59.1", + "@sanity/diff-match-patch": "^3.2.0", "@stablyai/playwright-test": "^2.1.14", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-virtual": "^3.13.24", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7af13f9c3..b8d00d79d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: '@playwright/test': specifier: ^1.59.1 version: 1.59.1 + '@sanity/diff-match-patch': + specifier: ^3.2.0 + version: 3.2.0 '@stablyai/playwright-test': specifier: ^2.1.14 version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) @@ -2604,6 +2607,10 @@ packages: cpu: [x64] os: [win32] + '@sanity/diff-match-patch@3.2.0': + resolution: {integrity: sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==} + engines: {node: '>=18.18'} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -8799,6 +8806,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.3': optional: true + '@sanity/diff-match-patch@3.2.0': {} + '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/is@4.6.0': {} diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 1c0cb2734..fdbaee0df 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -17,6 +17,8 @@ import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface' import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance' import { useRichMarkdownMenuController } from './useRichMarkdownMenuController' import { useRichMarkdownProgrammaticSync } from './useRichMarkdownProgrammaticSync' +import { useRichMarkdownReconcileRoundTrip } from './useRichMarkdownReconcileRoundTrip' +import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' import { useRichMarkdownReviewController } from './useRichMarkdownReviewController' import { useRichMarkdownReviewEditorEffects } from './useRichMarkdownReviewEditorEffects' import { @@ -99,6 +101,11 @@ export default function RichMarkdownEditor({ const menu = useRichMarkdownMenuController({ markdownDocuments }) const isMac = navigator.userAgent.includes('Mac') const lastCommittedMarkdownRef = useRef(content) + // Why: three-way source-preserving reconciliation baseline — the raw on-disk + // bytes and their canonical serialization — so edits patch onto the original + // style rather than re-canonicalizing untouched regions (#6080). + const originalSourceRef = useRef(content) + const baseCanonicalRef = useRef('') const onContentChangeRef = useRef(onContentChange) const onDirtyStateHintRef = useRef(onDirtyStateHint) const onSaveRef = useRef(onSave) @@ -159,6 +166,13 @@ export default function RichMarkdownEditor({ onSaveRef.current = onSave onOpenDocLinkRef.current = onOpenDocLink isEditingLinkRef.current = isEditingLink + const reconcileRoundTripRef = useRichMarkdownReconcileRoundTrip({ + htmlSuperscriptLinkContext, + filePath, + runtimeEnvironmentId, + worktreeId, + worktreeRoot + }) const flushPendingSerialization = useCallback(() => { if (serializeTimerRef.current === null) { @@ -167,16 +181,19 @@ export default function RichMarkdownEditor({ window.clearTimeout(serializeTimerRef.current) serializeTimerRef.current = null try { - const markdown = editorRef.current?.getMarkdown() - if (markdown !== undefined) { - lastCommittedMarkdownRef.current = markdown + const { markdown, didSerialize } = commitRichMarkdownSerialization( + editorRef.current, + { originalSourceRef, baseCanonicalRef, lastCommittedMarkdownRef }, + reconcileRoundTripRef.current + ) + if (didSerialize) { onContentChangeRef.current(markdown) } } catch { // Why: save/restart flows should never crash the UI just because the // editor was torn down between scheduling and flushing a debounced sync. } - }, []) + }, [reconcileRoundTripRef]) useEffect(() => { // Why: autosave/restart paths live outside the editor component tree, so a @@ -216,6 +233,9 @@ export default function RichMarkdownEditor({ rootRef, editorRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + reconcileRoundTripRef, onContentChangeRef, onDirtyStateHintRef, onSaveRef, @@ -288,6 +308,8 @@ export default function RichMarkdownEditor({ filePath, isApplyingProgrammaticUpdateRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, markdownDocuments, rootRef, runtimeEnvironmentId, diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts index 45f43e77e..8573ddea9 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -48,6 +48,9 @@ function createConfigParams(overrides: Partial = {}): Editor rootRef: ref(null), editorRef: ref(null), lastCommittedMarkdownRef: ref(''), + originalSourceRef: ref(''), + baseCanonicalRef: ref(''), + reconcileRoundTripRef: ref<(markdown: string) => string | null>(() => null), onContentChangeRef: ref(vi.fn()), onDirtyStateHintRef: ref(vi.fn()), onSaveRef: ref(vi.fn()), diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 12ec6dc92..ec94eda94 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -22,6 +22,7 @@ import { type RichMarkdownRuntimeSettings } from './rich-markdown-editor-click-routing' import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler' +import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' import { createRichMarkdownImageResolverContext, setRichMarkdownImageResolverContext @@ -51,6 +52,9 @@ export type EditorConfigParams = { rootRef: MutableRefObject editorRef: MutableRefObject lastCommittedMarkdownRef: MutableRefObject + originalSourceRef: MutableRefObject + baseCanonicalRef: MutableRefObject + reconcileRoundTripRef: MutableRefObject<(markdown: string) => string | null> onContentChangeRef: MutableRefObject<(content: string) => void> onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void> onSaveRef: MutableRefObject<(content: string) => void> @@ -100,6 +104,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE rootRef, editorRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + reconcileRoundTripRef, onContentChangeRef, onDirtyStateHintRef, onSaveRef, @@ -170,6 +177,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE editorRef, rootRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + reconcileRoundTripRef, onContentChangeRef, onSaveRef, isEditingLinkRef, @@ -236,6 +246,11 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE // split on load. normalizeEmptyListItems(nextEditor) lastCommittedMarkdownRef.current = content + // Why: seed the source-preserving reconciliation baseline — the raw loaded + // bytes and their canonical serialization — so the first edit patches onto + // the original style instead of re-canonicalizing the whole file. + originalSourceRef.current = content + baseCanonicalRef.current = nextEditor.getMarkdown() isInitializingRef.current = false cancelAutoFocusRef.current?.() cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current) @@ -268,9 +283,14 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE serializeTimerRef.current = window.setTimeout(() => { serializeTimerRef.current = null try { - const markdown = nextEditor.getMarkdown() - lastCommittedMarkdownRef.current = markdown - onContentChangeRef.current(markdown) + const { markdown, didSerialize } = commitRichMarkdownSerialization( + nextEditor, + { originalSourceRef, baseCanonicalRef, lastCommittedMarkdownRef }, + reconcileRoundTripRef.current + ) + if (didSerialize) { + onContentChangeRef.current(markdown) + } } catch { // Why: save/restart flows should never crash the UI just because // the editor was torn down between scheduling and serializing. diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index ba9aaea31..02d53e1b6 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -53,6 +53,9 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext editorRef: { current: editor }, rootRef: { current: null }, lastCommittedMarkdownRef: { current: '' }, + originalSourceRef: { current: '' }, + baseCanonicalRef: { current: '' }, + reconcileRoundTripRef: { current: () => null }, onContentChangeRef: { current: vi.fn() }, onSaveRef: { current: vi.fn() }, isEditingLinkRef: { current: false }, diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index 2805141d1..d8995f3c9 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -3,7 +3,6 @@ import type { Editor } from '@tiptap/react' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' import { isMarkdownPreviewFindShortcut } from './markdown-preview-search' -import { editorShortcutMatches } from './editor-shortcuts' import type { LinkBubbleState } from './RichMarkdownLinkBubble' import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands' import { @@ -21,12 +20,16 @@ import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-de import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard' import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut' +import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut' export type KeyHandlerContext = { isMac: boolean editorRef: MutableRefObject rootRef: MutableRefObject lastCommittedMarkdownRef: MutableRefObject + originalSourceRef: MutableRefObject + baseCanonicalRef: MutableRefObject + reconcileRoundTripRef: MutableRefObject<(markdown: string) => string | null> onContentChangeRef: MutableRefObject<(content: string) => void> onSaveRef: MutableRefObject<(content: string) => void> isEditingLinkRef: MutableRefObject @@ -127,15 +130,7 @@ export function createRichMarkdownKeyHandler( ctx.openSearchRef.current() return true } - if (editorShortcutMatches('editor.save', event)) { - event.preventDefault() - // Why: flush any pending debounced serialization so the save - // captures the very latest editor content, not a stale snapshot. - ctx.flushPendingSerialization() - const markdown = ctx.editorRef.current?.getMarkdown() ?? ctx.lastCommittedMarkdownRef.current - ctx.lastCommittedMarkdownRef.current = markdown - ctx.onContentChangeRef.current(markdown) - ctx.onSaveRef.current(markdown) + if (handleRichMarkdownSaveShortcut(ctx, event)) { return true } diff --git a/src/renderer/src/components/editor/rich-markdown-reconcile-serializer.ts b/src/renderer/src/components/editor/rich-markdown-reconcile-serializer.ts new file mode 100644 index 000000000..64c83fe5c --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-reconcile-serializer.ts @@ -0,0 +1,61 @@ +import { Editor } from '@tiptap/core' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { + createRichMarkdownHtmlSuperscriptLinkContext, + type RichMarkdownHtmlSuperscriptLinkContext +} from './rich-markdown-html-superscript-link-context' +import { + setRichMarkdownImageResolverContext, + type RichMarkdownImageResolverContext +} from './rich-markdown-image-context' +import { normalizeEmptyListItems } from './rich-markdown-normalize' + +export type RichMarkdownReconcileSerializerContext = { + htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext + imageResolverContext: RichMarkdownImageResolverContext +} + +/** + * Re-serializes markdown through a throwaway editor that REPLICATES the mounted + * editor's post-load state: same extension set, the file's own superscript-link + * classification context, its image resolver context, and normalizeEmptyListItems. + * Reconciliation's safety re-parse must match the live getMarkdown() output, or + * docs with empty list items / local images / superscript links would spuriously + * mismatch and silently fall back to canonical. Returns null if serialization throws. + */ +export function serializeRichMarkdownForReconcile( + content: string, + { htmlSuperscriptLinkContext, imageResolverContext }: RichMarkdownReconcileSerializerContext +): string | null { + try { + const codec = createRichMarkdownEditorCodec() + // Why: mirror the live editor's link classification (sourceOwner/paths drive + // whether a link serializes as a superscript link) via a fresh context built + // from the live snapshot, without subscribing to the live editor's context. + const { version: _version, ...snapshot } = htmlSuperscriptLinkContext.getSnapshot() + const context = createRichMarkdownHtmlSuperscriptLinkContext(snapshot) + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ + codec, + htmlSuperscriptLinks: true, + htmlSuperscriptLinkContext: context + }), + content: encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }), + contentType: 'markdown', + onBeforeCreate: ({ editor: nextEditor }) => { + setRichMarkdownImageResolverContext(nextEditor, imageResolverContext) + } + }) + try { + normalizeEmptyListItems(editor) + return editor.getMarkdown() + } finally { + editor.destroy() + } + } catch { + return null + } +} diff --git a/src/renderer/src/components/editor/rich-markdown-save-shortcut.ts b/src/renderer/src/components/editor/rich-markdown-save-shortcut.ts new file mode 100644 index 000000000..14c641a95 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-save-shortcut.ts @@ -0,0 +1,31 @@ +import type { KeyHandlerContext } from './rich-markdown-key-handler' +import { editorShortcutMatches } from './editor-shortcuts' +import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' + +/** + * Cmd/Ctrl+S: flush the debounced serialization, then reconcile toward the + * original source style before saving so untouched regions keep their bytes. + */ +export function handleRichMarkdownSaveShortcut( + ctx: KeyHandlerContext, + event: KeyboardEvent +): boolean { + if (!editorShortcutMatches('editor.save', event)) { + return false + } + event.preventDefault() + // Why: flush pending debounced serialization so the save captures the very + // latest editor content, not a stale snapshot. + ctx.flushPendingSerialization() + // Why: the flush already reconciled + updated refs, so this re-serialize is + // idempotent (edited === baseCanonical → returns the reconciled bytes). On a + // torn-down editor it falls back to the last committed bytes without patching. + const { markdown } = commitRichMarkdownSerialization( + ctx.editorRef.current, + ctx, + ctx.reconcileRoundTripRef.current + ) + ctx.onContentChangeRef.current(markdown) + ctx.onSaveRef.current(markdown) + return true +} diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts new file mode 100644 index 000000000..e08b7a08b --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Editor } from '@tiptap/react' +import { + commitRichMarkdownSerialization, + type RichMarkdownReconcileRefs +} from './rich-markdown-serialization-commit' +import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut' +import type { KeyHandlerContext } from './rich-markdown-key-handler' + +// Style-only canonicalizer mirroring the #6080 rewrites; stands in for the live +// editor's getMarkdown so these tests need no DOM. Strips the trailing newline +// like the real getMarkdown, so the branch-6 exact re-parse comparison behaves as +// in production (the reconcile roundTrip and the edited getMarkdown agree on EOF). +function fakeCanonicalize(md: string): string { + return md + .replace(/__([^_]+)__/g, '**$1**') + .replace(/_([^_]+)_/g, '*$1*') + .replace(/\n+$/, '') +} +const roundTrip = (md: string): string => fakeCanonicalize(md) + +function refs(originalSource: string, baseCanonical: string): RichMarkdownReconcileRefs { + return { + originalSourceRef: { current: originalSource }, + baseCanonicalRef: { current: baseCanonical }, + lastCommittedMarkdownRef: { current: '' } + } +} + +function fakeEditor(getMarkdown: () => string): Editor { + return { getMarkdown } as unknown as Editor +} + +describe('commitRichMarkdownSerialization (shared disk-bound serialize chokepoint)', () => { + it('persists SOURCE-PRESERVING bytes, not raw getMarkdown (regression gate for #6080)', () => { + const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*') + const editor = fakeEditor(() => '# Title!\n\n*word*') // canonical edit + + const { markdown, didSerialize } = commitRichMarkdownSerialization(editor, r, roundTrip) + + // Fails if this site reverted to raw getMarkdown() (would emit *word*). + expect(markdown).toBe('# Title!\n\n_word_\n') + expect(didSerialize).toBe(true) + }) + + it('advances all three refs so the next incremental edit patches onto the reconciled source', () => { + const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*') + const editor = fakeEditor(() => '# Title!\n\n*word*') + + commitRichMarkdownSerialization(editor, r, roundTrip) + + expect(r.originalSourceRef.current).toBe('# Title!\n\n_word_\n') // reconciled bytes + expect(r.baseCanonicalRef.current).toBe('# Title!\n\n*word*') // canonical of reconciled + expect(r.lastCommittedMarkdownRef.current).toBe('# Title!\n\n_word_\n') // exact disk bytes + }) + + it('falls back to the last committed bytes when the editor is torn down (null)', () => { + const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*') + r.lastCommittedMarkdownRef.current = '# Title\n\n_word_\n' + + const { markdown, didSerialize } = commitRichMarkdownSerialization(null, r, roundTrip) + + expect(didSerialize).toBe(false) + expect(markdown).toBe('# Title\n\n_word_\n') + // Refs untouched on a torn-down editor. + expect(r.originalSourceRef.current).toBe('# Title\n\n_word_\n') + }) + + it('does not crash when getMarkdown throws (editor destroyed mid-flush)', () => { + const r = refs('src', 'src') + r.lastCommittedMarkdownRef.current = 'safe' + const editor = fakeEditor(() => { + throw new Error('editor destroyed') + }) + + const { markdown, didSerialize } = commitRichMarkdownSerialization(editor, r, roundTrip) + + expect(didSerialize).toBe(false) + expect(markdown).toBe('safe') + }) +}) + +describe('handleRichMarkdownSaveShortcut (Cmd/Ctrl+S persistence site)', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + function saveEvent(): KeyboardEvent & { preventDefault: ReturnType } { + return { + key: 's', + code: 'KeyS', + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + preventDefault: vi.fn() + } as unknown as KeyboardEvent & { preventDefault: ReturnType } + } + + function saveContext(editor: Editor | null): { + ctx: KeyHandlerContext + onSave: ReturnType + onContentChange: ReturnType + flush: ReturnType + } { + const onSave = vi.fn() + const onContentChange = vi.fn() + const flush = vi.fn() + const ctx = { + editorRef: { current: editor }, + originalSourceRef: { current: '# Title\n\n_word_\n' }, + baseCanonicalRef: { current: '# Title\n\n*word*' }, + lastCommittedMarkdownRef: { current: '' }, + reconcileRoundTripRef: { current: roundTrip }, + onContentChangeRef: { current: onContentChange }, + onSaveRef: { current: onSave }, + flushPendingSerialization: flush + } as unknown as KeyHandlerContext + return { ctx, onSave, onContentChange, flush } + } + + it('flushes then saves SOURCE-PRESERVING bytes on Cmd+S (mac)', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const editor = fakeEditor(() => '# Title!\n\n*word*') + const { ctx, onSave, onContentChange, flush } = saveContext(editor) + const event = saveEvent() + + expect(handleRichMarkdownSaveShortcut(ctx, event)).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(flush).toHaveBeenCalledTimes(1) + // onSave/onContentChange receive reconciled bytes, not raw *word*. + expect(onSave).toHaveBeenCalledWith('# Title!\n\n_word_\n') + expect(onContentChange).toHaveBeenCalledWith('# Title!\n\n_word_\n') + }) + + it('ignores non-save keystrokes and touches nothing', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const editor = fakeEditor(() => '# Title!\n\n*word*') + const { ctx, onSave, flush } = saveContext(editor) + const event = { ...saveEvent(), key: 'a', code: 'KeyA' } as KeyboardEvent & { + preventDefault: ReturnType + } + + expect(handleRichMarkdownSaveShortcut(ctx, event)).toBe(false) + expect(flush).not.toHaveBeenCalled() + expect(onSave).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts new file mode 100644 index 000000000..d35e9bde4 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts @@ -0,0 +1,59 @@ +import type { MutableRefObject } from 'react' +import type { Editor } from '@tiptap/react' +import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile' + +export type RichMarkdownReconcileRefs = { + /** Current on-disk source bytes; updated to the reconciled output each commit. */ + originalSourceRef: MutableRefObject + /** Canonical serialization of `originalSourceRef` (getMarkdown of the unedited doc). */ + baseCanonicalRef: MutableRefObject + /** Exact bytes last handed to disk; gates the external-change reload. */ + lastCommittedMarkdownRef: MutableRefObject +} + +export type RichMarkdownSerializationCommit = { + /** Bytes to persist to disk (reconciled, or last committed on a torn-down editor). */ + markdown: string + /** False when the editor was torn down before serializing (refs left untouched). */ + didSerialize: boolean +} + +/** + * Single place where reconciliation and ref updates happen for every disk-bound + * serialize site. Computes edited=getMarkdown(), reconciles toward the original + * source style, and advances the refs for the next incremental edit. + */ +export function commitRichMarkdownSerialization( + editor: Editor | null, + refs: RichMarkdownReconcileRefs, + roundTrip: (markdown: string) => string | null +): RichMarkdownSerializationCommit { + let edited: string | undefined + try { + edited = editor?.getMarkdown() + } catch { + // Why: the editor can be destroyed between scheduling and serializing; a + // save/restart flush must never crash here. + edited = undefined + } + if (edited === undefined) { + // Torn-down fallback: reuse the already-reconciled bytes without patching. + return { markdown: refs.lastCommittedMarkdownRef.current, didSerialize: false } + } + + const reconciled = reconcileSerializedMarkdown({ + originalSource: refs.originalSourceRef.current, + baseCanonical: refs.baseCanonicalRef.current, + edited, + roundTrip + }) + + refs.originalSourceRef.current = reconciled + // Why: reconciled ≡ edited semantically, so its canonical form is `edited` + // (also correct in every fallback branch, which returns `edited` verbatim). + refs.baseCanonicalRef.current = edited + // Why: the external-change guard short-circuits on lastCommittedMarkdownRef, so + // it must hold the exact reconciled bytes that reach disk, not the canonical form. + refs.lastCommittedMarkdownRef.current = reconciled + return { markdown: reconciled, didSerialize: true } +} diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts new file mode 100644 index 000000000..9d221d404 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts @@ -0,0 +1,516 @@ +import { describe, expect, it, vi } from 'vitest' +import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile' +import { serializeRichMarkdownForReconcile } from './rich-markdown-reconcile-serializer' +import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' +import type { RichMarkdownImageResolverContext } from './rich-markdown-image-context' + +// A deterministic stand-in for the live editor's canonicalization, covering the +// exact style rewrites #6080 reports. Used both to derive baseCanonical/edited +// and as the injected safety `roundTrip`, so the fake stays self-consistent. +function fakeCanonicalize(md: string): string { + return md + .split('\n') + .map((line) => line.replace(/^(\s*)\* /, '$1- ')) // `* bullet` -> `- bullet` + .join('\n') + .replace(/__([^_]+)__/g, '**$1**') // `__strong__` -> `**strong**` (before emphasis) + .replace(/_([^_]+)_/g, '*$1*') // `_emphasis_` -> `*emphasis*` +} + +const roundTrip = (md: string): string => fakeCanonicalize(md) + +/** Reconcile using the fake canonicalizer for both the base and the safety re-parse. */ +function reconcileWithFake(originalSource: string, edited: string): string { + return reconcileSerializedMarkdown({ + originalSource, + baseCanonical: fakeCanonicalize(originalSource), + edited, + roundTrip + }) +} + +describe('reconcileSerializedMarkdown', () => { + it('preserves untouched non-canonical regions on a 1-char edit', () => { + const originalSource = '# Title\n\n_emphasis_ and __strong__\n\n* one\n* two\n' + const baseCanonical = fakeCanonicalize(originalSource) + const edited = baseCanonical.replace('# Title', '# Title!') + + const reconciled = reconcileWithFake(originalSource, edited) + + // Untouched markup keeps its original bytes. + expect(reconciled).toContain('_emphasis_') + expect(reconciled).toContain('__strong__') + expect(reconciled).toContain('* one') + expect(reconciled).toContain('* two') + // The edited region reflects the change. + expect(reconciled).toContain('# Title!') + // Never re-canonicalized. + expect(reconciled).not.toContain('*emphasis*') + expect(reconciled).not.toContain('- one') + }) + + it('preserves the trailing newline of the original source', () => { + const originalSource = '# H\n\n_word_\n' + const edited = fakeCanonicalize(originalSource).replace('# H', '# H!') + + const reconciled = reconcileWithFake(originalSource, edited) + + expect(reconciled.endsWith('\n')).toBe(true) + expect(reconciled).toContain('_word_') + }) + + it('keeps newly-typed content canonical while preserving the original style', () => { + const originalSource = '_old_\n' + const edited = '*old* and *new*\n' + + const reconciled = reconcileWithFake(originalSource, edited) + + expect(reconciled).toContain('_old_') // original preserved + expect(reconciled).toContain('*new*') // new content stays canonical + }) + + it('returns edited when the source is already canonical (branch 2)', () => { + const originalSource = '*word* here\n' + const edited = '*word* there\n' + + // baseCanonical === originalSource, so there is nothing to preserve. + expect(reconcileWithFake(originalSource, edited)).toBe(edited) + }) + + it('returns the source verbatim when there is no semantic change (branch 1)', () => { + const originalSource = '_word_ and * bullet\n' + const baseCanonical = fakeCanonicalize(originalSource) + + // edited === baseCanonical: e.g. cursor moved, selection changed, no edit. + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited: baseCanonical, + roundTrip + }) + + // Exact bytes, including trailing newline — zero disk churn. + expect(reconciled).toBe(originalSource) + }) + + it('falls back to canonical when either string exceeds the size cap (branch 3)', () => { + const big = 'line of text\n'.repeat(9000) // ~117 KB, above the size cap + const originalSource = `${big}_x_\n` + const edited = `${big}*x* y\n` + + // Non-canonical source + real semantic change, but oversize → canonical fallback. + expect(reconcileWithFake(originalSource, edited)).toBe(edited) + }) + + it('bounds diff work for replacement-heavy edits instead of using the 1s library default', () => { + let now = 0 + const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => { + now += 10 + return now + }) + const baseCanonical = 'a'.repeat(20_000) + const edited = 'b'.repeat(20_000) + + try { + const reconciled = reconcileSerializedMarkdown({ + originalSource: `_${baseCanonical.slice(1)}`, + baseCanonical, + edited, + roundTrip: (markdown) => markdown + }) + + expect(reconciled).toBe(edited) + // The 10ms budget expires almost immediately; the dependency's default + // one-second budget takes ~102 reads with this deterministic clock. + expect(dateNow.mock.calls.length).toBeLessThan(10) + } finally { + dateNow.mockRestore() + } + }) + + it('skips the dependency half-match path when its long seed repeats', () => { + const dateNow = vi.spyOn(Date, 'now') + const baseCanonical = 'ab'.repeat(24_500) + const edited = 'ba'.repeat(24_500) + + try { + const reconciled = reconcileSerializedMarkdown({ + originalSource: `_${baseCanonical.slice(1)}`, + baseCanonical, + edited, + roundTrip: (markdown) => markdown + }) + + expect(reconciled).toBe(edited) + // The dependency reads the clock when diffing; zero reads proves the + // repetitive-input preflight returned before its unbounded half-match scan. + expect(dateNow).not.toHaveBeenCalled() + } finally { + dateNow.mockRestore() + } + }) + + it('still preserves a small edit inside a highly repetitive document', () => { + const dateNow = vi.spyOn(Date, 'now') + const baseCanonical = 'ab'.repeat(24_500) + const editIndex = Math.floor(baseCanonical.length / 2) + const edited = `${baseCanonical.slice(0, editIndex)}X${baseCanonical.slice(editIndex + 1)}` + + try { + const reconciled = reconcileSerializedMarkdown({ + originalSource: `_${baseCanonical.slice(1)}`, + baseCanonical, + edited, + roundTrip: (markdown) => `a${markdown.slice(1)}` + }) + + expect(reconciled).toBe(`_${edited.slice(1)}`) + expect(dateNow).toHaveBeenCalled() + } finally { + dateNow.mockRestore() + } + }) + + it('falls back to canonical when a hunk fails to apply (branch 5)', () => { + const baseCanonical = 'The quick brown fox jumps over the lazy dog every morning.\n' + const edited = 'The quick brown fox LEAPS over the lazy dog every morning.\n' + const originalSource = 'Zzz totally unrelated content sharing nothing at all here.\n' + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip + }) + + expect(reconciled).toBe(edited) + }) + + it('falls back to canonical when the safety re-parse mismatches (branch 6)', () => { + const reconciled = reconcileSerializedMarkdown({ + originalSource: '_a_\n', + baseCanonical: '*a*\n', + edited: '*a* b\n', + // Simulates a fuzzy misplacement whose render differs from `edited`. + roundTrip: () => 'something entirely different' + }) + + expect(reconciled).toBe('*a* b\n') + }) + + it('falls back to canonical when the safety serializer returns null (branch 6)', () => { + const reconciled = reconcileSerializedMarkdown({ + originalSource: '_a_\n', + baseCanonical: '*a*\n', + edited: '*a* b\n', + roundTrip: () => null + }) + + expect(reconciled).toBe('*a* b\n') + }) + + it('preserves non-canonical regions across an incremental two-edit ref chain', () => { + const originalSource = '# Title\n\n_emphasis_\n\n* item\n' + const base = fakeCanonicalize(originalSource) + const edited1 = base.replace('# Title', '# Title A') + + const reconciled1 = reconcileWithFake(originalSource, edited1) + expect(reconciled1).toContain('_emphasis_') + expect(reconciled1).toContain('* item') + + // Simulate the commit helper advancing the refs: originalSource := reconciled1, + // baseCanonical := edited1. The second edit must still preserve the markup. + const edited2 = edited1.replace('# Title A', '# Title AB') + const reconciled2 = reconcileSerializedMarkdown({ + originalSource: reconciled1, + baseCanonical: edited1, + edited: edited2, + roundTrip + }) + + expect(reconciled2).toContain('_emphasis_') + expect(reconciled2).toContain('* item') + expect(reconciled2).toContain('# Title AB') + }) + + it('skips the safety re-parse for a canonical LF file with a trailing newline (branch 2)', () => { + // The common case: file is already canonical but ends in \n, so getMarkdown + // (which strips the trailing newline) never equals it byte-for-byte. This + // must NOT pay the expensive re-parse — the whole point of branch 2. + const originalSource = '# Title\n\n*word*\n\n- one\n- two\n' + const baseCanonical = '# Title\n\n*word*\n\n- one\n- two' // getMarkdown: no trailing \n + const edited = '# Title!\n\n*word*\n\n- one\n- two' + const roundTrip = vi.fn(() => edited) + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip + }) + + expect(roundTrip).not.toHaveBeenCalled() + // Edit applied, and the source's trailing newline is preserved. + expect(reconciled).toBe('# Title!\n\n*word*\n\n- one\n- two\n') + }) + + it('preserves CRLF + trailing newline for a canonical CRLF file without re-parsing (branch 2)', () => { + const originalSource = '# H\r\n\r\n*word*\r\n' + const baseCanonical = '# H\n\n*word*' // getMarkdown: LF, no trailing + const edited = '# H!\n\n*word*' + const roundTrip = vi.fn(() => edited) + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip + }) + + expect(roundTrip).not.toHaveBeenCalled() + expect(reconciled).toBe('# H!\r\n\r\n*word*\r\n') + // No mixed endings. + expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n') + }) + + it('restores CRLF on the oversize fallback so a uniform-CRLF file never flips to LF (branch 3)', () => { + const big = 'line of text\r\n'.repeat(9000) // ~130 KB, above the cap, all CRLF + const originalSource = `${big}_x_\r\n` // non-canonical emphasis, oversize + const editedLf = `${big.replace(/\r\n/g, '\n')}*x* y\n` + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical: `${big.replace(/\r\n/g, '\n')}*x*\n`, + edited: editedLf, + roundTrip: () => null + }) + + // Canonical fallback content, but the source's CRLF endings are kept. + expect(reconciled).toContain('\r\n') + expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n') + }) + + it('restores CRLF on the hunk-apply fallback (branch 5)', () => { + const originalSource = 'Zzz unrelated content sharing nothing here.\r\n' + const baseCanonical = 'The quick brown fox jumps over the lazy dog.\n' + const edited = 'The quick brown fox LEAPS over the lazy dog.\n' + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip + }) + + // Falls back to canonical content, but in the source's CRLF ending. + expect(reconciled).toBe('The quick brown fox LEAPS over the lazy dog.\r\n') + }) + + it('preserves CRLF endings on disk without mixing (branch 4 EOL restore)', () => { + const originalSource = '# H\r\n\r\n_word_\r\n\r\n* one\r\n' + // getMarkdown always emits LF, so base/edited are LF. + const baseCanonical = '# H\n\n*word*\n\n- one\n' + const edited = '# H!\n\n*word*\n\n- one\n' + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip + }) + + expect(reconciled).toContain('\r\n') + expect(reconciled).toContain('_word_') + expect(reconciled).toContain('# H!') + // No mixed endings: stripping CRLF leaves no lone LF. + expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n') + }) + + it('lands a repeated-substring edit on the correct occurrence or falls back', () => { + const originalSource = ['- _alpha_', '- _beta_', '', '- _alpha_', '- _beta_', ''].join('\n') + const baseCanonical = fakeCanonicalize(originalSource) + // Edit only the FIRST "alpha". + const edited = baseCanonical.replace('*alpha*', '*alpha edited*') + + const reconciled = reconcileWithFake(originalSource, edited) + + // Invariant: the reconciled bytes render exactly to `edited` — the change is + // never silently relocated to the wrong occurrence or dropped. + expect(fakeCanonicalize(reconciled).trimEnd()).toBe(edited.trimEnd()) + // Either the source-preserving path landed it, or it cleanly fell back. + const landedInStyle = + reconciled.includes('_alpha edited_') && reconciled.split('_beta_').length === 3 + expect(landedInStyle || reconciled === edited).toBe(true) + }) +}) + +describe('serializeRichMarkdownForReconcile (real editor pipeline)', () => { + const serializerContext = { + htmlSuperscriptLinkContext: createRichMarkdownHtmlSuperscriptLinkContext({ + sourceFilePath: '', + worktreeId: '', + worktreeRoot: null, + sourceOwner: { kind: 'unknown' as const } + }), + imageResolverContext: { + filePath: '', + runtimeContext: undefined + } satisfies RichMarkdownImageResolverContext + } + + const serialize = (md: string): string | null => + serializeRichMarkdownForReconcile(md, serializerContext) + + it('applies normalizeEmptyListItems so empty list items round-trip stably', () => { + // `3. ` immediately before a heading parses as an empty list item; without the + // normalize step the safety re-parse would spuriously mismatch and no-op. + const doc = '3. \n# Heading\n' + const once = serialize(doc) + expect(once).not.toBeNull() + // Idempotent: re-serializing the output is stable (the live editor's steady state). + expect(serialize(once!)?.trimEnd()).toBe(once!.trimEnd()) + }) + + it('reconciles a non-canonical doc end-to-end with the real serializer, preserving style', () => { + const originalSource = '# Title\n\n_emphasis_ text\n' + const baseCanonical = serialize(originalSource)! + const edited = baseCanonical.replace('# Title', '# Title!') + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => serialize(md) + }) + + expect(reconciled).toContain('_emphasis_') // original style preserved + expect(reconciled).toContain('# Title!') // edit applied + // Safety invariant: reconciled renders exactly to the editor's canonical output. + expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd()) + }) + + it('minimizes the diff for the exact #6080 repro (real serializer)', () => { + // The reported case: a 1-char H1 edit must not rewrite untouched markup. + const originalSource = + '# Title\n\n_emphasis_ and __strong__\n\n* one\n* two\n* three\n\n_more emphasis_\n' + const baseCanonical = serialize(originalSource)! + const edited = baseCanonical.replace('# Title', '# Title!') + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => serialize(md) + }) + + // Every untouched non-canonical construct keeps its original bytes. + for (const preserved of [ + '_emphasis_', + '__strong__', + '* one', + '* two', + '* three', + '_more emphasis_' + ]) { + expect(reconciled).toContain(preserved) + } + // The edit landed, and nothing else was re-canonicalized. + expect(reconciled).toContain('# Title!') + expect(reconciled).not.toContain('*emphasis*') + expect(reconciled).not.toContain('- one') + // Safety invariant: reconciled renders exactly to the editor's canonical output. + expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd()) + }) + + it('branch-2 fast path output still renders to the canonical edit (real serializer)', () => { + // Guards the re-parse-skipping fast path: even though branch 2 does NOT run + // the safety re-parse, its output must satisfy the same invariant the full + // path proves — reconciled renders exactly to the editor's canonical output. + const originalSource = '# Title\n\n*emphasis* text\n\n- one\n- two\n' // canonical + trailing \n + const baseCanonical = serialize(originalSource)! + expect(originalSource).not.toBe(baseCanonical) // differs only by the trailing \n + const edited = baseCanonical.replace('# Title', '# Title!') + let roundTripCalls = 0 + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => { + roundTripCalls += 1 + return serialize(md) + } + }) + + expect(roundTripCalls).toBe(0) // took the cheap path + expect(reconciled).toBe('# Title!\n\n*emphasis* text\n\n- one\n- two\n') + expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd()) + }) + + it('does not drop a user-added trailing empty paragraph (branch 2 edited-side guard)', () => { + // getMarkdown keeps `\n\n` for a real trailing empty paragraph, so the fast + // path must NOT strip it: a canonical doc with a single trailing newline + // (branch-2 eligible on the source side) edited to add a trailing empty + // paragraph must persist that block, not silently drop it on save. + const originalSource = '# H\n\ntext\n' + const baseCanonical = serialize(originalSource)! // '# H\n\ntext' + const edited = '# H\n\ntext\n\n' // user pressed Enter at EOF + let roundTripCalls = 0 + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => { + roundTripCalls += 1 + return serialize(md) + } + }) + + expect(roundTripCalls).toBeGreaterThan(0) // deferred to the safety-verified path + // Exact reload equality (NOT trimEnd): the empty paragraph survives. + expect(serialize(reconciled)).toBe(serialize(edited)) + }) + + it('does not introduce a spurious   when a trailing-blank-line doc is edited (branch 2 guard)', () => { + // A canonical heading-ending doc with 3 trailing blank lines: the fast path + // must NOT preserve that long trailing run while skipping the re-parse, or a + // reload materializes a literal   paragraph (a content change). The + // >1-trailing-newline guard defers this to the safety-checked path. + const originalSource = '# Notes\n\nSome text here.\n\n## TODO\n\n\n\n' + const baseCanonical = serialize(originalSource)! + const edited = baseCanonical.replace('## TODO', '## TODO\n\nA new note.') + let roundTripCalls = 0 + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => { + roundTripCalls += 1 + return serialize(md) + } + }) + + expect(roundTripCalls).toBeGreaterThan(0) // took the safety-verified path + expect(reconciled).not.toContain(' ') + // Safety invariant: reconciled renders exactly to the editor's canonical edit. + expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd()) + }) + + it('falls back cleanly when an empty-list doc cannot be source-preserved', () => { + // Combines an empty list item with non-canonical emphasis; whatever the fuzzy + // patch does, the output must render to `edited` (no corruption). + const originalSource = '_lead_\n\n3. \n# Heading\n' + const baseCanonical = serialize(originalSource)! + const edited = baseCanonical.replace('Heading', 'Heading!') + + const reconciled = reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip: (md) => serialize(md) + }) + + expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd()) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts new file mode 100644 index 000000000..a1bdf3997 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts @@ -0,0 +1,195 @@ +import { + applyPatches, + cleanupEfficiency, + cleanupSemantic, + makeDiff, + makePatches +} from '@sanity/diff-match-patch' + +// Why: bound the safety re-parse cost — it builds a throwaway TipTap editor per +// commit, whose parse time scales with document length (UTF-16 code units, the +// unit `.length` returns and the closest cheap proxy for parse cost — not UTF-8 +// bytes). Measured ~50-67ms at the cap on fast HW (higher on low-end/SSH), under +// the 300ms serialize debounce; a higher cap risks a main-thread stall on +// slow/SSH hosts. Above the cap we use today's canonical output (no regression). +const RECONCILE_SIZE_CAP_CODE_UNITS = 50_000 + +// Why: diff-match-patch defaults to a one-second search, which freezes the +// renderer on replacement-heavy paste/edit paths. A timed-out coarse diff is +// still safe because the round-trip proof below rejects any bad placement. +const RECONCILE_DIFF_TIMEOUT_SECONDS = 0.01 + +export type ReconcileSerializedMarkdownParams = { + /** Current on-disk source bytes (possibly non-canonical, possibly CRLF). */ + originalSource: string + /** Canonical serialization of `originalSource` (what getMarkdown returns unedited). */ + baseCanonical: string + /** Canonical serialization after the user's edit (getMarkdown, always LF). */ + edited: string + /** + * Re-serializes reconciled bytes through the live editor's pipeline. Injected + * so the reconcile logic is unit-testable without a DOM. Returns null when the + * throwaway serializer fails (treated as a safety mismatch → canonical fallback). + */ + roundTrip: (markdown: string) => string | null +} + +/** + * Carries the user's edit into the original source style so untouched regions + * keep their non-canonical bytes. Falls back to the canonical `edited` output + * (today's behavior) whenever the source-preserving transform cannot be proven + * render-equivalent — so it can never corrupt or relocate content. + */ +export function reconcileSerializedMarkdown({ + originalSource, + baseCanonical, + edited, + roundTrip +}: ReconcileSerializedMarkdownParams): string { + // Branch 1: no semantic change vs the unedited doc → preserve the original + // bytes verbatim (incl. trailing newline / EOL); zero disk churn. + if (edited === baseCanonical) { + return originalSource + } + + // Work in LF space: getMarkdown emits LF while `originalSource` may be CRLF, so + // patching LF-context hunks against raw CRLF would fuzzy-match poorly and mix + // endings. The detected EOL is restored on EVERY non-verbatim return below so a + // uniform-CRLF file never silently flips to LF, even on a canonical fallback. + const eol = detectDominantEol(originalSource) + const originalSourceLf = toLf(originalSource) + const baseLf = toLf(baseCanonical) + const editedLf = toLf(edited) + + // Branch 2: the source body equals the canonical `edited` body apart from its + // EOL/trailing newlines, so nothing non-canonical remains to preserve. Skip the + // expensive safety re-parse and carry the source's EOL + trailing newline onto + // the edit. Two guards keep the re-parse skip provably drift-free: + // - source trailing run ≤ 1 newline: a longer trailing-blank run can + // materialize a spurious ` ` empty paragraph on reload when the edit + // turns the EOF block into a paragraph; + // - `edited` has no trailing newline: getMarkdown keeps a trailing `\n\n` for a + // real trailing empty paragraph, so stripping it here would silently drop a + // block the user added. + // Both cases (rare) defer to the branch-6-verified path below instead. + const originalTrailingNewlines = originalSourceLf.match(/\n+$/)?.[0] ?? '' + if ( + originalTrailingNewlines.length <= 1 && + !editedLf.endsWith('\n') && + stripTrailingNewlines(originalSourceLf) === stripTrailingNewlines(baseLf) + ) { + return restoreEol(editedLf + originalTrailingNewlines, eol) + } + + // Branch 3: oversize → bounded-cost canonical fallback (today's behavior). + if ( + Math.max(originalSource.length, baseCanonical.length, edited.length) > + RECONCILE_SIZE_CAP_CODE_UNITS + ) { + return restoreEol(editedLf, eol) + } + + // Branch 4: run the divergent-base patch entirely in LF space. + // Why: the dependency's half-match accelerator ignores its diff deadline and + // can spend 100ms+ scanning repeated long seeds. Returning canonical is safer + // than entering that unbounded path for highly repetitive replacements. + if (hasRepeatedHalfMatchSeed(baseLf, editedLf)) { + return restoreEol(editedLf, eol) + } + let diffs = makeDiff(baseLf, editedLf, { + checkLines: true, + timeout: RECONCILE_DIFF_TIMEOUT_SECONDS + }) + // Match makePatches(textA, textB)'s cleanup behavior while supplying the + // bounded diff ourselves instead of inheriting the library's 1s timeout. + if (diffs.length > 2) { + diffs = cleanupSemantic(diffs) + diffs = cleanupEfficiency(diffs) + } + const patches = makePatches(baseLf, diffs) + const [reconciledLf, results] = applyPatches(patches, originalSourceLf) + + // Branch 5: a hunk that failed to locate in the non-canonical source → the + // fuzzy match is unreliable here, so fall back to canonical. + if (results.some((applied) => !applied)) { + return restoreEol(editedLf, eol) + } + + // Branch 6: prove the reconciled bytes render-equal the editor's document. + // Any fuzzy misplacement (e.g. onto a repeated substring) changes canonical + // output and is caught here → canonical fallback. Compared under norm so only + // style/EOL/EOF differences are tolerated. + const reparsed = roundTrip(reconciledLf) + if (reparsed === null || normalizeForSafety(reparsed) !== normalizeForSafety(editedLf)) { + return restoreEol(editedLf, eol) + } + + // Restore the detected EOL as the final step so reconciled CRLF stays CRLF. + return restoreEol(reconciledLf, eol) +} + +function stripTrailingNewlines(lfText: string): string { + return lfText.replace(/\n+$/, '') +} + +function detectDominantEol(text: string): '\n' | '\r\n' { + const totalLf = (text.match(/\n/g) ?? []).length + const crlf = (text.match(/\r\n/g) ?? []).length + const lfOnly = totalLf - crlf + return crlf > 0 && crlf >= lfOnly ? '\r\n' : '\n' +} + +function toLf(text: string): string { + return text.replace(/\r\n/g, '\n') +} + +function restoreEol(lfText: string, eol: '\n' | '\r\n'): string { + // lfText is pure LF, so a blind LF→CRLF replace produces no mixed endings. + return eol === '\r\n' ? lfText.replace(/\n/g, '\r\n') : lfText +} + +function normalizeForSafety(text: string): string { + // Why: both operands are canonical getMarkdown outputs, so an equal-render + // reconciliation is byte-identical here apart from EOL. Compare exactly (only + // CRLF-normalized) — a trailing `\n\n` empty paragraph is semantic, so a lenient + // trimEnd would mask exactly the trailing-block drift branch 6 must catch. + return text.replace(/\r\n/g, '\n') +} + +function hasRepeatedHalfMatchSeed(textA: string, textB: string): boolean { + // Match diff-match-patch's own prefix/suffix trimming so a small edit in a + // repetitive document is not mistaken for a repetitive replacement. + const minimumLength = Math.min(textA.length, textB.length) + let prefixLength = 0 + while ( + prefixLength < minimumLength && + textA.charCodeAt(prefixLength) === textB.charCodeAt(prefixLength) + ) { + prefixLength += 1 + } + let suffixLength = 0 + while ( + suffixLength < minimumLength - prefixLength && + textA.charCodeAt(textA.length - suffixLength - 1) === + textB.charCodeAt(textB.length - suffixLength - 1) + ) { + suffixLength += 1 + } + const middleA = textA.slice(prefixLength, textA.length - suffixLength) + const middleB = textB.slice(prefixLength, textB.length - suffixLength) + const longText = middleA.length > middleB.length ? middleA : middleB + const shortText = middleA.length > middleB.length ? middleB : middleA + if (longText.length < 4 || shortText.length * 2 < longText.length) { + return false + } + + const seedLength = Math.floor(longText.length / 4) + for (const start of [Math.ceil(longText.length / 4), Math.ceil(longText.length / 2)]) { + const seed = longText.slice(start, start + seedLength) + const firstMatch = shortText.indexOf(seed) + if (firstMatch !== -1 && shortText.includes(seed, firstMatch + 1)) { + return true + } + } + return false +} diff --git a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts index 40822dc7b..b9675d5e3 100644 --- a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts @@ -60,6 +60,9 @@ function createContext(editor: Editor): KeyHandlerContext { editorRef: { current: editor }, rootRef: { current: null }, lastCommittedMarkdownRef: { current: '' }, + originalSourceRef: { current: '' }, + baseCanonicalRef: { current: '' }, + reconcileRoundTripRef: { current: () => null }, onContentChangeRef: { current: vi.fn() }, onSaveRef: { current: vi.fn() }, isEditingLinkRef: { current: false }, diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts new file mode 100644 index 000000000..c136095b8 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import { Editor } from '@tiptap/core' +import { useRichMarkdownProgrammaticSync } from './useRichMarkdownProgrammaticSync' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { + createRichMarkdownEditorCodec, + type RichMarkdownEditorCodec +} from './rich-markdown-source-transport' +import { + createRichMarkdownHtmlSuperscriptLinkContext, + type RichMarkdownHtmlSuperscriptLinkContext +} from './rich-markdown-html-superscript-link-context' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' + +function buildEditor( + codec: RichMarkdownEditorCodec, + htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext, + markdown: string +): Editor { + return new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ + codec, + htmlSuperscriptLinks: true, + htmlSuperscriptLinkContext + }), + content: encodeRawMarkdownHtmlForRichEditor(markdown, codec, { htmlSuperscriptLinks: true }), + contentType: 'markdown' + }) +} + +describe('useRichMarkdownProgrammaticSync external-reload baseline adoption (#6080)', () => { + it('adopts externally-canonicalized bytes as the reconciliation baseline without a reload', () => { + const codec = createRichMarkdownEditorCodec() + const htmlSuperscriptLinkContext = createRichMarkdownHtmlSuperscriptLinkContext({ + sourceFilePath: '', + worktreeId: '', + worktreeRoot: null, + sourceOwner: { kind: 'unknown' as const } + }) + // The editor currently shows emphasis loaded from the original `_old_` source; + // its canonical (getMarkdown) form is `*old*` with no trailing newline. + const canonical = '# T\n\n*old*' + const editor = buildEditor(codec, htmlSuperscriptLinkContext, canonical) + expect(editor.getMarkdown()).toBe(canonical) + + // Refs still reflect the ORIGINAL non-canonical source bytes on disk. + const lastCommittedMarkdownRef = { current: '# T\n\n_old_' } + const originalSourceRef = { current: '# T\n\n_old_' } + const baseCanonicalRef = { current: canonical } + const isApplyingProgrammaticUpdateRef = { current: false } + + try { + // An external tool rewrites disk to the canonical bytes (same semantics, + // new byte-level style). The doc already renders them, so no reload — but + // the reconciliation baseline must still be refreshed to the new bytes. + renderHook(() => + useRichMarkdownProgrammaticSync({ + codec, + content: canonical, + docLinkMenuSetter: vi.fn(), + editor, + fileId: 'f1', + filePath: '/repo/README.md', + isApplyingProgrammaticUpdateRef, + lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + markdownDocuments: undefined, + rootRef: { current: null }, + runtimeEnvironmentId: null, + settings: null, + slashMenuSetter: vi.fn(), + worktreeId: 'w1', + worktreeRoot: null + }) + ) + + // Without the fix these stay stale at `_old_`, and the next edit would + // rewrite the file back to `_old_`, silently undoing the external change. + expect(lastCommittedMarkdownRef.current).toBe(canonical) + expect(originalSourceRef.current).toBe(canonical) + expect(baseCanonicalRef.current).toBe(canonical) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts index 2086a39f2..92d1c4308 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -22,6 +22,8 @@ type RichMarkdownProgrammaticSyncOptions = { filePath: string isApplyingProgrammaticUpdateRef: MutableRefObject lastCommittedMarkdownRef: MutableRefObject + originalSourceRef: MutableRefObject + baseCanonicalRef: MutableRefObject markdownDocuments?: MarkdownDocument[] rootRef: MutableRefObject runtimeEnvironmentId?: string | null @@ -46,6 +48,8 @@ export function useRichMarkdownProgrammaticSync({ filePath, isApplyingProgrammaticUpdateRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, markdownDocuments, rootRef, runtimeEnvironmentId, @@ -101,12 +105,29 @@ export function useRichMarkdownProgrammaticSync({ if (!editor) { return } - if (content === lastCommittedMarkdownRef.current || editor.getMarkdown() === content) { + if (content === lastCommittedMarkdownRef.current) { + return + } + if (editor.getMarkdown() === content) { + // Why: disk bytes changed but already render-equal to the current doc (e.g. + // an external tool canonicalized byte-level style). Skip the disruptive + // reload, but adopt the new bytes as the reconciliation baseline so the next + // edit patches onto the fresh source, not the stale pre-change source. + lastCommittedMarkdownRef.current = content + originalSourceRef.current = content + baseCanonicalRef.current = content return } isApplyingProgrammaticUpdateRef.current = true try { - applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef, codec) + applyExternalRichMarkdownContent( + editor, + content, + lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + codec + ) } finally { isApplyingProgrammaticUpdateRef.current = false } @@ -120,6 +141,8 @@ export function useRichMarkdownProgrammaticSync({ fileId, isApplyingProgrammaticUpdateRef, lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, rootRef, slashMenuSetter ]) @@ -129,6 +152,8 @@ function applyExternalRichMarkdownContent( editor: Editor, content: string, lastCommittedMarkdownRef: MutableRefObject, + originalSourceRef: MutableRefObject, + baseCanonicalRef: MutableRefObject, codec: RichMarkdownEditorCodec ): void { try { @@ -145,6 +170,10 @@ function applyExternalRichMarkdownContent( // external content, matching onCreate's single-paragraph reflow behavior. normalizeEmptyListItems(editor) lastCommittedMarkdownRef.current = content + // Why: reset the reconciliation baseline to the freshly loaded external bytes + // so subsequent edits preserve the new source style, not the pre-reload one. + originalSourceRef.current = content + baseCanonicalRef.current = editor.getMarkdown() if (hadFocus) { const docSize = editor.state.doc.content.size editor diff --git a/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts b/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts new file mode 100644 index 000000000..556759a72 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts @@ -0,0 +1,42 @@ +import { useRef, type MutableRefObject } from 'react' +import { useAppStore } from '@/store' +import { serializeRichMarkdownForReconcile } from './rich-markdown-reconcile-serializer' +import { createRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' + +type ReconcileRoundTripParams = { + htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext + filePath: string + runtimeEnvironmentId?: string | null + worktreeId: string + worktreeRoot: string | null +} + +/** + * Exposes the reconciliation safety serializer as a render-updated ref. It + * mirrors the live editor's codec/link/image context so the step-6 re-parse + * matches getMarkdown(), and only runs on commit — so rebuilding the closure + * each render is cheap and always reflects the latest context. + */ +export function useRichMarkdownReconcileRoundTrip({ + htmlSuperscriptLinkContext, + filePath, + runtimeEnvironmentId, + worktreeId, + worktreeRoot +}: ReconcileRoundTripParams): MutableRefObject<(markdown: string) => string | null> { + const settings = useAppStore((s) => s.settings) + const ref = useRef<(markdown: string) => string | null>(() => null) + ref.current = (markdown) => + serializeRichMarkdownForReconcile(markdown, { + htmlSuperscriptLinkContext, + imageResolverContext: createRichMarkdownImageResolverContext({ + filePath, + runtimeEnvironmentId, + settings, + worktreeId, + worktreeRoot + }) + }) + return ref +}