diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index db35c8083..3cae47435 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -10,11 +10,20 @@ import { THEME_CSS_ATTRIBUTE, UNSAFE_CSS_ATTRIBUTE, } from '../constants'; +import { resolveFindAgainShortcut } from '../editor/command'; +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 +37,13 @@ import type { CodeViewScrollBehavior, CodeViewScrollTarget, DiffLineAnnotation, + DiffSearchLineDecoration, DiffsEditor, FileContents, HunkSeparators, LineAnnotation, PendingCodeViewLayoutReset, + SearchLineDecoration, SelectedLineRange, SelectionSide, SmoothScrollSettings, @@ -44,10 +55,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 +122,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 +163,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 +782,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 +1175,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 +1213,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 +1252,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 +1264,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 +1283,338 @@ 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, options) => { + 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; + } + } + if (matches.length === 0 || options?.syncSelection === false) { + this.setSearchResults(matches, undefined); + return undefined; + } + + const nextCurrent = matches[0]; + this.setSearchResults(matches, nextCurrent); + this.scrollToSearchMatch(nextCurrent); + return nextCurrent; + }, + 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 +1893,7 @@ export class CodeView { this.render(); this.syncItemEditors(); this.syncSelection(); + this.markSearchMatchesDirty(); return true; } @@ -1543,6 +1933,7 @@ export class CodeView { } this.renamePendingScrollTarget(oldId, newId); this.renamePendingLayoutAnchor(oldId, newId); + this.markSearchMatchesDirty(); this.render(); return true; } @@ -1555,6 +1946,7 @@ export class CodeView { this.appendItemsInternal(inputs); this.syncItemEditors(); this.syncSelection(); + this.markSearchMatchesDirty(); } public removeItem(itemId: string): boolean { @@ -1583,6 +1975,7 @@ export class CodeView { } this.syncItemEditors(removedItemsById); this.syncSelection(); + this.markSearchMatchesDirty(); } /** @@ -1707,6 +2100,9 @@ export class CodeView { ) { this.render(); } + if (hasSearchIndexOptionChanged(prevOptions, options)) { + this.markSearchMatchesDirty(); + } } public capturePendingLayoutAnchor( @@ -1796,6 +2192,7 @@ export class CodeView { if (layoutDirty) { this.markItemLayoutDirty(item); } + this.markSearchMatchesDirty(); this.render(); } @@ -1941,6 +2338,7 @@ export class CodeView { height: 0, element: undefined, renderedOptionsRevision: this.renderOptionsRevision, + renderedSearchRevision: this.searchRenderRevision, instance, } satisfies CodeViewDiffItemContext; } @@ -1961,6 +2359,7 @@ export class CodeView { height: 0, element: undefined, renderedOptionsRevision: this.renderOptionsRevision, + renderedSearchRevision: this.searchRenderRevision, instance, } satisfies CodeViewFileItemContext; } @@ -3147,6 +3546,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 +3644,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 +3662,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; @@ -3537,15 +3955,48 @@ export class CodeView { this.render(); }; - // Abort any in-flight programmatic scroll when the user takes over. - // Attached to root as a passive listener for wheel / touchstart / - // pointerdown / keydown; we never mutate the event, just drop our state. - private clearPendingScroll = (): void => { + // Abort any in-flight programmatic scroll when the user takes over. Handled + // shortcuts may start their own programmatic scroll, so leave those intact. + private clearPendingScroll = (event?: Event): void => { + if (event?.defaultPrevented === true) { + return; + } this.pendingScrollTarget = undefined; this.pendingLayoutAnchor = undefined; 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 (this.searchPanel !== undefined) { + const findAgain = resolveFindAgainShortcut(event); + if (findAgain !== undefined) { + event.preventDefault(); + this.searchPanel.navigate(findAgain === 'previous'); + 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 +4521,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 +4756,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 +4822,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 +4834,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 +4845,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..c4660871f 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -115,10 +115,14 @@ } [data-match-range] { z-index: -10; + border: 0; + border-radius: 3px; background-color: var( --diffs-editor-match-bg, var(--diffs-editor-selection-bg) ); + outline: none; + box-decoration-break: clone; } [data-match-range]:not([data-focus]) { background-color: var( @@ -305,219 +309,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