From cbe64383dc3dfce6090cd4740b3ecb9450517cf4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:19:34 -0700 Subject: [PATCH] Reuse source-line calculations for Markdown review selections (#25173) * Reuse source-line calculations for Markdown review selections * Use typed editor probes in review selection performance coverage --- .../rich-markdown-comment-blocks.test.ts | 216 ++++++++++++++++++ .../editor/rich-markdown-comment-blocks.ts | 82 +++++++ .../rich-markdown-review-annotations.ts | 76 +----- .../rich-markdown-review-note-positioning.ts | 4 +- ...ich-markdown-review-rail-benchmark.test.ts | 3 +- .../rich-markdown-review-rail-blocks.ts | 32 +-- ...kdown-review-selection-performance.spec.ts | 209 +++++++++++++++++ 7 files changed, 524 insertions(+), 98 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-comment-blocks.ts create mode 100644 tests/e2e/markdown-review-selection-performance.spec.ts diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts new file mode 100644 index 00000000000..a416d6719f0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts @@ -0,0 +1,216 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Editor } from '@tiptap/core' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { + buildRichMarkdownCommentBlocks, + getRichMarkdownCommentBlocks +} from './rich-markdown-comment-blocks' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { + createRichMarkdownHtmlSuperscriptLinkContext, + type RichMarkdownHtmlSuperscriptLinkContext +} from './rich-markdown-html-superscript-link-context' +import { + getRichMarkdownAnnotationHighlightRanges, + getRichMarkdownAnnotationHighlightRangesForComment, + getRichMarkdownAnnotationTarget, + getRichMarkdownCommentAtPos +} from './rich-markdown-review-annotations' +import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks' + +const editors: Editor[] = [] +const SOURCE = 'First paragraph\n\n```ts\none\n\ntwo\n```\n\nLast paragraph' + +function createEditor(source = SOURCE, context?: RichMarkdownHtmlSuperscriptLinkContext) { + const root = document.createElement('div') + document.body.append(root) + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: root, + extensions: createRichMarkdownExtensions({ + codec, + htmlSuperscriptLinks: Boolean(context), + htmlSuperscriptLinkContext: context + }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec, { + htmlSuperscriptLinks: Boolean(context) + }), + contentType: 'markdown' + }) + editors.push(editor) + editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false)) + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 600, 400)) + return { editor, root } +} + +function selectLastParagraph(editor: Editor, characters = 4) { + let from: number | undefined + editor.state.doc.forEach((node, offset) => { + if (node.textContent === 'Last paragraph') { + from = offset + 1 + } + }) + if (from === undefined) { + throw new Error('Last paragraph missing') + } + editor.commands.setTextSelection({ from, to: from + characters }) + const paragraph = Array.from(editor.view.dom.querySelectorAll('p')).find( + (element) => element.textContent === 'Last paragraph' + ) + const text = paragraph?.firstChild + if (!(text instanceof Text)) { + throw new Error('Last paragraph text missing') + } + const range = document.createRange() + range.setStart(text, 0) + range.setEnd(text, characters) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + return from +} + +function comment(lineNumber = 29): DiffComment { + return { + id: 'note', + worktreeId: 'workspace', + filePath: 'notes.md', + source: 'markdown', + lineNumber, + selectedText: 'Last', + body: 'Review', + createdAt: 1, + side: 'modified' + } +} + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.destroy() + } + window.getSelection()?.removeAllRanges() + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('Markdown review source block reuse', () => { + it('preserves source lines when the document interaction host changes', () => { + const context = createRichMarkdownHtmlSuperscriptLinkContext({ + sourceFilePath: '/repo/notes.md', + worktreeId: 'workspace', + worktreeRoot: '/repo', + sourceOwner: { kind: 'local' } + }) + const { editor } = createEditor( + 'First [1]\n\nLast paragraph', + context + ) + const blocks = getRichMarkdownCommentBlocks(editor) + const doc = editor.state.doc + const serialize = vi.spyOn(editor.markdown!, 'serialize') + const instrumentedBlocks = getRichMarkdownCommentBlocks(editor) + serialize.mockClear() + context.update({ + sourceFilePath: '/remote/notes.md', + worktreeId: 'remote-workspace', + worktreeRoot: '/remote', + sourceOwner: { kind: 'ssh', connectionId: 'connection' } + }) + expect(editor.state.doc).toBe(doc) + expect(getRichMarkdownCommentBlocks(editor)).toBe(instrumentedBlocks) + expect(serialize).not.toHaveBeenCalled() + serialize.mockRestore() + expect(buildRichMarkdownCommentBlocks(editor)).toEqual(blocks) + }) + + it('shares one source map across highlights, comment clicks, selection targets and the rail', () => { + const { editor, root } = createEditor() + const from = selectLastParagraph(editor) + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + const json = vi.spyOn(editor, 'getJSON') + const note = comment() + const expected = [{ from, to: from + 4 }] + + expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + expect(json).toHaveBeenCalledTimes(1) + serialize.mockClear() + json.mockClear() + + for (let index = 0; index < 20; index++) { + expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) + expect(getRichMarkdownAnnotationHighlightRangesForComment(editor, note, 20)).toEqual(expected) + expect(getRichMarkdownCommentAtPos(editor, [note], 20, from + 2)).toBe(note) + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + from, + to: from + 4, + selectedText: 'Last', + lineNumber: 9 + }) + expect(getRichMarkdownReviewRailBlocks(editor)).toBe(getRichMarkdownCommentBlocks(editor)) + } + expect(serialize).not.toHaveBeenCalled() + expect(json).not.toHaveBeenCalled() + }) + + it('updates selected text and viewport geometry without rebuilding source lines', () => { + const { editor, root } = createEditor() + const rect = vi.spyOn(Range.prototype, 'getBoundingClientRect') + rect.mockReturnValue(new DOMRect(10, 20, 80, 20)) + selectLastParagraph(editor) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + selectedText: 'Last', + buttonTop: 48, + lineNumber: 9 + }) + serialize.mockClear() + const doc = editor.state.doc + selectLastParagraph(editor, 9) + rect.mockReturnValue(new DOMRect(10, 120, 140, 20)) + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + selectedText: 'Last para', + buttonTop: 148, + lineNumber: 9 + }) + expect(editor.state.doc).toBe(doc) + expect(serialize).not.toHaveBeenCalled() + }) + + it('rebuilds source lines after editing a multiline block and undoing it', () => { + const { editor, root } = createEditor() + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + let codeFrom: number | undefined + editor.state.doc.forEach((node, offset) => { + if (node.type.name === 'codeBlock') { + codeFrom = offset + 1 + } + }) + if (codeFrom === undefined) { + throw new Error('Code block missing') + } + editor.view.dispatch(editor.state.tr.insertText('new\n', codeFrom)) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(10) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + expect( + getRichMarkdownCommentAtPos(editor, [comment(30)], 20, editor.state.selection.from) + ).toEqual(comment(30)) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + serialize.mockClear() + + expect(editor.commands.undo()).toBe(true) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + serialize.mockClear() + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + expect(serialize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts new file mode 100644 index 00000000000..261b982f0bd --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts @@ -0,0 +1,82 @@ +import type { Editor, JSONContent } from '@tiptap/core' +import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' + +export type RichMarkdownCommentBlock = { + key: string + startLine: number + endLine: number + from: number + to: number +} + +function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { + return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() +} + +export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { + const jsonContent = editor.getJSON().content ?? [] + const blocks: RichMarkdownCommentBlock[] = [] + let nextLine = 1 + let previousNodeJson: JSONContent | null = null + let previousNodeLineCount = 0 + + editor.state.doc.forEach((node, nodeOffset, index) => { + const nodeJson = jsonContent[index] + if (!nodeJson) { + return + } + const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) + const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) + if (previousNodeJson) { + const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) + const separatorLineCount = Math.max( + 0, + countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount + ) + nextLine += separatorLineCount + } + const startLine = nextLine + const endLine = Math.max(startLine, startLine + nodeLineCount - 1) + const from = nodeOffset + 1 + blocks.push({ + key: `${index}:${startLine}-${endLine}`, + startLine, + endLine, + from, + to: from + Math.max(0, node.nodeSize - 1) + }) + nextLine = endLine + 1 + previousNodeJson = nodeJson + previousNodeLineCount = nodeLineCount + }) + + if (blocks.length === 0) { + blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) + } + + return blocks +} + +type RichMarkdownCommentBlocksSnapshot = { + doc: Editor['state']['doc'] + markdown: Editor['markdown'] + serialize: NonNullable['serialize'] | undefined + blocks: readonly RichMarkdownCommentBlock[] +} + +// Keep only the current document per editor; scrolling changes geometry, not source lines. +const blocksByEditor = new WeakMap() + +export function getRichMarkdownCommentBlocks(editor: Editor): readonly RichMarkdownCommentBlock[] { + const doc = editor.state.doc + const markdown = editor.markdown + const serialize = markdown?.serialize + const cached = blocksByEditor.get(editor) + if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) { + return cached.blocks + } + + const blocks = buildRichMarkdownCommentBlocks(editor) + blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) + return blocks +} diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts index 90bd170a5c1..f466a38b321 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts @@ -1,6 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' import type { Editor } from '@tiptap/react' -import type { JSONContent } from '@tiptap/core' import type { DiffComment } from '../../../../shared/diff-comment-types' import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight' import { @@ -10,7 +9,14 @@ import { import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges' import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map' -import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' +import { + getRichMarkdownCommentBlocks, + type RichMarkdownCommentBlock +} from './rich-markdown-comment-blocks' +export { + buildRichMarkdownCommentBlocks, + type RichMarkdownCommentBlock +} from './rich-markdown-comment-blocks' export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24 @@ -22,14 +28,6 @@ const RICH_MARKDOWN_ANNOTATION_POPOVER_WIDTH_PX = 420 const RICH_MARKDOWN_ANNOTATION_POPOVER_RIGHT_OFFSET_PX = 24 const RICH_MARKDOWN_ANNOTATION_POPOVER_MIN_HEIGHT_PX = 220 -export type RichMarkdownCommentBlock = { - key: string - startLine: number - endLine: number - from: number - to: number -} - export type RichMarkdownComposerState = { lineNumber: number startLine?: number @@ -45,54 +43,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & { buttonLeft: number } -function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { - return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() -} - -export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { - const jsonContent = editor.getJSON().content ?? [] - const blocks: RichMarkdownCommentBlock[] = [] - let nextLine = 1 - let previousNodeJson: JSONContent | null = null - let previousNodeLineCount = 0 - - editor.state.doc.forEach((node, nodeOffset, index) => { - const nodeJson = jsonContent[index] - if (!nodeJson) { - return - } - const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) - const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) - if (previousNodeJson) { - const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) - const separatorLineCount = Math.max( - 0, - countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount - ) - nextLine += separatorLineCount - } - const startLine = nextLine - const endLine = Math.max(startLine, startLine + nodeLineCount - 1) - const from = nodeOffset + 1 - blocks.push({ - key: `${index}:${startLine}-${endLine}`, - startLine, - endLine, - from, - to: from + Math.max(0, node.nodeSize - 1) - }) - nextLine = endLine + 1 - previousNodeJson = nodeJson - previousNodeLineCount = nodeLineCount - }) - - if (blocks.length === 0) { - blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) - } - - return blocks -} - export function clampRichMarkdownAnnotationTarget( editor: Editor, target: RichMarkdownAnnotationTarget @@ -122,8 +72,7 @@ export function getRichMarkdownAnnotationHighlightRanges( if (comments.length === 0) { return [] } - // Why once: block resolution re-serializes the doc; per comment it was O(n*doc). - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) return comments.flatMap((comment) => getRichMarkdownAnnotationHighlightRangesForComment( editor, @@ -138,10 +87,9 @@ export function getRichMarkdownAnnotationHighlightRangesForComment( editor: Editor, comment: DiffComment, markdownSourceLineOffset: number, - // Why optional: callers looping over comments pass one shared build. prebuiltBlocks?: readonly RichMarkdownCommentBlock[] ): RichMarkdownAnnotationHighlightRange[] { - const blocks = prebuiltBlocks ?? buildRichMarkdownCommentBlocks(editor) + const blocks = prebuiltBlocks ?? getRichMarkdownCommentBlocks(editor) const selectedText = comment.selectedText?.trim() if (!selectedText) { return [] @@ -173,7 +121,7 @@ export function getRichMarkdownCommentAtPos( if (comments.length === 0) { return null } - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) return ( comments.find((comment) => getRichMarkdownAnnotationHighlightRangesForComment( @@ -216,7 +164,7 @@ export function getRichMarkdownCommentAnchorTop( } function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerState { - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) const { from, to, empty } = editor.state.selection const selectedBlocks = empty ? blocks.filter((block) => block.from <= from && from <= block.to) diff --git a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts index 4fdc12a51b9..b7cc8bc63e2 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts @@ -1,7 +1,7 @@ import type { Editor } from '@tiptap/react' import type { DiffComment } from '../../../../shared/diff-comment-types' import { getRichMarkdownCommentAnchorTop } from './rich-markdown-review-annotations' -import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks' +import { getRichMarkdownCommentBlocks } from './rich-markdown-comment-blocks' import { stackRichMarkdownReviewNotePositions, type RichMarkdownReviewNotePosition @@ -21,7 +21,7 @@ export function measureRichMarkdownReviewNotePositions({ markdownSourceLineOffset }: MeasureRichMarkdownReviewNotePositionsOptions): RichMarkdownReviewNotePosition[] { const containerRect = container.getBoundingClientRect() - const blocks = getRichMarkdownReviewRailBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) const nextPositions = markdownComments .map((comment): RichMarkdownReviewNotePosition | null => { const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset) diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts index 7090769f71f..d64fcc667b8 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts @@ -48,7 +48,8 @@ function measureBaseline({ block, containerRect, container.scrollTop, - markdownSourceLineOffset + markdownSourceLineOffset, + buildRichMarkdownCommentBlocks(editor) ) return top === null ? null : { comment, top } }) diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts index 35dd1bb9eb5..9cb8bf0b64d 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts @@ -1,31 +1 @@ -import type { Editor } from '@tiptap/core' -import { - buildRichMarkdownCommentBlocks, - type RichMarkdownCommentBlock -} from './rich-markdown-review-annotations' - -type ReviewRailBlocks = { - doc: Editor['state']['doc'] - markdown: Editor['markdown'] - serialize: NonNullable['serialize'] | undefined - blocks: readonly RichMarkdownCommentBlock[] -} - -// Keep only the current document per editor; scrolling changes geometry, not source lines. -const blocksByEditor = new WeakMap() - -export function getRichMarkdownReviewRailBlocks( - editor: Editor -): readonly RichMarkdownCommentBlock[] { - const doc = editor.state.doc - const markdown = editor.markdown - const serialize = markdown?.serialize - const cached = blocksByEditor.get(editor) - if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) { - return cached.blocks - } - - const blocks = buildRichMarkdownCommentBlocks(editor) - blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) - return blocks -} +export { getRichMarkdownCommentBlocks as getRichMarkdownReviewRailBlocks } from './rich-markdown-comment-blocks' diff --git a/tests/e2e/markdown-review-selection-performance.spec.ts b/tests/e2e/markdown-review-selection-performance.spec.ts new file mode 100644 index 00000000000..a9ee0035599 --- /dev/null +++ b/tests/e2e/markdown-review-selection-performance.spec.ts @@ -0,0 +1,209 @@ +import type { Locator, Page } from '@stablyai/playwright-test' +import type { Editor, JSONContent } from '@tiptap/core' +import type { MarkdownManager } from '@tiptap/markdown' +import { expect, test } from './helpers/orca-app' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' + +const PARAGRAPH_COUNT = Number(process.env.ORCA_MARKDOWN_REVIEW_PERF_PARAGRAPHS ?? '500') +const targetIndex = Math.floor(PARAGRAPH_COUNT / 2) +const paragraph = (index: number) => + `Paragraph ${index}. Ordinary editing and selection of plain prose.` +const SOURCE = Array.from({ length: PARAGRAPH_COUNT }, (_, index) => paragraph(index)).join('\n\n') + +type ReviewSelectionMetrics = { + calls: number + jsonCalls: number + serializeMs: number + jsonMs: number + frameGaps: number[] +} + +type ReviewSelectionProbe = { + snapshot: () => ReviewSelectionMetrics + reset: () => void + restore: () => void +} + +type PageRichMarkdownReviewEditorElement = HTMLElement & { + editor?: Editor & { markdown?: MarkdownManager } + __reviewSelectionProbe?: ReviewSelectionProbe +} + +async function frames(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) +} + +async function measure(editor: Locator, reset = false): Promise { + return editor.evaluate((element, reset) => { + const editorElement = + element.closest('.rich-markdown-editor') + const probe = editorElement?.__reviewSelectionProbe + if (!probe) { + throw new Error('Review selection probe missing') + } + if (reset) { + probe.reset() + } + return probe.snapshot() + }, reset) +} + +test('selection and scrolling reuse Markdown review source lines', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + test.setTimeout(180_000) + expect(Buffer.byteLength(SOURCE)).toBeLessThan(600 * 1024) + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-review-perf', + 'review-selection', + testInfo.workerIndex, + SOURCE + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await expect(editor.locator('p')).toHaveCount(PARAGRAPH_COUNT, { timeout: 60_000 }) + await editor.evaluate((element) => { + const editorElement = + element.closest('.rich-markdown-editor') + const instance = editorElement?.editor + if (!editorElement || !instance) { + throw new Error('Editor unavailable') + } + const markdown = instance.markdown + if (!markdown) { + throw new Error('Markdown manager missing') + } + const serialize = markdown.serialize + const getJSON = instance.getJSON + let calls = 0 + let jsonCalls = 0 + let serializeMs = 0 + let jsonMs = 0 + let frameGaps: number[] = [] + let previousFrame: number | null = null + let frameId = 0 + const tick = (now: number) => { + if (previousFrame !== null) { + frameGaps.push(now - previousFrame) + } + previousFrame = now + frameId = requestAnimationFrame(tick) + } + frameId = requestAnimationFrame(tick) + markdown.serialize = (content: JSONContent): string => { + const start = performance.now() + try { + calls++ + return serialize.call(markdown, content) + } finally { + serializeMs += performance.now() - start + } + } + instance.getJSON = (): ReturnType => { + const start = performance.now() + try { + jsonCalls++ + return getJSON.call(instance) + } finally { + jsonMs += performance.now() - start + } + } + editorElement.__reviewSelectionProbe = { + snapshot: () => ({ calls, jsonCalls, serializeMs, jsonMs, frameGaps }), + reset: () => { + calls = 0 + jsonCalls = 0 + serializeMs = 0 + jsonMs = 0 + frameGaps = [] + previousFrame = null + }, + restore: () => { + cancelAnimationFrame(frameId) + markdown.serialize = serialize + instance.getJSON = getJSON + } + } + }) + try { + const target = editor.getByText(paragraph(targetIndex), { exact: true }) + await target.evaluate((element) => element.scrollIntoView({ block: 'center' })) + const point = await target.evaluate((element) => { + const text = element.firstChild + if (!(text instanceof Text)) { + throw new Error('Paragraph text missing') + } + const range = document.createRange() + range.setStart(text, 0) + range.collapse(true) + const rect = range.getBoundingClientRect() + return { x: rect.left, y: rect.top + rect.height / 2 } + }) + await orcaPage.mouse.click(point.x, point.y) + await orcaPage.keyboard.press('Shift+ArrowRight') + await expect( + orcaPage.getByRole('button', { name: 'Add review note', exact: true }) + ).toBeVisible() + await frames(orcaPage) + const cold = await measure(editor) + await measure(editor, true) + for (let index = 0; index < 20; index++) { + await orcaPage.keyboard.press('Shift+ArrowRight') + await frames(orcaPage) + } + const selection = await measure(editor) + expect(await orcaPage.evaluate(() => window.getSelection()?.toString())).toBe( + paragraph(targetIndex).slice(0, 21) + ) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + await measure(editor, true) + for (let index = 0; index < 10; index++) { + await viewport.evaluate((element, index) => { + element.scrollTop += index % 2 ? -8 : 8 + }, index) + await frames(orcaPage) + } + const scroll = await measure(editor) + const result = { + paragraphs: PARAGRAPH_COUNT, + sourceBytes: Buffer.byteLength(SOURCE), + cold, + selection, + scroll + } + await testInfo.attach('review-selection-metrics', { + body: JSON.stringify(result, null, 2), + contentType: 'application/json' + }) + process.stdout.write(`${JSON.stringify(result)}\n`) + await expect(editor).toBeFocused() + const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'review-selection' }).last() + await expect(tab.locator('span.rounded-full')).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('review-selection.png') }) + expect(selection.calls).toBe(0) + expect(selection.jsonCalls).toBe(0) + expect(scroll.calls).toBe(0) + expect(scroll.jsonCalls).toBe(0) + } finally { + await editor.evaluate((element) => { + const editorElement = + element.closest('.rich-markdown-editor') + editorElement?.__reviewSelectionProbe?.restore() + }) + } +})