From 17c320d252208dbb283823c84f370d0f19603ecc Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Tue, 16 Jun 2026 17:03:41 -0700 Subject: [PATCH 1/5] Phase 1: Make the editor search foundations shareable --- packages/diffs/src/editor/pieceTable.ts | 226 ++--------------- packages/diffs/src/editor/searchPanel.ts | 16 +- packages/diffs/src/editor/textDocument.ts | 4 +- packages/diffs/src/search.ts | 237 ++++++++++++++++++ packages/diffs/test/editorSearchPanel.test.ts | 6 +- packages/diffs/test/search.test.ts | 119 +++++++++ 6 files changed, 390 insertions(+), 218 deletions(-) create mode 100644 packages/diffs/src/search.ts create mode 100644 packages/diffs/test/search.test.ts diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index d06f15c92..37f35087a 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -1,13 +1,13 @@ +import { + type MatchRange, + searchLineByLine, + type SearchParams, +} from '../search'; import type { Position, Range, ResolvedTextEdit } from '../types'; import { computeLineOffsets } from '../utils/computeFileOffsets'; -import type { SearchParams } from './searchPanel'; -const MAX_FIND_MATCHES = 100000; const LINE_FEED = 10; const CARRIAGE_RETURN = 13; -// TODO(ije): use Intl.Segmenter instead of regex for word separators -const WORD_SEPARATORS = '`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?' as const; - // A piece is a segment of text that is either original or added. class Piece { static Original = 0; @@ -328,90 +328,28 @@ export class PieceTable { return foundOffset ?? wrappedOffset; } - search(searchParams: SearchParams): [start: number, end: number][] { - if (searchParams.text.length === 0 || this.#length === 0) { - return []; - } - - // Search currently operates line-by-line, so newline-spanning patterns are unsupported. - if ( - searchParams.text.includes('\n') || - searchParams.text.includes('\r') || - (searchParams.regex && - (searchParams.text.includes('\\n') || - searchParams.text.includes('\\r'))) - ) { - return []; - } - - let pattern: RegExp; - try { - pattern = compileSearchRegExp( - searchParams.text, - searchParams.regex, - searchParams.caseSensitive - ); - } catch { - return []; - } - - return this.#collectSearchMatchesLineByLine( - pattern, - searchParams.wholeWord, - MAX_FIND_MATCHES - ); - } - - #collectSearchMatchesLineByLine( - pattern: RegExp, - wholeWord: boolean, - limit: number - ): [number, number][] { - const out: [number, number][] = []; - // Search visits the whole document, so flatten it once instead of making - // several treap descents for every line and whole-word boundary. + search(searchParams: SearchParams): MatchRange[] { + // Search scans the whole document, so flatten the treap once instead of + // descending it again for every line and whole-word boundary. const documentText = this.#textFromPieces(); - const docLength = documentText.length; const lineOffsets = computeLineOffsets(documentText); - - // Reuse the single compiled pattern across every line, resetting its - // lastIndex before each line, instead of allocating a fresh RegExp per - // line. The pattern is global, so lastIndex tracks progress within a line. - for (let line = 0; line < lineOffsets.length; line++) { - const lineStart = lineOffsets[line]; - let lineEnd = lineOffsets[line + 1] ?? docLength; - while ( - lineEnd > lineStart && - isEOL(documentText.charCodeAt(lineEnd - 1)) - ) { - lineEnd--; - } - const lineText = documentText.slice(lineStart, lineEnd); - pattern.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = pattern.exec(lineText)) !== null) { - const rel = match.index; - const fragment = match[0]; - if (fragment.length === 0) { - pattern.lastIndex = advancePastEmptyMatch(lineText, rel); - continue; - } - const docStart = lineStart + rel; - if ( - !wholeWord || - isWholeWordAtDocOffsets(documentText, docStart, fragment.length) - ) { - out.push([docStart, docStart + fragment.length]); - if (out.length >= limit) { - return out; + return searchLineByLine( + { + textLength: documentText.length, + lineCount: lineOffsets.length, + getLineText: (line) => { + const start = lineOffsets[line] ?? documentText.length; + let end = lineOffsets[line + 1] ?? documentText.length; + while (end > start && isEOL(documentText.charCodeAt(end - 1))) { + end--; } - } - if (rel === pattern.lastIndex) { - pattern.lastIndex = advancePastEmptyMatch(lineText, rel); - } - } - } - return out; + return documentText.slice(start, end); + }, + getLineStartOffset: (line) => lineOffsets[line] ?? documentText.length, + charAt: (offset) => documentText.charAt(offset), + }, + searchParams + ); } insert(text: string, offset: number): void { @@ -1107,119 +1045,3 @@ function upperBound(values: number[], target: number): number { } return lo; } - -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function isWordSeparatorCharCode(charCode: number): boolean { - if (charCode <= 32 || charCode === 127) { - return true; - } - const ch = String.fromCharCode(charCode); - return WORD_SEPARATORS.includes(ch); -} - -// Checks if the given text is a whole word by checking if the -// characters before and after are word separators. -function isWholeWordAtDocOffsets( - text: string, - docStart: number, - length: number -): boolean { - const beforeOk = - docStart <= 0 || isWordSeparatorCharCode(text.charCodeAt(docStart - 1)); - const afterOk = - docStart + length >= text.length || - isWordSeparatorCharCode(text.charCodeAt(docStart + length)); - return beforeOk && afterOk; -} - -function compileSearchRegExp( - source: string, - isRegex: boolean, - caseSensitive: boolean -): RegExp { - const body = isRegex ? source : escapeRegExp(source); - const flags = `g${caseSensitive ? '' : 'i'}${isRegex ? 'm' : ''}`; - return new RegExp(body, flags); -} - -/** Expands `$&`, `$1`, `$$`, etc. in a regex replace string using a match. */ -function expandReplaceString( - replacement: string, - match: RegExpExecArray -): string { - return replacement.replace(/\$([$&]|\d+)/g, (_token, group: string) => { - if (group === '$') { - return '$'; - } - if (group === '&') { - return match[0] ?? ''; - } - const index = Number(group); - return match[index] ?? ''; - }); -} - -/** - * Builds the text to insert for one search match, including regex capture - * substitution when regex mode is enabled. - */ -export function buildSearchReplacementText( - positionAt: (offset: number) => Position, - offsetAt: (position: Position) => number, - getLineText: (line: number) => string, - searchParams: SearchParams, - matchStart: number, - matchEnd: number -): string { - if (!searchParams.regex) { - return searchParams.replaceText; - } - - const position = positionAt(matchStart); - const lineText = getLineText(position.line); - const lineStart = offsetAt({ line: position.line, character: 0 }); - const relStart = matchStart - lineStart; - - let pattern: RegExp; - try { - pattern = compileSearchRegExp( - searchParams.text, - true, - searchParams.caseSensitive - ); - } catch { - return searchParams.replaceText; - } - - // Re-run at the original line offset so lookaround can inspect context - // outside the matched range while captures still come from this exact hit. - pattern.lastIndex = relStart; - const match = pattern.exec(lineText); - if ( - match === null || - match.index !== relStart || - match[0].length !== matchEnd - matchStart - ) { - return searchParams.replaceText; - } - return expandReplaceString(searchParams.replaceText, match); -} - -function advancePastEmptyMatch(text: string, index: number): number { - if (index + 1 < text.length) { - const first = text.charCodeAt(index); - const second = text.charCodeAt(index + 1); - if ( - first >= 0xd800 && - first <= 0xdbff && - second >= 0xdc00 && - second <= 0xdfff - ) { - return index + 2; - } - } - return index + 1; -} diff --git a/packages/diffs/src/editor/searchPanel.ts b/packages/diffs/src/editor/searchPanel.ts index 2c96a6dd5..1d3a8a423 100644 --- a/packages/diffs/src/editor/searchPanel.ts +++ b/packages/diffs/src/editor/searchPanel.ts @@ -1,22 +1,18 @@ +import { + buildSearchReplacementText, + type MatchRange, + type SearchParams, +} from '../search'; import type { ResolvedTextEdit } from '../types'; import { resolveFindAgainShortcut } from './command'; -import { buildSearchReplacementText } from './pieceTable'; import { isPrimaryModifier } from './platform'; import { getEditorIconSvg, type SVGSpriteNames } from './sprite'; import type { TextDocument } from './textDocument'; import { h } from './utils'; -export type MatchRange = [startOffset: number, endOffset: number]; - export type SearchPanelMode = 'find' | 'replace'; -export interface SearchParams { - text: string; - replaceText: string; - caseSensitive: boolean; - wholeWord: boolean; - regex: boolean; -} +export type { MatchRange, SearchParams } from '../search'; export interface SearchPanelOptions { textDocument: TextDocument; diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 0738a6253..fb533b651 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -1,3 +1,4 @@ +import type { MatchRange, SearchParams } from '../search'; import type { DiffLineAnnotation, EditorChange, @@ -15,7 +16,6 @@ import { shouldCoalesceEditStackEntry, } from './editStack'; import { PieceTable } from './pieceTable'; -import type { SearchParams } from './searchPanel'; export type { Position, Range, TextEdit } from '../types'; @@ -193,7 +193,7 @@ export class TextDocument { return this.#pieceTable.findNextNonOverlappingSubstring(needle, occupied); } - search(searchParams: SearchParams): [start: number, end: number][] { + search(searchParams: SearchParams): MatchRange[] { return this.#pieceTable.search(searchParams); } diff --git a/packages/diffs/src/search.ts b/packages/diffs/src/search.ts new file mode 100644 index 000000000..94ad5648a --- /dev/null +++ b/packages/diffs/src/search.ts @@ -0,0 +1,237 @@ +export type MatchRange = [startOffset: number, endOffset: number]; + +export interface SearchParams { + text: string; + replaceText: string; + caseSensitive: boolean; + wholeWord: boolean; + regex: boolean; +} + +export interface SearchPosition { + readonly line: number; + readonly character: number; +} + +export interface LineByLineSearchDocument { + readonly textLength: number; + readonly lineCount: number; + getLineText(line: number): string; + getLineStartOffset(line: number): number; + charAt(offset: number): string; +} + +export const MAX_FIND_MATCHES = 100000; + +// TODO(ije): use Intl.Segmenter instead of regex for word separators +const WORD_SEPARATORS = '`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?' as const; + +export function searchLineByLine( + document: LineByLineSearchDocument, + searchParams: SearchParams, + limit: number = MAX_FIND_MATCHES +): MatchRange[] { + if (searchParams.text.length === 0 || document.textLength === 0) { + return []; + } + + // Search currently operates line-by-line, so newline-spanning patterns are unsupported. + if (isNewlineSpanningSearch(searchParams.text, searchParams.regex)) { + return []; + } + + let pattern: RegExp; + try { + pattern = compileSearchRegExp( + searchParams.text, + searchParams.regex, + searchParams.caseSensitive + ); + } catch { + return []; + } + + return collectSearchMatchesLineByLine( + document, + pattern, + searchParams.wholeWord, + limit + ); +} + +/** Expands `$&`, `$1`, `$$`, etc. in a regex replace string using a match. */ +export function buildSearchReplacementText( + positionAt: (offset: number) => SearchPosition, + offsetAt: (position: SearchPosition) => number, + getLineText: (line: number) => string, + searchParams: SearchParams, + matchStart: number, + matchEnd: number +): string { + if (!searchParams.regex) { + return searchParams.replaceText; + } + + const position = positionAt(matchStart); + const lineText = getLineText(position.line); + const lineStart = offsetAt({ line: position.line, character: 0 }); + const relStart = matchStart - lineStart; + + let pattern: RegExp; + try { + pattern = compileSearchRegExp( + searchParams.text, + true, + searchParams.caseSensitive + ); + } catch { + return searchParams.replaceText; + } + + // Re-run at the original line offset so lookaround can inspect context + // outside the matched range while captures still come from this exact hit. + pattern.lastIndex = relStart; + const match = pattern.exec(lineText); + if ( + match === null || + match.index !== relStart || + match[0].length !== matchEnd - matchStart + ) { + return searchParams.replaceText; + } + return expandReplaceString(searchParams.replaceText, match); +} + +function isNewlineSpanningSearch(text: string, isRegex: boolean): boolean { + return ( + text.includes('\n') || + text.includes('\r') || + (isRegex && (text.includes('\\n') || text.includes('\\r'))) + ); +} + +function collectSearchMatchesLineByLine( + document: LineByLineSearchDocument, + pattern: RegExp, + wholeWord: boolean, + limit: number +): MatchRange[] { + const out: MatchRange[] = []; + const charAt = (offset: number) => document.charAt(offset); + + // Reuse the single compiled pattern across every line, resetting its + // lastIndex before each line, instead of allocating a fresh RegExp per line. + // The pattern is global, so lastIndex tracks progress within a line. + for (let line = 0; line < document.lineCount; line++) { + const lineText = document.getLineText(line); + const lineStart = document.getLineStartOffset(line); + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(lineText)) !== null) { + const rel = match.index; + const fragment = match[0]; + if (fragment.length === 0) { + pattern.lastIndex = advancePastEmptyMatch(lineText, rel); + continue; + } + const docStart = lineStart + rel; + if ( + !wholeWord || + isWholeWordAtDocOffsets( + docStart, + fragment.length, + document.textLength, + charAt + ) + ) { + out.push([docStart, docStart + fragment.length]); + if (out.length >= limit) { + return out; + } + } + if (rel === pattern.lastIndex) { + pattern.lastIndex = advancePastEmptyMatch(lineText, rel); + } + } + } + return out; +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function compileSearchRegExp( + source: string, + isRegex: boolean, + caseSensitive: boolean +): RegExp { + const body = isRegex ? source : escapeRegExp(source); + const flags = `g${caseSensitive ? '' : 'i'}${isRegex ? 'm' : ''}`; + return new RegExp(body, flags); +} + +function isWordSeparatorCharCode(charCode: number): boolean { + if (charCode <= 32 || charCode === 127) { + return true; + } + const ch = String.fromCharCode(charCode); + return WORD_SEPARATORS.includes(ch); +} + +// Checks if the given text is a whole word by checking if the +// characters before and after are word separators. +function isWholeWordAtDocOffsets( + docStart: number, + length: number, + docLength: number, + charAt: (offset: number) => string +): boolean { + const beforeOk = + docStart <= 0 || + isWordSeparatorCharCode(charCodeUnitAt(charAt, docStart - 1)); + const afterOk = + docStart + length >= docLength || + isWordSeparatorCharCode(charCodeUnitAt(charAt, docStart + length)); + return beforeOk && afterOk; +} + +function charCodeUnitAt( + charAt: (offset: number) => string, + offset: number +): number { + const unit = charAt(offset); + return unit.length === 0 ? 0 : unit.charCodeAt(0); +} + +function expandReplaceString( + replacement: string, + match: RegExpExecArray +): string { + return replacement.replace(/\$([$&]|\d+)/g, (_token, group: string) => { + if (group === '$') { + return '$'; + } + if (group === '&') { + return match[0] ?? ''; + } + const index = Number(group); + return match[index] ?? ''; + }); +} + +function advancePastEmptyMatch(text: string, index: number): number { + if (index + 1 < text.length) { + const first = text.charCodeAt(index); + const second = text.charCodeAt(index + 1); + if ( + first >= 0xd800 && + first <= 0xdbff && + second >= 0xdc00 && + second <= 0xdfff + ) { + return index + 2; + } + } + return index + 1; +} diff --git a/packages/diffs/test/editorSearchPanel.test.ts b/packages/diffs/test/editorSearchPanel.test.ts index da5825eab..c8c66ee2a 100644 --- a/packages/diffs/test/editorSearchPanel.test.ts +++ b/packages/diffs/test/editorSearchPanel.test.ts @@ -1,9 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { - buildSearchReplacementText, - PieceTable, -} from '../src/editor/pieceTable'; +import { PieceTable } from '../src/editor/pieceTable'; import { type MatchRange, type SearchPanelOptions, @@ -11,6 +8,7 @@ import { type SearchParams, } from '../src/editor/searchPanel'; import { TextDocument } from '../src/editor/textDocument'; +import { buildSearchReplacementText } from '../src/search'; import type { ResolvedTextEdit } from '../src/types'; import { installDom, wait } from './domHarness'; diff --git a/packages/diffs/test/search.test.ts b/packages/diffs/test/search.test.ts new file mode 100644 index 000000000..382172c82 --- /dev/null +++ b/packages/diffs/test/search.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from 'bun:test'; + +import { + buildSearchReplacementText, + type LineByLineSearchDocument, + searchLineByLine, + type SearchParams, +} from '../src/search'; + +function createSearchDocument(text: string): LineByLineSearchDocument { + const lineStarts = [0]; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) { + lineStarts.push(i + 1); + } + } + + const getLineStartOffset = (line: number) => lineStarts[line] ?? text.length; + + return { + textLength: text.length, + lineCount: lineStarts.length, + getLineStartOffset, + getLineText(line) { + const start = getLineStartOffset(line); + const nextStart = lineStarts[line + 1] ?? text.length; + let end = nextStart; + while (end > start && isLineEnding(text.charCodeAt(end - 1))) { + end--; + } + return text.slice(start, end); + }, + charAt(offset) { + return text.charAt(offset); + }, + }; +} + +function searchParams(overrides: Partial): SearchParams { + return { + text: '', + replaceText: '', + caseSensitive: false, + wholeWord: false, + regex: false, + ...overrides, + }; +} + +function isLineEnding(charCode: number): boolean { + return charCode === 10 || charCode === 13; +} + +describe('searchLineByLine', () => { + test('searches a string document without TextDocument', () => { + const document = createSearchDocument('Alpha beta\nalpha BETA'); + + expect(searchLineByLine(document, searchParams({ text: 'alpha' }))).toEqual( + [ + [0, 5], + [11, 16], + ] + ); + }); + + test('supports whole-word matching', () => { + const document = createSearchDocument('foo food (foo)'); + + expect( + searchLineByLine(document, searchParams({ text: 'foo', wholeWord: true })) + ).toEqual([ + [0, 3], + [10, 13], + ]); + }); + + test('returns no results for invalid regex input', () => { + const document = createSearchDocument('foo'); + + expect( + searchLineByLine(document, searchParams({ text: '[', regex: true })) + ).toEqual([]); + }); + + test('does not match newline-spanning queries', () => { + const document = createSearchDocument('foo\nbar'); + + expect( + searchLineByLine(document, searchParams({ text: 'foo\nbar' })) + ).toEqual([]); + expect( + searchLineByLine( + document, + searchParams({ text: 'foo\\nbar', regex: true }) + ) + ).toEqual([]); + }); + + test('expands regex capture replacements', () => { + const text = 'const answer = 42'; + const document = createSearchDocument(text); + const positionAt = (offset: number) => ({ line: 0, character: offset }); + + expect( + buildSearchReplacementText( + positionAt, + (position) => position.character, + (line) => document.getLineText(line), + searchParams({ + text: '(answer) = (\\d+)', + replaceText: '$1: $2', + regex: true, + }), + 6, + text.length + ) + ).toBe('answer: 42'); + }); +}); From 7b9e1af4584a7b4d213e6f9cba54c2317935f03c Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Thu, 16 Jul 2026 16:50:28 -0700 Subject: [PATCH 2/5] Phase 2: Make the search panel work with new shared utilities --- packages/diffs/src/editor/editor.ts | 89 ++++-- packages/diffs/src/editor/searchPanel.ts | 266 +++++++++--------- packages/diffs/test/editorSearchPanel.test.ts | 219 +++++++++++++- 3 files changed, 398 insertions(+), 176 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index c703dd2e5..fc4d5dc99 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -2,6 +2,7 @@ import { dequeueRender, queueRender, } from '../managers/UniversalRenderingManager'; +import { buildSearchReplacementText, type MatchRange } from '../search'; import type { DiffLineAnnotation, DiffsEditableComponent, @@ -54,11 +55,7 @@ import { PopoverManager, type PopoverPlacementBounds, } from './popover'; -import { - type MatchRange, - type SearchPanelMode, - SearchPanelWidget, -} from './searchPanel'; +import { type SearchPanelMode, SearchPanelWidget } from './searchPanel'; import type { AutoSurround, CursorMoveOptions, @@ -839,7 +836,7 @@ export class Editor implements DiffsEditor { const tokenizer = this.#tokenizer; if (tokenizer !== undefined) { tokenizer.pauseBackgroundTokenize(); - requestAnimationFrame(() => { + queueRender(() => { tokenizer.resumeBackgroundTokenize(); }); } @@ -4790,36 +4787,70 @@ export class Editor implements DiffsEditor { this.#retainSearchPanelFocus = retainFocus; }; + const buildReplacementEdit = ( + searchParams: Parameters[0], + matchStart: number, + matchEnd: number + ): ResolvedTextEdit => ({ + start: matchStart, + end: matchEnd, + text: buildSearchReplacementText( + (offset) => textDocument.positionAt(offset), + (position) => textDocument.offsetAt(position), + (line) => textDocument.getLineText(line), + searchParams, + matchStart, + matchEnd + ), + }); + + const applyReplace = (edits: ResolvedTextEdit[]) => { + if (edits.length === 0) { + return; + } + const change = textDocument.applyEdits( + edits.map((edit) => ({ + range: { + start: textDocument.positionAt(edit.start), + end: textDocument.positionAt(edit.end), + }, + newText: edit.text, + })), + true, + this.#selections + ); + if (change !== undefined) { + this.#applyChange( + change, + undefined, + this.#applyChangeToLineAnnotations(change), + { skipSearchRefresh: true } + ); + } + }; + const searchPanel = new SearchPanelWidget({ - textDocument, containerElement: preElement, defaultQuery, mode, initialMatch, + search: (searchParams) => textDocument.search(searchParams), + isSameMatch: ([aStart, aEnd], [bStart, bEnd]) => + aStart === bStart && aEnd === bEnd, scrollToMatch, - applyReplace: (edits: ResolvedTextEdit[]) => { - if (edits.length === 0) { - return; - } - const change = textDocument.applyEdits( - edits.map((edit) => ({ - range: { - start: textDocument.positionAt(edit.start), - end: textDocument.positionAt(edit.end), - }, - newText: edit.text, - })), - true, - this.#selections - ); - if (change !== undefined) { - this.#applyChange( - change, - undefined, - this.#applyChangeToLineAnnotations(change), - { skipSearchRefresh: true } + replace: { + replaceMatch: ([start, end], searchParams): MatchRange => { + const edit = buildReplacementEdit(searchParams, start, end); + applyReplace([edit]); + return [start + edit.text.length, start + edit.text.length]; + }, + replaceAll: (matches, searchParams) => { + applyReplace( + matches.map(([start, end]) => + buildReplacementEdit(searchParams, start, end) + ) ); - } + }, }, onUpdate: ( allMatches: MatchRange[], diff --git a/packages/diffs/src/editor/searchPanel.ts b/packages/diffs/src/editor/searchPanel.ts index 1d3a8a423..94ec11d03 100644 --- a/packages/diffs/src/editor/searchPanel.ts +++ b/packages/diffs/src/editor/searchPanel.ts @@ -1,35 +1,38 @@ -import { - buildSearchReplacementText, - type MatchRange, - type SearchParams, -} from '../search'; -import type { ResolvedTextEdit } from '../types'; +import { type MatchRange, type SearchParams } from '../search'; import { resolveFindAgainShortcut } from './command'; import { isPrimaryModifier } from './platform'; import { getEditorIconSvg, type SVGSpriteNames } from './sprite'; -import type { TextDocument } from './textDocument'; import { h } from './utils'; export type SearchPanelMode = 'find' | 'replace'; export type { MatchRange, SearchParams } from '../search'; -export interface SearchPanelOptions { - textDocument: TextDocument; +export interface SearchPanelReplaceHandlers { + replaceMatch: ( + match: TMatch, + searchParams: SearchParams + ) => TMatch | undefined; + replaceAll: (matches: TMatch[], searchParams: SearchParams) => void; +} + +export interface SearchPanelOptions { containerElement: HTMLElement; defaultQuery: string; mode?: SearchPanelMode; - initialMatch?: MatchRange; - scrollToMatch: (nextMatch: MatchRange, retainFocus: boolean) => void; - applyReplace: (edits: ResolvedTextEdit[]) => void; + initialMatch?: TMatch; + search: (searchParams: SearchParams) => TMatch[]; + isSameMatch?: (a: TMatch, b: TMatch) => boolean; + scrollToMatch: (nextMatch: TMatch, retainFocus: boolean) => void; + replace?: SearchPanelReplaceHandlers; onUpdate: ( - matches: MatchRange[], + matches: TMatch[], options?: { syncSelection?: boolean } - ) => MatchRange | undefined; + ) => TMatch | undefined; onClose: () => void; } -export class SearchPanelWidget { +export class SearchPanelWidget { #container: HTMLDivElement; #inputElement: HTMLInputElement; #updateMatches?: (options?: { syncSelection?: boolean }) => void; @@ -37,19 +40,24 @@ export class SearchPanelWidget { #navigate?: (findPrevious: boolean) => void; #close?: () => void; - constructor(options: SearchPanelOptions) { + constructor(options: SearchPanelOptions) { const { - textDocument, containerElement, defaultQuery, mode = 'find', initialMatch, + search, + isSameMatch = Object.is, scrollToMatch, - applyReplace, + replace, onUpdate, onClose, } = options; + const canReplace = replace !== undefined; + const normalizeMode = (nextMode: SearchPanelMode): SearchPanelMode => + canReplace ? nextMode : 'find'; + const searchParams: SearchParams = { text: defaultQuery, replaceText: '', @@ -59,19 +67,34 @@ export class SearchPanelWidget { }; const matches = { - all: [] as MatchRange[], - current: undefined as MatchRange | undefined, + all: [] as TMatch[], + current: undefined as TMatch | undefined, }; + const getSearchParamsSnapshot = (): SearchParams => ({ ...searchParams }); + const getMatchIndex = (match: TMatch): number => + matches.all.findIndex((candidate) => isSameMatch(candidate, match)); + // Default to the empty-query "no results" state so it shows on open before // any search runs. const matchResultElement = h('div', { dataset: { matches: '', noMatches: '' }, textContent: 'No results', }); + + const updateCurrentMatch = (currentMatch: TMatch | undefined) => { + if (currentMatch === undefined) { + matchResultElement.textContent = `${matches.all.length} results`; + } else { + const index = getMatchIndex(currentMatch); + matchResultElement.textContent = `${index + 1} of ${matches.all.length}`; + } + matches.current = currentMatch; + }; + const updateMatches = (options?: { syncSelection?: boolean }) => { matches.all = - searchParams.text !== '' ? textDocument.search(searchParams) : []; + searchParams.text !== '' ? search(getSearchParamsSnapshot()) : []; const noMatches = matches.all.length === 0; prevButton.disabled = noMatches; nextButton.disabled = noMatches; @@ -98,19 +121,6 @@ export class SearchPanelWidget { }; this.#updateMatches = updateMatches; - const updateCurrentMatch = (currentMatch: MatchRange | undefined) => { - if (currentMatch === undefined) { - matchResultElement.textContent = `${matches.all.length} results`; - } else { - const [start, end] = currentMatch; - const index = matches.all.findIndex( - (m) => m[0] === start && m[1] === end - ); - matchResultElement.textContent = `${index + 1} of ${matches.all.length}`; - } - matches.current = currentMatch; - }; - const updateSearchParam = ( key: K, value: SearchParams[K] @@ -124,26 +134,19 @@ export class SearchPanelWidget { retainFocus: boolean = false ) => { const allMatches = matches.all; - let nextMatch: MatchRange | undefined = allMatches[0]; + let nextMatch: TMatch | undefined = allMatches[0]; if (allMatches.length > 0) { - if (findPrevious) { - const searchOffset = matches.current?.[0] ?? 0; + const currentIndex = + matches.current !== undefined ? getMatchIndex(matches.current) : -1; + if (findPrevious && currentIndex === -1) { nextMatch = allMatches.at(-1); - for (const m of allMatches) { - if (m[1] <= searchOffset) { - nextMatch = m; - } else { - break; - } - } + } else if (findPrevious) { + nextMatch = allMatches.at(currentIndex - 1); + nextMatch ??= allMatches.at(-1); + } else if (currentIndex === -1) { + nextMatch = allMatches[0]; } else { - const searchOffset = matches.current?.[1] ?? 0; - for (const m of allMatches) { - if (m[0] >= searchOffset) { - nextMatch = m; - break; - } - } + nextMatch = allMatches[currentIndex + 1] ?? allMatches[0]; } } if (nextMatch !== undefined) { @@ -156,24 +159,12 @@ export class SearchPanelWidget { this.#navigate = (findPrevious: boolean) => findNextMatch(findPrevious, true); - const buildReplacementEdit = ( - matchStart: number, - matchEnd: number - ): ResolvedTextEdit => ({ - start: matchStart, - end: matchEnd, - text: buildSearchReplacementText( - (offset) => textDocument.positionAt(offset), - (position) => textDocument.offsetAt(position), - (line) => textDocument.getLineText(line), - searchParams, - matchStart, - matchEnd - ), - }); - - const replace = () => { - if (searchParams.text === '' || matches.all.length === 0) { + const replaceCurrentMatch = () => { + if ( + replace === undefined || + searchParams.text === '' || + matches.all.length === 0 + ) { return; } @@ -186,24 +177,29 @@ export class SearchPanelWidget { } } - const [start, end] = currentMatch; - const edit = buildReplacementEdit(start, end); - applyReplace([edit]); + const nextMatch = replace.replaceMatch( + currentMatch, + getSearchParamsSnapshot() + ); // Collapse after the replacement so the next search pass advances. - scrollToMatch([start + edit.text.length, start + edit.text.length], true); + if (nextMatch !== undefined) { + scrollToMatch(nextMatch, true); + } matches.current = undefined; updateMatches(); }; - const replaceAll = () => { - if (searchParams.text === '' || matches.all.length === 0) { + const replaceAllMatches = () => { + if ( + replace === undefined || + searchParams.text === '' || + matches.all.length === 0 + ) { return; } - applyReplace( - matches.all.map(([start, end]) => buildReplacementEdit(start, end)) - ); + replace.replaceAll(matches.all.slice(), getSearchParamsSnapshot()); matches.current = undefined; updateMatches(); }; @@ -265,32 +261,6 @@ export class SearchPanelWidget { const wholeWordToggle = makeToggle('whole-word', 'Whole Word', 'wholeWord'); const regexToggle = makeToggle('regex', 'Regexp', 'regex'); - const replaceInputElement = h('input', { - type: 'text', - placeholder: 'Replace', - dataset: 'replace', - value: '', - oninput: (e: Event) => { - searchParams.replaceText = (e.target as HTMLInputElement).value; - }, - onkeydown: (e: KeyboardEvent) => { - if (e.isComposing) { - return; - } - const findAgain = resolveFindAgainShortcut(e); - if (e.key === 'Escape') { - e.preventDefault(); - close(); - } else if (e.key === 'Enter') { - e.preventDefault(); - replace(); - } else if (findAgain !== undefined) { - e.preventDefault(); - findNextMatch(findAgain === 'previous', true); - } - }, - }); - this.#inputElement = h('input', { type: 'text', placeholder: 'Search', @@ -338,25 +308,6 @@ export class SearchPanelWidget { children: [this.#inputElement, searchTogglesElement], }); - // The replace input and its action buttons are tagged as replace cells so - // they can be hidden together when the panel is in find-only mode. - const replaceInputBox = h('div', { - dataset: { inputBox: '', replace: '', replaceCell: '' }, - children: [replaceInputElement], - }); - - const replaceActionsElement = h('div', { - dataset: { replaceActions: '', replaceCell: '' }, - children: [ - iconButton({ icon: 'replace', label: 'Replace', onClick: replace }), - iconButton({ - icon: 'replace-all', - label: 'Replace All', - onClick: replaceAll, - }), - ], - }); - // Held so the no-results state can toggle their native disabled flag. const prevButton = iconButton({ icon: 'arrow-up', @@ -387,26 +338,75 @@ export class SearchPanelWidget { dataset: { searchClose: '' }, }); + const gridChildren: Node[] = [findInputBox]; + if (canReplace) { + const replaceInputElement = h('input', { + type: 'text', + placeholder: 'Replace', + dataset: 'replace', + value: '', + oninput: (e: Event) => { + searchParams.replaceText = (e.target as HTMLInputElement).value; + }, + onkeydown: (e: KeyboardEvent) => { + if (e.isComposing) { + return; + } + const findAgain = resolveFindAgainShortcut(e); + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } else if (e.key === 'Enter') { + e.preventDefault(); + replaceCurrentMatch(); + } else if (findAgain !== undefined) { + e.preventDefault(); + findNextMatch(findAgain === 'previous', true); + } + }, + }); + + // The replace input and its action buttons are tagged as replace cells so + // they can be hidden together when the panel is in find-only mode. + const replaceInputBox = h('div', { + dataset: { inputBox: '', replace: '', replaceCell: '' }, + children: [replaceInputElement], + }); + + const replaceActionsElement = h('div', { + dataset: { replaceActions: '', replaceCell: '' }, + children: [ + iconButton({ + icon: 'replace', + label: 'Replace', + onClick: replaceCurrentMatch, + }), + iconButton({ + icon: 'replace-all', + label: 'Replace All', + onClick: replaceAllMatches, + }), + ], + }); + + gridChildren.push(replaceInputBox, replaceActionsElement); + } + + gridChildren.push(matchResultElement, navElement, closeElement); + // Cells are positioned by CSS grid-template-areas (see editor.css), so DOM // order here only drives tab/reading order, not layout. Keep the replace // input directly after the find input (and its toggles) so Tab walks // find -> replace before reaching the nav arrows and close button. const gridElement = h('div', { - dataset: { searchGrid: '', mode }, - children: [ - findInputBox, - replaceInputBox, - replaceActionsElement, - matchResultElement, - navElement, - closeElement, - ], + dataset: { searchGrid: '', mode: normalizeMode(mode) }, + children: gridChildren, }); // Toggles the panel between find and find/replace modes by showing or // hiding the replace cells, then returns focus to the find input. const applyMode = (next: SearchPanelMode) => { - gridElement.dataset.mode = next; + gridElement.dataset.mode = normalizeMode(next); this.#inputElement.focus(); this.#inputElement.select(); }; diff --git a/packages/diffs/test/editorSearchPanel.test.ts b/packages/diffs/test/editorSearchPanel.test.ts index c8c66ee2a..763dc99c0 100644 --- a/packages/diffs/test/editorSearchPanel.test.ts +++ b/packages/diffs/test/editorSearchPanel.test.ts @@ -34,6 +34,27 @@ function pressKey( return event; } +function waitForAnimationFrame(): Promise { + return wait(0); +} + +function createContainer(): HTMLElement { + const container = document.createElement('pre'); + document.body.appendChild(container); + return container; +} + +function defaultSearchParams(overrides: Partial): SearchParams { + return { + text: '', + replaceText: '', + caseSensitive: false, + wholeWord: false, + regex: false, + ...overrides, + }; +} + interface WidgetHarness { widget: SearchPanelWidget; input: HTMLInputElement; @@ -74,14 +95,44 @@ function createWidget( const applied: ResolvedTextEdit[][] = []; const updates: (MatchRange[] | undefined)[] = []; + const buildReplacementEdit = ( + [start, end]: MatchRange, + searchParams: SearchParams + ): ResolvedTextEdit => ({ + start, + end, + text: buildSearchReplacementText( + (offset) => textDocument.positionAt(offset), + (position) => textDocument.offsetAt(position), + (line) => textDocument.getLineText(line), + searchParams, + start, + end + ), + }); + let closed = false; const widget = new SearchPanelWidget({ - textDocument, containerElement, defaultQuery, mode, + search: (searchParams) => textDocument.search(searchParams), + isSameMatch: ([aStart, aEnd], [bStart, bEnd]) => + aStart === bStart && aEnd === bEnd, scrollToMatch: (nextMatch) => scrolled.push([...nextMatch]), - applyReplace: (edits) => applied.push(edits), + replace: { + replaceMatch: (match, searchParams): MatchRange => { + const edit = buildReplacementEdit(match, searchParams); + applied.push([edit]); + const nextOffset = edit.start + edit.text.length; + return [nextOffset, nextOffset]; + }, + replaceAll: (matches, searchParams) => { + applied.push( + matches.map((match) => buildReplacementEdit(match, searchParams)) + ); + }, + }, onUpdate: (matches) => { updates.push(matches.map((match) => [...match] as MatchRange)); return selectCurrent ? matches[0] : undefined; @@ -402,23 +453,55 @@ function mountReplaceHost(contents: string): ReplaceHostHarness { scrolled.push([nextMatch[0], nextMatch[1]]); }; + const buildReplacementEdit = ( + [start, end]: MatchRange, + searchParams: SearchParams + ): ResolvedTextEdit => ({ + start, + end, + text: buildSearchReplacementText( + (offset) => textDocument.positionAt(offset), + (position) => textDocument.offsetAt(position), + (line) => textDocument.getLineText(line), + searchParams, + start, + end + ), + }); + + const applyReplace = (edits: ResolvedTextEdit[]) => { + appliedBatches.push(edits); + textDocument.applyEdits( + edits.map((edit) => ({ + range: { + start: textDocument.positionAt(edit.start), + end: textDocument.positionAt(edit.end), + }, + newText: edit.text, + })) + ); + }; + const widget = new SearchPanelWidget({ - textDocument, containerElement, defaultQuery: '', mode: 'replace', + search: (searchParams) => textDocument.search(searchParams), + isSameMatch: ([aStart, aEnd], [bStart, bEnd]) => + aStart === bStart && aEnd === bEnd, scrollToMatch, - applyReplace: (edits) => { - appliedBatches.push(edits); - textDocument.applyEdits( - edits.map((edit) => ({ - range: { - start: textDocument.positionAt(edit.start), - end: textDocument.positionAt(edit.end), - }, - newText: edit.text, - })) - ); + replace: { + replaceMatch: (match, searchParams): MatchRange => { + const edit = buildReplacementEdit(match, searchParams); + applyReplace([edit]); + const nextOffset = edit.start + edit.text.length; + return [nextOffset, nextOffset]; + }, + replaceAll: (matches, searchParams) => { + applyReplace( + matches.map((match) => buildReplacementEdit(match, searchParams)) + ); + }, }, onUpdate: (matches) => { for (const match of matches) { @@ -993,3 +1076,111 @@ describe('search agrees with a string-model oracle under random splices', () => ); }); }); +describe('SearchPanelWidget', () => { + test('runs in find-only mode without replace handlers', async () => { + const dom = installDom(); + try { + const searchCalls: SearchParams[] = []; + const updates: MatchRange[][] = []; + + const widget = new SearchPanelWidget({ + containerElement: createContainer(), + defaultQuery: 'foo', + mode: 'replace', + initialMatch: [0, 3], + search: (params) => { + searchCalls.push(params); + return [[0, 3]]; + }, + scrollToMatch: () => {}, + onUpdate: (matches) => { + updates.push(matches); + return matches[0]; + }, + onClose: () => {}, + }); + + await waitForAnimationFrame(); + + const panel = document.querySelector('[data-search-panel]'); + const grid = panel?.querySelector('[data-search-grid]'); + + expect(searchCalls).toEqual([defaultSearchParams({ text: 'foo' })]); + expect(updates).toEqual([[[0, 3]]]); + expect(grid?.dataset.mode).toBe('find'); + expect(panel?.querySelector('[data-replace]')).toBeNull(); + + widget.setMode('replace'); + expect(grid?.dataset.mode).toBe('find'); + + widget.cleanup(); + } finally { + dom.cleanup(); + } + }); + + test('delegates replace actions to optional replace handlers', async () => { + const dom = installDom(); + try { + const matches: MatchRange[] = [ + [0, 3], + [4, 7], + ]; + const replacedMatches: [MatchRange, SearchParams][] = []; + const scrolledMatches: [MatchRange, boolean][] = []; + const replaceAllCalls: [MatchRange[], SearchParams][] = []; + + const widget = new SearchPanelWidget({ + containerElement: createContainer(), + defaultQuery: 'foo', + mode: 'replace', + initialMatch: matches[0], + search: () => matches, + isSameMatch: ([aStart, aEnd], [bStart, bEnd]) => + aStart === bStart && aEnd === bEnd, + scrollToMatch: (match, retainFocus) => { + scrolledMatches.push([match, retainFocus]); + }, + replace: { + replaceMatch: (match, params) => { + replacedMatches.push([match, params]); + return [11, 11]; + }, + replaceAll: (allMatches, params) => { + replaceAllCalls.push([allMatches, params]); + }, + }, + onUpdate: (allMatches) => allMatches[0], + onClose: () => {}, + }); + + await waitForAnimationFrame(); + + const panel = document.querySelector('[data-search-panel]')!; + const replaceInput = panel.querySelector( + 'input[data-replace]' + )!; + replaceInput.value = 'bar'; + replaceInput.dispatchEvent(new Event('input', { bubbles: true })); + + panel + .querySelector('button[title="Replace"]')! + .click(); + panel + .querySelector('button[title="Replace All"]')! + .click(); + + expect(replacedMatches).toEqual([ + [matches[0], defaultSearchParams({ text: 'foo', replaceText: 'bar' })], + ]); + expect(scrolledMatches).toContainEqual([[11, 11], true]); + expect(replaceAllCalls).toEqual([ + [matches, defaultSearchParams({ text: 'foo', replaceText: 'bar' })], + ]); + + widget.cleanup(); + } finally { + dom.cleanup(); + } + }); +}); From cf467329ace3b5fba23b56c75014b2b21e61852a Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Wed, 17 Jun 2026 18:32:41 -0700 Subject: [PATCH 3/5] AI did it again, and just completely built everything... I need to more deeply review this... it's probably a fuck ton --- packages/diffs/src/components/CodeView.ts | 637 +++++++++++++++++- packages/diffs/src/components/File.ts | 4 + packages/diffs/src/components/FileDiff.ts | 4 + .../src/components/VirtualizedFileDiff.ts | 5 + packages/diffs/src/editor/editor.css | 216 ------ packages/diffs/src/editor/searchPanel.css | 300 +++++++++ packages/diffs/src/editor/searchPanel.ts | 43 +- .../diffs/src/renderers/DiffHunksRenderer.ts | 66 +- packages/diffs/src/renderers/FileRenderer.ts | 33 +- packages/diffs/src/style.css | 17 + packages/diffs/src/types.ts | 11 + .../diffs/src/utils/applySearchDecorations.ts | 180 +++++ .../diffs/test/CodeView.searchPanel.test.ts | 440 ++++++++++++ .../diffs/test/editorCollapsedEdit.test.ts | 8 +- packages/diffs/test/editorComposition.test.ts | 4 +- packages/diffs/test/editorSearchPanel.test.ts | 27 +- .../diffs/test/editorVirtualizedEdit.test.ts | 8 +- 17 files changed, 1754 insertions(+), 249 deletions(-) create mode 100644 packages/diffs/src/editor/searchPanel.css create mode 100644 packages/diffs/src/utils/applySearchDecorations.ts create mode 100644 packages/diffs/test/CodeView.searchPanel.test.ts diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index db35c8083..81b7db51f 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -10,11 +10,19 @@ import { THEME_CSS_ATTRIBUTE, UNSAFE_CSS_ATTRIBUTE, } from '../constants'; +import { isPrimaryModifier } from '../editor/platform'; +import { SearchPanelWidget } from '../editor/searchPanel'; import type { SelectionWriteOptions } from '../managers/InteractionManager'; import { dequeueRender, queueRender, } from '../managers/UniversalRenderingManager'; +import { + type LineByLineSearchDocument, + MAX_FIND_MATCHES, + searchLineByLine, + type SearchParams, +} from '../search'; import type { CodeViewCreateEditorOptions, CodeViewDiffItem, @@ -28,11 +36,13 @@ import type { CodeViewScrollBehavior, CodeViewScrollTarget, DiffLineAnnotation, + DiffSearchLineDecoration, DiffsEditor, FileContents, HunkSeparators, LineAnnotation, PendingCodeViewLayoutReset, + SearchLineDecoration, SelectedLineRange, SelectionSide, SmoothScrollSettings, @@ -44,10 +54,13 @@ import { areObjectsEqual } from '../utils/areObjectsEqual'; import { areOptionsEqual } from '../utils/areOptionsEqual'; import { areSelectionsEqual } from '../utils/areSelectionsEqual'; import { areThemesEqual } from '../utils/areThemesEqual'; +import { linesFromFileContents } from '../utils/computeFileOffsets'; import { createCodeViewHeaderFooterHostElement } from '../utils/createCodeViewHeaderFooterHostElement'; import { createWindowFromScrollPosition } from '../utils/createWindowFromScrollPosition'; import { finishEditSessionForDiff } from '../utils/editSessionHunks'; import { isStyleNode } from '../utils/isStyleNode'; +import type { DiffLineMetadata } from '../utils/iterateOverDiff'; +import { iterateOverDiff } from '../utils/iterateOverDiff'; import { prefersReducedMotion } from '../utils/prefersReducedMotion'; import { roundToDevicePixel } from '../utils/roundToDevicePixel'; import type { WorkerPoolManager } from '../worker'; @@ -108,6 +121,33 @@ interface PagedScrollPosition { scrollPageOffset: number; } +interface CodeViewSearchLineMetadata { + itemId: string; + itemIndex: number; + itemType: CodeViewItem['type']; + side: SelectionSide | undefined; + lineNumber: number; + lineIndex: number; + renderedLineIndex: number; +} + +interface CodeViewSearchLine { + text: string; + metadata: CodeViewSearchLineMetadata; +} + +interface CodeViewSearchMatch extends CodeViewSearchLineMetadata { + startCharacter: number; + endCharacter: number; +} + +interface CodeViewSearchState { + params: SearchParams | undefined; + matches: CodeViewSearchMatch[]; + matchesByItem: Map; + current: CodeViewSearchMatch | undefined; +} + interface AdvancedVirtualizedBaseItem { /** Current index of this record in the ordered items array. */ index: number; @@ -122,6 +162,8 @@ interface AdvancedVirtualizedBaseItem { version: number | undefined; /** Last CodeView option revision this item rendered with. */ renderedOptionsRevision: number; + /** Last CodeView search-highlight revision this item rendered with. */ + renderedSearchRevision: number; } interface CodeViewDiffItemContext< @@ -739,6 +781,15 @@ export class CodeView { private root: HTMLElement | undefined; private resizeObserver: ResizeObserver | undefined; + private searchPanel: SearchPanelWidget | undefined; + private searchState: CodeViewSearchState = { + params: undefined, + matches: [], + matchesByItem: new Map(), + current: undefined, + }; + private searchMatchesDirty = false; + private searchRenderRevision = 0; private container: HTMLDivElement | undefined = document.createElement('div'); private stickyContainer = document.createElement('div'); @@ -1123,6 +1174,7 @@ export class CodeView { this.root.addEventListener('pointerdown', this.clearPendingScroll, { passive: true, }); + this.root.addEventListener('keydown', this.handleSearchKeyDown); this.root.addEventListener('keydown', this.clearPendingScroll, { passive: true, }); @@ -1160,6 +1212,7 @@ export class CodeView { public reset(): void { dequeueRender(this.computeRenderRangeAndEmit); this.clearReadySubscription(); + this.closeSearchPanel(); this.restoreScrollInteractions(); this.cleanAllRenderedItems(); // Rendered-item cleanup above already detached mounted editors; cleaning @@ -1198,7 +1251,9 @@ export class CodeView { } public cleanUp(): void { + dequeueRender(this.computeRenderRangeAndEmit); this.reset(); + dequeueRender(this.computeRenderRangeAndEmit); this.clearElementPool(); this.restoreScrollInteractions(); this.workerManager?.unsubscribeToThemeChanges(this); @@ -1208,6 +1263,7 @@ export class CodeView { this.root?.removeEventListener('wheel', this.clearPendingScroll); this.root?.removeEventListener('touchstart', this.clearPendingScroll); this.root?.removeEventListener('pointerdown', this.clearPendingScroll); + this.root?.removeEventListener('keydown', this.handleSearchKeyDown); this.root?.removeEventListener('keydown', this.clearPendingScroll); this.root?.style.removeProperty('overflow-anchor'); this.container?.remove(); @@ -1226,6 +1282,331 @@ export class CodeView { this.container = undefined; } + private openSearchPanel(): void { + const container = this.container; + if (container === undefined) { + return; + } + + if (this.searchPanel !== undefined) { + this.searchPanel.setMode('find'); + this.searchPanel.focus(); + return; + } + + this.searchPanel = new SearchPanelWidget({ + containerElement: container, + defaultQuery: '', + mode: 'find', + search: (searchParams) => { + const matches = this.searchCodeView(searchParams); + this.searchState.params = searchParams; + return matches; + }, + isSameMatch: areCodeViewSearchMatchesEqual, + scrollToMatch: (match) => { + this.scrollToSearchMatch(match); + }, + onUpdate: (matches) => { + const current = this.searchState.current; + if (current !== undefined) { + const nextCurrent = matches.find((match) => + areCodeViewSearchMatchesEqual(match, current) + ); + if (nextCurrent !== undefined) { + this.setSearchResults(matches, nextCurrent); + return nextCurrent; + } + } + this.setSearchResults(matches, undefined); + return undefined; + }, + onClose: () => { + this.searchPanel = undefined; + this.resetSearchState(); + }, + }); + this.applySearchPanelOverlayStyles(); + this.searchPanel.focus(); + } + + private closeSearchPanel(): void { + this.searchPanel?.cleanup(); + this.searchPanel = undefined; + this.resetSearchState(); + } + + private resetSearchState(): void { + const hadSearchDecorations = + this.searchState.matches.length > 0 || + this.searchState.current !== undefined; + this.searchState.params = undefined; + this.searchState.matches = []; + this.searchState.matchesByItem = new Map(); + this.searchState.current = undefined; + this.searchMatchesDirty = false; + if (hadSearchDecorations) { + this.invalidateSearchDecorations(); + } + } + + private scrollToSearchMatch(match: CodeViewSearchMatch): void { + this.setCurrentSearchMatch(match); + this.scrollTo({ + type: 'line', + id: match.itemId, + lineNumber: match.lineNumber, + side: match.side, + align: 'center', + behavior: 'smooth-auto', + }); + } + + private markSearchMatchesDirty(): void { + if ( + this.searchPanel === undefined || + this.searchState.params === undefined + ) { + return; + } + + this.searchMatchesDirty = true; + this.render(); + } + + private flushSearchMatches(): void { + if (!this.searchMatchesDirty) { + return; + } + + this.searchMatchesDirty = false; + if ( + this.searchPanel === undefined || + this.searchState.params === undefined + ) { + return; + } + + this.searchPanel.updateMatches({ syncSelection: false }); + } + + private setSearchResults( + matches: CodeViewSearchMatch[], + current: CodeViewSearchMatch | undefined + ): void { + const matchesChanged = !areCodeViewSearchMatchArraysEqual( + this.searchState.matches, + matches + ); + const currentChanged = !areOptionalCodeViewSearchMatchesEqual( + this.searchState.current, + current + ); + + if (matchesChanged) { + this.searchState.matches = matches; + this.searchState.matchesByItem = + groupCodeViewSearchMatchesByItem(matches); + } + this.searchState.current = current; + + if (matchesChanged || currentChanged) { + this.invalidateSearchDecorations(); + } + } + + private setCurrentSearchMatch(match: CodeViewSearchMatch): void { + if ( + areOptionalCodeViewSearchMatchesEqual(this.searchState.current, match) + ) { + return; + } + + this.searchState.current = match; + this.invalidateSearchDecorations(); + } + + private invalidateSearchDecorations(): void { + this.searchRenderRevision++; + this.render(); + } + + private getSearchDecorationsForItem( + item: CodeViewContextItem + ): + | readonly SearchLineDecoration[] + | readonly DiffSearchLineDecoration[] + | undefined { + const matches = this.searchState.matchesByItem.get(item.item.id); + if (matches == null || matches.length === 0) { + return undefined; + } + + const current = this.searchState.current; + if (item.type === 'file') { + return matches.map( + (match): SearchLineDecoration => ({ + lineIndex: match.lineIndex, + startCharacter: match.startCharacter, + endCharacter: match.endCharacter, + current: + current !== undefined && + areCodeViewSearchMatchesEqual(match, current), + }) + ); + } + + return matches.flatMap((match): DiffSearchLineDecoration[] => { + if (match.side === undefined) { + return []; + } + return [ + { + side: match.side, + lineIndex: match.lineIndex, + startCharacter: match.startCharacter, + endCharacter: match.endCharacter, + current: + current !== undefined && + areCodeViewSearchMatchesEqual(match, current), + }, + ]; + }); + } + + private searchCodeView(searchParams: SearchParams): CodeViewSearchMatch[] { + const matches: CodeViewSearchMatch[] = []; + for (const item of this.items) { + const remaining = MAX_FIND_MATCHES - matches.length; + if (remaining <= 0) { + break; + } + + const nextMatches = + item.type === 'file' + ? this.searchFileItem(item, searchParams, remaining) + : this.searchDiffItem(item, searchParams, remaining); + matches.push(...nextMatches); + } + return matches; + } + + private searchFileItem( + item: CodeViewFileItemContext, + searchParams: SearchParams, + limit: number + ): CodeViewSearchMatch[] { + if (item.item.collapsed === true) { + return []; + } + + const lines = linesFromFileContents(item.item.file.contents).map( + (text, lineIndex): CodeViewSearchLine => ({ + text, + metadata: { + itemId: item.item.id, + itemIndex: item.index, + itemType: 'file', + side: undefined, + lineNumber: lineIndex + 1, + lineIndex, + renderedLineIndex: lineIndex, + }, + }) + ); + + return collectCodeViewLineMatches(lines, searchParams, limit); + } + + private searchDiffItem( + item: CodeViewDiffItemContext, + searchParams: SearchParams, + limit: number + ): CodeViewSearchMatch[] { + if (item.item.collapsed === true) { + return []; + } + + const fileDiff = item.item.fileDiff; + const diffStyle = this.options.diffStyle ?? 'split'; + const expandedHunks = + this.options.expandUnchanged === true + ? true + : item.instance.getExpandedHunksForSearch(); + const lines: CodeViewSearchLine[] = []; + + const addLine = ( + side: SelectionSide, + line: DiffLineMetadata | undefined + ): void => { + if (line === undefined) { + return; + } + + const sourceLines = + side === 'additions' ? fileDiff.additionLines : fileDiff.deletionLines; + const text = sourceLines[line.lineIndex]; + if (text === undefined) { + return; + } + + lines.push({ + text, + metadata: { + itemId: item.item.id, + itemIndex: item.index, + itemType: 'diff', + side, + lineNumber: line.lineNumber, + lineIndex: line.lineIndex, + renderedLineIndex: + diffStyle === 'unified' + ? line.unifiedLineIndex + : line.splitLineIndex, + }, + }); + }; + + iterateOverDiff({ + diff: fileDiff, + diffStyle, + expandedHunks, + collapsedContextThreshold: + this.options.collapsedContextThreshold ?? + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, + callback: ({ additionLine, deletionLine }) => { + if (diffStyle === 'unified') { + if ( + additionLine !== undefined && + fileDiff.additionLines[additionLine.lineIndex] !== undefined + ) { + addLine('additions', additionLine); + } else { + addLine('deletions', deletionLine); + } + return; + } + + addLine('deletions', deletionLine); + addLine('additions', additionLine); + }, + }); + + return collectCodeViewLineMatches(lines, searchParams, limit); + } + + private applySearchPanelOverlayStyles(): void { + const panelElement = this.container?.previousElementSibling; + if ( + !(panelElement instanceof HTMLElement) || + panelElement.dataset.searchPanel === undefined + ) { + return; + } + + panelElement.dataset.searchPanelOverlay = ''; + } + private cleanAllRenderedItems() { if (this.renderState.firstIndex === -1) { return; @@ -1504,6 +1885,7 @@ export class CodeView { this.render(); this.syncItemEditors(); this.syncSelection(); + this.markSearchMatchesDirty(); return true; } @@ -1543,6 +1925,7 @@ export class CodeView { } this.renamePendingScrollTarget(oldId, newId); this.renamePendingLayoutAnchor(oldId, newId); + this.markSearchMatchesDirty(); this.render(); return true; } @@ -1555,6 +1938,7 @@ export class CodeView { this.appendItemsInternal(inputs); this.syncItemEditors(); this.syncSelection(); + this.markSearchMatchesDirty(); } public removeItem(itemId: string): boolean { @@ -1583,6 +1967,7 @@ export class CodeView { } this.syncItemEditors(removedItemsById); this.syncSelection(); + this.markSearchMatchesDirty(); } /** @@ -1707,6 +2092,9 @@ export class CodeView { ) { this.render(); } + if (hasSearchIndexOptionChanged(prevOptions, options)) { + this.markSearchMatchesDirty(); + } } public capturePendingLayoutAnchor( @@ -1796,6 +2184,7 @@ export class CodeView { if (layoutDirty) { this.markItemLayoutDirty(item); } + this.markSearchMatchesDirty(); this.render(); } @@ -1941,6 +2330,7 @@ export class CodeView { height: 0, element: undefined, renderedOptionsRevision: this.renderOptionsRevision, + renderedSearchRevision: this.searchRenderRevision, instance, } satisfies CodeViewDiffItemContext; } @@ -1961,6 +2351,7 @@ export class CodeView { height: 0, element: undefined, renderedOptionsRevision: this.renderOptionsRevision, + renderedSearchRevision: this.searchRenderRevision, instance, } satisfies CodeViewFileItemContext; } @@ -3147,6 +3538,8 @@ export class CodeView { this.syncContainerHeight(); } + this.flushSearchMatches(); + // Resolve the logical scrollTop this render frame should target. The paged // root scrollTop is derived later only if the scaffold needs to move. const targetScrollTop = this.computeTargetScrollTopForFrame( @@ -3243,8 +3636,16 @@ export class CodeView { item.element = this.acquireElement(); syncRenderedItemOrder(this.stickyContainer, item.element, prevElement); instance.virtualizedSetup(); - if (renderItem(item, item.element)) { + if ( + renderItem( + item, + item.element, + false, + this.getSearchDecorationsForItem(item) + ) + ) { item.renderedOptionsRevision = this.renderOptionsRevision; + item.renderedSearchRevision = this.searchRenderRevision; updatedItems.add(item); } prevElement = item.element; @@ -3253,9 +3654,18 @@ export class CodeView { else { syncRenderedItemOrder(this.stickyContainer, item.element, prevElement); const forceRender = - item.renderedOptionsRevision !== this.renderOptionsRevision; - if (renderItem(item, undefined, forceRender)) { + item.renderedOptionsRevision !== this.renderOptionsRevision || + item.renderedSearchRevision !== this.searchRenderRevision; + if ( + renderItem( + item, + undefined, + forceRender, + this.getSearchDecorationsForItem(item) + ) + ) { item.renderedOptionsRevision = this.renderOptionsRevision; + item.renderedSearchRevision = this.searchRenderRevision; updatedItems.add(item); } prevElement = item.element; @@ -3546,6 +3956,28 @@ export class CodeView { this.scrollAnimation = undefined; }; + private handleSearchKeyDown = (event: KeyboardEvent): void => { + if (event.defaultPrevented) { + return; + } + + if (event.key === 'Escape' && this.searchPanel !== undefined) { + event.preventDefault(); + this.closeSearchPanel(); + return; + } + + if ( + isPrimaryModifier(event) && + (event.key === 'f' || event.code === 'KeyF') + ) { + // Prevent the browser find UI and open CodeView's find-only panel. + event.preventDefault(); + this.openSearchPanel(); + this.searchPanel?.setMode(event.altKey ? 'replace' : 'find'); + } + }; + private handleResize = (entries: ResizeObserverEntry[]) => { let shouldRender = false; for (const entry of entries) { @@ -4070,6 +4502,178 @@ export class CodeView { } } +class CodeViewLineSearchDocument implements LineByLineSearchDocument { + readonly lines: CodeViewSearchLine[]; + readonly lineStarts: number[] = []; + readonly textLength: number; + + constructor(lines: readonly CodeViewSearchLine[]) { + this.lines = lines.map(({ text, metadata }) => ({ + text: trimLineEnding(text), + metadata, + })); + + let offset = 0; + for (const line of this.lines) { + this.lineStarts.push(offset); + offset += line.text.length + 1; + } + + this.textLength = this.lines.length === 0 ? 0 : offset - 1; + } + + get lineCount(): number { + return this.lines.length; + } + + getLineText(line: number): string { + return this.lines[line]?.text ?? ''; + } + + getLineStartOffset(line: number): number { + return this.lineStarts[line] ?? this.textLength; + } + + getMetadata(line: number): CodeViewSearchLineMetadata { + const metadata = this.lines[line]?.metadata; + if (metadata === undefined) { + throw new Error('CodeViewLineSearchDocument.getMetadata: invalid line'); + } + return metadata; + } + + charAt(offset: number): string { + if (offset < 0 || offset >= this.textLength || this.lines.length === 0) { + return ''; + } + + const lineIndex = this.getLineIndexAtOffset(offset); + const line = this.lines[lineIndex]; + const lineStart = this.getLineStartOffset(lineIndex); + if (line === undefined) { + return ''; + } + + const character = offset - lineStart; + return character < line.text.length ? line.text.charAt(character) : '\n'; + } + + getLineIndexAtOffset(offset: number): number { + let low = 0; + let high = this.lineStarts.length - 1; + let result = 0; + + while (low <= high) { + const mid = (low + high) >> 1; + const lineStart = this.lineStarts[mid] ?? 0; + if (lineStart <= offset) { + result = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return result; + } +} + +function collectCodeViewLineMatches( + lines: readonly CodeViewSearchLine[], + searchParams: SearchParams, + limit: number +): CodeViewSearchMatch[] { + if (lines.length === 0 || limit <= 0) { + return []; + } + + const document = new CodeViewLineSearchDocument(lines); + const ranges = searchLineByLine(document, searchParams, limit); + return ranges.map(([startOffset, endOffset]) => { + const lineIndex = document.getLineIndexAtOffset(startOffset); + const lineStart = document.getLineStartOffset(lineIndex); + return { + ...document.getMetadata(lineIndex), + startCharacter: startOffset - lineStart, + endCharacter: endOffset - lineStart, + }; + }); +} + +function trimLineEnding(text: string): string { + let end = text.length; + while (end > 0) { + const charCode = text.charCodeAt(end - 1); + if (charCode !== 10 && charCode !== 13) { + break; + } + end--; + } + return end === text.length ? text : text.slice(0, end); +} + +function areCodeViewSearchMatchesEqual( + a: CodeViewSearchMatch, + b: CodeViewSearchMatch +): boolean { + return ( + a.itemId === b.itemId && + a.itemType === b.itemType && + a.side === b.side && + a.lineNumber === b.lineNumber && + a.lineIndex === b.lineIndex && + a.renderedLineIndex === b.renderedLineIndex && + a.startCharacter === b.startCharacter && + a.endCharacter === b.endCharacter + ); +} + +function areOptionalCodeViewSearchMatchesEqual( + a: CodeViewSearchMatch | undefined, + b: CodeViewSearchMatch | undefined +): boolean { + if (a === undefined || b === undefined) { + return a === b; + } + return areCodeViewSearchMatchesEqual(a, b); +} + +function areCodeViewSearchMatchArraysEqual( + a: readonly CodeViewSearchMatch[], + b: readonly CodeViewSearchMatch[] +): boolean { + if (a === b) { + return true; + } + if (a.length !== b.length) { + return false; + } + for (let index = 0; index < a.length; index++) { + const aMatch = a[index]; + const bMatch = b[index]; + if ( + aMatch === undefined || + bMatch === undefined || + !areCodeViewSearchMatchesEqual(aMatch, bMatch) + ) { + return false; + } + } + return true; +} + +function groupCodeViewSearchMatchesByItem( + matches: readonly CodeViewSearchMatch[] +): Map { + const grouped = new Map(); + for (const match of matches) { + const itemMatches = grouped.get(match.itemId) ?? []; + itemMatches.push(match); + grouped.set(match.itemId, itemMatches); + } + return grouped; +} + function prepareItemInstance( item: CodeViewContextItem ): number { @@ -4133,6 +4737,22 @@ function hasItemLayoutOptionChanged( ); } +function hasSearchIndexOptionChanged( + previousOptions: CodeViewOptions, + nextOptions: CodeViewOptions +): boolean { + return ( + (previousOptions.diffStyle ?? 'split') !== + (nextOptions.diffStyle ?? 'split') || + (previousOptions.expandUnchanged ?? false) !== + (nextOptions.expandUnchanged ?? false) || + (previousOptions.collapsedContextThreshold ?? + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD) !== + (nextOptions.collapsedContextThreshold ?? + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD) + ); +} + function hasCodeViewDiffEstimateOptionChanged( previousOptions: CodeViewOptions, nextOptions: CodeViewOptions @@ -4183,7 +4803,10 @@ function formatSelectedLinePoint( function renderItem( item: CodeViewContextItem, fileContainer?: HTMLElement, - forceRender = false + forceRender = false, + searchDecorations?: + | readonly SearchLineDecoration[] + | readonly DiffSearchLineDecoration[] ): boolean { if (item.type === 'diff') { return item.instance.render({ @@ -4192,6 +4815,9 @@ function renderItem( fileDiff: item.item.fileDiff, forceRender, lineAnnotations: item.item.annotations ?? [], + searchDecorations: searchDecorations as + | readonly DiffSearchLineDecoration[] + | undefined, }); } else { return item.instance.render({ @@ -4200,6 +4826,9 @@ function renderItem( file: item.item.file, forceRender, lineAnnotations: item.item.annotations ?? [], + searchDecorations: searchDecorations as + | readonly SearchLineDecoration[] + | undefined, }); } } diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 99fccac34..bc8cd80d6 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -37,6 +37,7 @@ import type { PrePropertiesConfig, RenderFileMetadata, RenderRange, + SearchLineDecoration, SelectedLineRange, ThemeTypes, } from '../types'; @@ -78,6 +79,7 @@ export interface FileRenderProps { preventEmit?: boolean; lineAnnotations?: LineAnnotation[]; renderRange?: RenderRange; + searchDecorations?: readonly SearchLineDecoration[]; } export interface FileHydrateProps extends Omit< @@ -633,6 +635,7 @@ export class File< deferManagers = false, lineAnnotations, renderRange, + searchDecorations, }: FileRenderProps): boolean { // postpone background tokenizing to next frame for avoiding UI freeze // during render @@ -672,6 +675,7 @@ export class File< this.setLineAnnotations(lineAnnotations); } this.fileRenderer.setLineAnnotations(this.lineAnnotations); + this.fileRenderer.setSearchDecorations(searchDecorations); const { disableErrorHandling = false, disableFileHeader = false } = this.options; diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 294926785..f58fd9d0a 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -40,6 +40,7 @@ import type { BaseDiffOptions, CustomPreProperties, DiffLineAnnotation, + DiffSearchLineDecoration, DiffsEditableComponent, DiffsEditor, DiffsTextDocument, @@ -140,6 +141,7 @@ export interface FileDiffRenderBaseProps { containerWrapper?: HTMLElement; lineAnnotations?: DiffLineAnnotation[]; renderRange?: RenderRange; + searchDecorations?: readonly DiffSearchLineDecoration[]; } export type FileDiffRenderProps = @@ -987,6 +989,7 @@ export class FileDiff< fileContainer, containerWrapper, renderRange, + searchDecorations, ...fileInputProps }: FileDiffRenderProps): boolean { const fileInput = getDiffFileInput(fileInputProps, 'FileDiff.render'); @@ -1107,6 +1110,7 @@ export class FileDiff< this.syncInteractionOptions(); this.hunksRenderer.setLineAnnotations(this.lineAnnotations); + this.hunksRenderer.setSearchDecorations(searchDecorations); const { disableErrorHandling = false, disableFileHeader = false } = this.options; diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index 76b20e160..197740135 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -7,6 +7,7 @@ import type { FileContents, FileDiffMetadata, Hunk, + HunkExpansionRegion, HunkSeparators, NumericScrollLineAnchor, PendingCodeViewLayoutReset, @@ -991,6 +992,10 @@ export class VirtualizedFileDiff< return !this.isAdvancedMode() && super.shouldSelfHealEditSession(); } + public getExpandedHunksForSearch(): Map { + return this.hunksRenderer.getExpandedHunksMap(); + } + public setVisibility(visible: boolean): void { if (this.isAdvancedMode() || this.fileContainer == null) { return; diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css index a65a8bf78..6fbff99bc 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -305,219 +305,3 @@ padding: 4px; pointer-events: auto; } - -/* Search Panel Widget */ -[data-search-panel] { - position: sticky; - top: 16px; - right: 16px; - z-index: 100; - display: flex; - justify-content: right; - flex-direction: column; - width: 100%; - /* Zero height ensures panel appears stickied immediately */ - height: 0; - container: search-panel / inline-size; - - [data-editor-widget] { - position: relative; - z-index: 100; - display: flex; - flex-shrink: 0; - align-items: stretch; - gap: 8px; - padding: 4px; - max-width: 100%; - min-width: 260px; - margin-inline: auto 16px; - } - - svg { - display: block; - fill: currentColor; - width: 12px; - height: 12px; - } -} - -[data-search-grid] { - display: grid; - grid-template-columns: auto auto auto; - grid-template-areas: - 'find matches nav' - 'replace actions .'; - align-items: center; - gap: 4px 6px; - width: 100%; -} -[data-input-box][data-find] { - grid-area: find; -} -[data-search-nav] { - grid-area: nav; -} -[data-input-box][data-replace] { - grid-area: replace; -} -[data-replace-actions] { - grid-area: actions; -} -[data-search-grid][data-mode='find'] { - grid-template-areas: 'find matches nav'; -} -[data-search-grid][data-mode='find'] [data-replace-cell] { - display: none; -} - -@container search-panel (width < 400px) { - [data-search-panel] [data-editor-widget] { - padding: 10px; - } - [data-search-grid][data-mode='replace'] { - grid-template-columns: auto 1fr auto; - grid-template-areas: - 'find find find' - 'replace replace replace' - 'nav matches actions'; - } - [data-search-grid][data-mode='find'] { - grid-template-columns: auto 1fr auto; - grid-template-areas: - 'find find find' - 'nav matches matches'; - } - [data-replace-actions] { - justify-self: end; - } - [data-search-grid] [data-input-box] { - width: auto; - } -} - -[data-input-box] { - position: relative; - display: flex; - align-items: center; - width: 200px; - - input { - flex-grow: 1; - width: 100%; - min-width: 0; - font-size: 13px; - line-height: 24px; - padding-inline: 6px; - color: var(--diffs-fg); - background-color: var(--diffs-bg); - border: 1px solid color-mix(in lab, var(--diffs-fg) 12%, var(--diffs-bg)); - border-radius: 6px; - outline: none; - - &::selection { - background-color: color-mix(in lab, var(--diffs-fg) 8%, var(--diffs-bg)); - } - - &:focus-visible { - outline: 1px solid var(--diffs-modified-base); - } - } - - &[data-find] input { - /* Space for overlaid search icon toggles */ - padding-inline-end: 72px; - } - - [data-search-icon] { - --diffs-search-icon-size: 20px; - } -} - -[data-search-toggles] { - position: absolute; - top: 50%; - right: 4px; - transform: translateY(-50%); - display: flex; - align-items: center; - gap: 1px; -} - -[data-matches] { - grid-area: matches; - flex-shrink: 0; - min-width: 50px; - font-size: 12px; - font-weight: 500; - line-height: 20px; - white-space: nowrap; - color: color-mix(in lab, var(--diffs-fg) 50%, var(--diffs-bg)); - - &[data-no-matches] { - color: color-mix(in lab, var(--diffs-fg) 35%, var(--diffs-bg)); - } -} - -[data-replace-actions], -[data-search-nav] { - display: flex; - align-items: center; -} - -/* Every clickable control is a real