mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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
This commit is contained in:
@@ -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 <sup><a href="./source.md">[1]</a></sup>\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()
|
||||
})
|
||||
})
|
||||
@@ -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<Editor['markdown']>['serialize'] | undefined
|
||||
blocks: readonly RichMarkdownCommentBlock[]
|
||||
}
|
||||
|
||||
// Keep only the current document per editor; scrolling changes geometry, not source lines.
|
||||
const blocksByEditor = new WeakMap<Editor, RichMarkdownCommentBlocksSnapshot>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -48,7 +48,8 @@ function measureBaseline({
|
||||
block,
|
||||
containerRect,
|
||||
container.scrollTop,
|
||||
markdownSourceLineOffset
|
||||
markdownSourceLineOffset,
|
||||
buildRichMarkdownCommentBlocks(editor)
|
||||
)
|
||||
return top === null ? null : { comment, top }
|
||||
})
|
||||
|
||||
@@ -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<Editor['markdown']>['serialize'] | undefined
|
||||
blocks: readonly RichMarkdownCommentBlock[]
|
||||
}
|
||||
|
||||
// Keep only the current document per editor; scrolling changes geometry, not source lines.
|
||||
const blocksByEditor = new WeakMap<Editor, ReviewRailBlocks>()
|
||||
|
||||
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'
|
||||
|
||||
@@ -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<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function measure(editor: Locator, reset = false): Promise<ReviewSelectionMetrics> {
|
||||
return editor.evaluate((element, reset) => {
|
||||
const editorElement =
|
||||
element.closest<PageRichMarkdownReviewEditorElement>('.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<PageRichMarkdownReviewEditorElement>('.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<Editor['getJSON']> => {
|
||||
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<PageRichMarkdownReviewEditorElement>('.rich-markdown-editor')
|
||||
editorElement?.__reviewSelectionProbe?.restore()
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user