From dd9b2d88b82eb551aa317b0ff280d7e3ddc49b2c Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 20 Jul 2026 22:25:38 +0800 Subject: [PATCH] [diffs/edit] Add search-match viewport culling - Binary-searches offset-sorted matches for each rendered line segment. - Skips virtualized offscreen matches and matches hidden inside collapsed diff gaps. --- packages/diffs/src/editor/editor.ts | 115 ++++++++++++------ .../diffs/test/editorCollapsedEdit.test.ts | 60 ++++++++- .../diffs/test/editorVirtualizedEdit.test.ts | 48 +++++++- 3 files changed, 186 insertions(+), 37 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 20e3cbd1c..bb2121a21 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -2425,14 +2425,16 @@ export class Editor implements DiffsEditor { // whole document. #suppressNativeSelectionSync then stops the resulting // selectionchange from reading that shorter range back over #selections // before the delete runs. - const renderedLines = this.#getRenderedEditableLineRange(); - if (renderedLines !== undefined) { + const renderedLineRanges = this.#getRenderedEditableLineRanges(); + const firstRenderedLine = renderedLineRanges[0]?.[0]; + const lastRenderedLine = renderedLineRanges.at(-1)?.[1]; + if (firstRenderedLine !== undefined && lastRenderedLine !== undefined) { this.#suppressNativeSelectionSync = true; this.#setWindowSelection({ - start: { line: renderedLines.first, character: 0 }, + start: { line: firstRenderedLine, character: 0 }, end: { - line: renderedLines.last, - character: textDocument.getLineLength(renderedLines.last), + line: lastRenderedLine, + character: textDocument.getLineLength(lastRenderedLine), }, direction: DirectionForward, }); @@ -3679,6 +3681,15 @@ export class Editor implements DiffsEditor { const textDocument = this.#textDocument; if (this.#matches !== undefined && textDocument !== undefined) { + const matches = this.#matches; + const renderRange = this.#renderRange; + const shouldCullMatches = + (renderRange !== undefined && + Number.isFinite(renderRange.totalLines)) || + (this.#isDiff && this.#fileInstance?.options.expandUnchanged !== true); + const renderedLineRanges = shouldCullMatches + ? this.#getRenderedEditableLineRanges() + : undefined; const primarySelection = this.#selections?.at(-1); const primaryStartOffset = primarySelection !== undefined @@ -3688,19 +3699,59 @@ export class Editor implements DiffsEditor { primarySelection !== undefined ? textDocument.offsetAt(primarySelection.end) : -1; - for (const [startOffset, endOffset] of this.#matches) { - const range: Range = { - start: textDocument.positionAt(startOffset), - end: textDocument.positionAt(endOffset), - }; - const isFocused = - primaryStartOffset === startOffset && primaryEndOffset === endOffset; - this.#renderSelection( - renderCtx, - 'match', - range, - isFocused ? 'focus' : undefined - ); + let firstMatchIndex = 0; + const rangeCount = renderedLineRanges?.length ?? 1; + for (let rangeIndex = 0; rangeIndex < rangeCount; rangeIndex++) { + let visibleEndOffset = Infinity; + const lineRange = renderedLineRanges?.[rangeIndex]; + if (lineRange !== undefined) { + const [firstLine, lastLine] = lineRange; + const visibleStartOffset = textDocument.offsetAt({ + line: firstLine, + character: 0, + }); + const endLine = lastLine + 1; + visibleEndOffset = + endLine < textDocument.lineCount + ? textDocument.offsetAt({ line: endLine, character: 0 }) + : textDocument.offsetAt({ + line: endLine - 1, + character: textDocument.getLineLength(endLine - 1), + }); + + // Matches are sorted and non-overlapping, so narrow the rendered + // offset window before resolving any match positions through the treap. + let low = firstMatchIndex; + let high = matches.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (matches[middle][1] <= visibleStartOffset) { + low = middle + 1; + } else { + high = middle; + } + } + firstMatchIndex = low; + } + for (; firstMatchIndex < matches.length; firstMatchIndex++) { + const [startOffset, endOffset] = matches[firstMatchIndex]; + if (startOffset >= visibleEndOffset) { + break; + } + const range: Range = { + start: textDocument.positionAt(startOffset), + end: textDocument.positionAt(endOffset), + }; + const isFocused = + primaryStartOffset === startOffset && + primaryEndOffset === endOffset; + this.#renderSelection( + renderCtx, + 'match', + range, + isFocused ? 'focus' : undefined + ); + } } } @@ -4823,18 +4874,14 @@ export class Editor implements DiffsEditor { return undefined; } - // Returns the first and last document lines that have an editable row in the - // current (virtualized) render window, or undefined when none are rendered. - // Used by select-all to anchor a native selection: only rendered lines - // resolve to DOM nodes, so the native range must stay within what is on - // screen even though the document selection spans the whole file. - #getRenderedEditableLineRange(): { first: number; last: number } | undefined { + // Returns contiguous document-line ranges that have editable rows in the + // current render window. Collapsed diff sections create gaps between ranges. + #getRenderedEditableLineRanges(): [first: number, last: number][] { const contentElement = this.#contentElement; if (contentElement === undefined) { - return undefined; + return []; } - let first: number | undefined; - let last: number | undefined; + const ranges: [first: number, last: number][] = []; for (const child of contentElement.children) { const el = child as HTMLElement; const lineType = el.dataset.lineType; @@ -4847,16 +4894,14 @@ export class Editor implements DiffsEditor { continue; } const line = lineNumber - 1; - if (first === undefined || line < first) { - first = line; - } - if (last === undefined || line > last) { - last = line; + const current = ranges.at(-1); + if (current !== undefined && line <= current[1] + 1) { + current[1] = Math.max(current[1], line); + } else { + ranges.push([line, line]); } } - return first === undefined || last === undefined - ? undefined - : { first, last }; + return ranges; } #getLineElement(line: number): HTMLElement | undefined { diff --git a/packages/diffs/test/editorCollapsedEdit.test.ts b/packages/diffs/test/editorCollapsedEdit.test.ts index f03dc587a..1ce335c70 100644 --- a/packages/diffs/test/editorCollapsedEdit.test.ts +++ b/packages/diffs/test/editorCollapsedEdit.test.ts @@ -1,8 +1,9 @@ -import { afterAll, describe, expect, test } from 'bun:test'; +import { afterAll, describe, expect, spyOn, test } from 'bun:test'; import { FileDiff } from '../src/components/FileDiff'; import { DEFAULT_THEMES } from '../src/constants'; import { Editor } from '../src/editor/editor'; +import { PieceTable } from '../src/editor/pieceTable'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; import { installDom, wait } from './domHarness'; @@ -203,6 +204,63 @@ describe('diff editor: collapsed regions during edit', () => { } }); + test('resolves search positions only for rendered sides of collapsed gaps', async () => { + const fixture = await createCollapsedEditFixture(); + const { container, editor } = fixture; + try { + editor.setOptions({ matchBrackets: false }); + editor.setSelections([ + { + start: { line: 9, character: 0 }, + end: { line: 9, character: 4 }, + direction: 'forward', + }, + ]); + const content = findAdditionContent(container); + expect(content).not.toBeUndefined(); + content!.dispatchEvent( + new window.KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code: 'KeyF', + composed: true, + key: 'f', + metaKey: true, + }) + ); + await wait(0); + const input = container.shadowRoot?.querySelector( + '[data-search-panel] input[data-search]' + ); + expect(input).not.toBeNull(); + input!.value = 'line'; + input!.dispatchEvent(new window.Event('input', { bubbles: true })); + + const visibleMatchCount = + container.shadowRoot?.querySelectorAll('[data-match-range]').length ?? + 0; + expect(visibleMatchCount).toBeGreaterThan(0); + expect(visibleMatchCount).toBeLessThan(60); + + const positionAt = spyOn(PieceTable.prototype, 'positionAt'); + try { + editor.setSelections([ + { + start: { line: 9, character: 0 }, + end: { line: 9, character: 0 }, + direction: 'none', + }, + ]); + + expect(positionAt).toHaveBeenCalledTimes(visibleMatchCount * 2); + } finally { + positionAt.mockRestore(); + } + } finally { + await fixture.cleanup(); + } + }); + test('typing below a collapsed gap patches the row without duplicates', async () => { const fixture = await createCollapsedEditFixture(); const { container, editor } = fixture; diff --git a/packages/diffs/test/editorVirtualizedEdit.test.ts b/packages/diffs/test/editorVirtualizedEdit.test.ts index 532ad57e5..8470a9d06 100644 --- a/packages/diffs/test/editorVirtualizedEdit.test.ts +++ b/packages/diffs/test/editorVirtualizedEdit.test.ts @@ -1,8 +1,9 @@ -import { afterAll, describe, expect, test } from 'bun:test'; +import { afterAll, describe, expect, spyOn, test } from 'bun:test'; import { File } from '../src/components/File'; import { DEFAULT_THEMES } from '../src/constants'; import { Editor } from '../src/editor/editor'; +import { PieceTable } from '../src/editor/pieceTable'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; import type { FileContents, RenderRange } from '../src/types'; import { installDom, wait } from './domHarness'; @@ -304,3 +305,48 @@ describe('Editor edits at the bottom of a virtualized window', () => { } }); }); + +describe('Editor search matches in a virtualized window', () => { + test('resolves positions only for matches in the rendered window', async () => { + const range = makeRange(900, 7); + const { cleanup, content, editor, fileContainer } = + await createWindowedEditor(2000, range); + try { + content.dispatchEvent( + new window.KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code: 'KeyF', + composed: true, + key: 'f', + metaKey: true, + }) + ); + await wait(0); + const input = fileContainer.shadowRoot?.querySelector( + '[data-search-panel] input[data-search]' + ); + expect(input).not.toBeNull(); + input!.value = 'line'; + input!.dispatchEvent(new window.Event('input', { bubbles: true })); + + editor.setOptions({ matchBrackets: false }); + const positionAt = spyOn(PieceTable.prototype, 'positionAt'); + try { + editor.setSelections([collapsedCaret(903)]); + + // There is one match per document line. A repaint resolves only both + // endpoints of the seven rendered matches, not all 2,000 matches. + expect(positionAt).toHaveBeenCalledTimes(range.totalLines * 2); + expect( + fileContainer.shadowRoot?.querySelectorAll('[data-match-range]') + .length + ).toBe(range.totalLines); + } finally { + positionAt.mockRestore(); + } + } finally { + cleanup(); + } + }); +});