diff --git a/apps/desktop/src/lib/__tests__/editor/codemirrorSelectionMatches.spec.ts b/apps/desktop/src/lib/__tests__/editor/codemirrorSelectionMatches.spec.ts new file mode 100644 index 0000000000..bbad511db6 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/codemirrorSelectionMatches.spec.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom + +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSelectionMatchMarkerScan, SELECTION_MATCH_SCAN_CHUNK_LENGTH, SELECTION_MATCH_UPDATE_DELAY_MS, selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches"; + +function createView(doc: string, selection = EditorSelection.cursor(0)) { + const parent = document.createElement("div"); + document.body.appendChild(parent); + return new EditorView({ + parent, + state: EditorState.create({ + doc, + selection, + extensions: [selectionMatchOccurrences()], + }), + }); +} + +describe("selectionMatchOccurrences", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); + }); + + it("waits for pointer selection updates to settle before rendering scrollbar matches", async () => { + const view = createView("foo one\nfoo two\nfoo three"); + + view.dispatch({ selection: { anchor: 0, head: 3 }, userEvent: "select.pointer" }); + + expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(0); + await vi.advanceTimersByTimeAsync(SELECTION_MATCH_UPDATE_DELAY_MS - 1); + expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(1); + expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(3); + view.destroy(); + }); + + it("scans large documents incrementally instead of completing in the update callback", async () => { + const prefix = "x".repeat(SELECTION_MATCH_SCAN_CHUNK_LENGTH + 8); + const view = createView(`${prefix}needle`, EditorSelection.range(prefix.length, prefix.length + 6)); + + await vi.advanceTimersByTimeAsync(0); + expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(20); + expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(1); + view.destroy(); + }); + + it("cancels an in-progress scan when the selection changes", async () => { + const prefix = "x".repeat(SELECTION_MATCH_SCAN_CHUNK_LENGTH + 8); + const view = createView(`${prefix}needle`, EditorSelection.range(prefix.length, prefix.length + 6)); + + await vi.advanceTimersByTimeAsync(0); + view.dispatch({ selection: { anchor: 0 }, userEvent: "select.pointer" }); + await vi.advanceTimersByTimeAsync(SELECTION_MATCH_UPDATE_DELAY_MS + 40); + + expect(view.dom.querySelectorAll(".cm-selectionMatchScrollbarMark")).toHaveLength(0); + view.destroy(); + }); +}); + +describe("SelectionMatchMarkerScan", () => { + it("finds a match that crosses a scan chunk boundary exactly once", () => { + const prefix = "x".repeat(SELECTION_MATCH_SCAN_CHUNK_LENGTH - 2); + const doc = `${prefix}needle tail`; + const state = EditorState.create({ doc, selection: EditorSelection.range(prefix.length, prefix.length + 6) }); + const scan = createSelectionMatchMarkerScan(state)!; + + while (!scan.scanNextChunk()) { + // Continue through the remaining chunks. + } + + expect(scan.markers).toEqual([{ from: prefix.length, to: prefix.length + 6, lineNumber: 1, selected: true }]); + }); + + it.each([ + { name: "canonical decomposition", query: "\u00e9x", occurrence: "e\u0301x" }, + { name: "compatibility decomposition", query: "Ax", occurrence: "\u{1d400}x" }, + ])("finds a $name match across a scan chunk boundary", ({ query, occurrence }) => { + const prefix = "x".repeat(SELECTION_MATCH_SCAN_CHUNK_LENGTH - 1); + const doc = `${prefix}${occurrence}\n${query}`; + const selectedFrom = doc.length - query.length; + const state = EditorState.create({ doc, selection: EditorSelection.range(selectedFrom, doc.length) }); + const scan = createSelectionMatchMarkerScan(state)!; + + while (!scan.scanNextChunk()) { + // Continue through the remaining chunks. + } + + expect(scan.markers).toContainEqual({ from: prefix.length, to: prefix.length + occurrence.length, lineNumber: 1, selected: false }); + }); + + it("caps scanned matches while retaining the selected marker beyond the cap", () => { + const prefix = `${"id ".repeat(100_000)}\n`; + const state = EditorState.create({ doc: `${prefix}id`, selection: EditorSelection.range(prefix.length, prefix.length + 2) }); + const scan = createSelectionMatchMarkerScan(state)!; + + expect(state.doc.length).toBeGreaterThan(SELECTION_MATCH_SCAN_CHUNK_LENGTH); + expect(scan.scanNextChunk()).toBe(true); + expect(scan.markers).toEqual([ + { from: prefix.length, to: prefix.length + 2, lineNumber: 2, selected: true }, + { from: 0, to: 2, lineNumber: 1, selected: false }, + ]); + }); +}); diff --git a/apps/desktop/src/lib/editor/codemirrorSelectionMatches.ts b/apps/desktop/src/lib/editor/codemirrorSelectionMatches.ts index 3db640621d..97a60172c7 100644 --- a/apps/desktop/src/lib/editor/codemirrorSelectionMatches.ts +++ b/apps/desktop/src/lib/editor/codemirrorSelectionMatches.ts @@ -4,9 +4,11 @@ import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; const MIN_SELECTION_MATCH_LENGTH = 2; const MAX_SELECTION_MATCH_LENGTH = 200; -const MAX_SCROLLBAR_MARKS = 1200; +const MAX_SCROLLBAR_MATCHES = 1200; +export const SELECTION_MATCH_SCAN_CHUNK_LENGTH = 64 * 1024; +export const SELECTION_MATCH_UPDATE_DELAY_MS = 120; -interface SelectionMatchMarker { +export interface SelectionMatchMarker { from: number; to: number; lineNumber: number; @@ -23,57 +25,130 @@ function selectedMatchQuery(state: EditorState): string | null { return text; } -function buildSelectionMatchMarkers(state: EditorState): SelectionMatchMarker[] { - const query = selectedMatchQuery(state); - if (!query) return []; +export class SelectionMatchMarkerScan { + readonly markers: SelectionMatchMarker[] = []; + private readonly seenLines = new Set(); + private from = 0; + private matchesScanned = 0; + private readonly lookaheadLength: number; - const selection = state.selection.main; - const markers: SelectionMatchMarker[] = []; - const seenLines = new Set(); - const cursor = new SearchCursor(state.doc, query); + constructor( + private readonly state: EditorState, + private readonly query: string, + ) { + const selection = state.selection.main; + const selectedLine = state.doc.lineAt(selection.from); + this.seenLines.add(selectedLine.number); + this.seenLines.add(-selectedLine.number); + this.markers.push({ from: selection.from, to: selection.to, lineNumber: selectedLine.number, selected: true }); - for (let match = cursor.next(); !match.done; match = cursor.next()) { - const { from, to } = match.value; - if (from === to) continue; - - const line = state.doc.lineAt(from); - const selected = from === selection.from && to === selection.to; - const lineKey = selected ? -line.number : line.number; - if (seenLines.has(lineKey)) continue; - seenLines.add(lineKey); - - markers.push({ from, to, lineNumber: line.number, selected }); - if (markers.length >= MAX_SCROLLBAR_MARKS) break; + // SearchCursor compares NFKD text, whose source can use two UTF-16 units per normalized code point. + const normalizedQuery = typeof query.normalize === "function" ? query.normalize("NFKD") : query; + this.lookaheadLength = Math.max(query.length, normalizedQuery.length * 2) - 1; } - return markers; + scanNextChunk(): boolean { + const doc = this.state.doc; + if (this.from >= doc.length || this.matchesScanned >= MAX_SCROLLBAR_MATCHES) return true; + + const chunkFrom = this.from; + const chunkTo = Math.min(doc.length, chunkFrom + SELECTION_MATCH_SCAN_CHUNK_LENGTH); + // Include enough lookahead to catch normalized matches that start in this chunk and cross its boundary. + const searchTo = Math.min(doc.length, chunkTo + this.lookaheadLength); + const selection = this.state.selection.main; + const cursor = new SearchCursor(doc, this.query, chunkFrom, searchTo); + + for (let match = cursor.next(); !match.done; match = cursor.next()) { + const { from, to } = match.value; + if (from >= chunkTo) break; + if (from === to) continue; + + this.matchesScanned += 1; + const line = doc.lineAt(from); + const selected = from === selection.from && to === selection.to; + const lineKey = selected ? -line.number : line.number; + if (!this.seenLines.has(lineKey)) { + this.seenLines.add(lineKey); + this.markers.push({ from, to, lineNumber: line.number, selected }); + } + if (this.matchesScanned >= MAX_SCROLLBAR_MATCHES) return true; + } + + this.from = chunkTo; + return this.from >= doc.length; + } +} + +export function createSelectionMatchMarkerScan(state: EditorState): SelectionMatchMarkerScan | null { + const query = selectedMatchQuery(state); + return query ? new SelectionMatchMarkerScan(state, query) : null; } class SelectionMatchScrollbar { private readonly layer: HTMLElement; + private readonly win: Window; private signature = ""; + private updateTimer: number | null = null; + private scanFrame: number | null = null; + private revision = 0; constructor(view: EditorView) { this.layer = document.createElement("div"); this.layer.className = "cm-selectionMatchScrollbarLayer"; + this.win = view.dom.ownerDocument.defaultView ?? window; view.dom.appendChild(this.layer); - this.render(view); + this.scheduleRender(view, 0); } update(update: ViewUpdate) { - if (update.docChanged || update.selectionSet || update.geometryChanged) { - this.render(update.view); - } + if (update.docChanged || update.selectionSet) this.scheduleRender(update.view); } destroy() { + this.cancelScheduledRender(); this.layer.remove(); } - private render(view: EditorView) { - const markers = buildSelectionMatchMarkers(view.state); - const lineCount = view.state.doc.lines; - const signature = markers.map((marker) => `${marker.lineNumber}:${marker.from}:${marker.to}:${marker.selected}`).join("|"); + private cancelScheduledRender() { + this.revision += 1; + if (this.updateTimer !== null) { + this.win.clearTimeout(this.updateTimer); + this.updateTimer = null; + } + if (this.scanFrame !== null) { + this.win.cancelAnimationFrame(this.scanFrame); + this.scanFrame = null; + } + } + + private scheduleRender(view: EditorView, delay = SELECTION_MATCH_UPDATE_DELAY_MS) { + this.cancelScheduledRender(); + const scan = createSelectionMatchMarkerScan(view.state); + this.renderMarkers([], view.state.doc.lines); + if (!scan) return; + + const revision = this.revision; + this.updateTimer = this.win.setTimeout(() => { + this.updateTimer = null; + this.scanNextFrame(view, scan, revision); + }, delay); + } + + private scanNextFrame(view: EditorView, scan: SelectionMatchMarkerScan, revision: number) { + if (revision !== this.revision) return; + if (scan.scanNextChunk()) { + this.renderMarkers(scan.markers, view.state.doc.lines); + return; + } + // Yield between chunks so a large document never monopolizes pointer and paint work. + this.scanFrame = this.win.requestAnimationFrame(() => { + this.scanFrame = null; + this.scanNextFrame(view, scan, revision); + }); + } + + private renderMarkers(markers: SelectionMatchMarker[], lineCount: number) { + const signature = `${lineCount}|${markers.map((marker) => `${marker.lineNumber}:${marker.from}:${marker.to}:${marker.selected}`).join("|")}`; if (signature === this.signature) return; this.signature = signature; this.layer.replaceChildren(