chrome-devtools-frontend
Version:
Chrome DevTools UI
365 lines (329 loc) • 14.4 kB
text/typescript
// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import type * as Workspace from '../../models/workspace/workspace.js';
import * as UI from '../../ui/legacy/legacy.js';
import {html, render, type TemplateResult} from '../../ui/lit/lit.js';
import searchResultsPaneStyles from './searchResultsPane.css.js';
import type {SearchResult} from './SearchScope.js';
const UIStrings = {
/**
* @description Accessibility label for number of matches in each file in search results pane
* @example {2} PH1
*/
matchesCountS: 'Matches Count {PH1}',
/**
* @description Search result label for results in the Search tool
* @example {2} PH1
*/
lineS: 'Line {PH1}',
/**
* @description Text in Search Results Pane of the Search tab
* @example {2} PH1
*/
showDMore: 'Show {PH1} more',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/search/SearchResultsPane.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
interface SearchMatch {
lineContent: string;
matchRanges: TextUtils.TextRange.SourceRange[];
resultLabel: string|number;
}
interface ViewInput {
results: SearchResult[];
matches: WeakMap<SearchResult, SearchMatch[]>;
expandedResults: WeakSet<SearchResult>;
onSelectMatch: (searchResult: SearchResult, matchIndex: number) => void;
onExpandSearchResult: (searchResult: SearchResult) => void;
onShowMoreMatches: (searchResult: SearchResult) => void;
}
export type View = (input: ViewInput, output: unknown, target: HTMLElement) => void;
export const DEFAULT_VIEW: View = (input, _output, target) => {
const {results, matches, expandedResults, onSelectMatch, onExpandSearchResult, onShowMoreMatches} = input;
const onExpand =
(searchResult: SearchResult, {detail: {expanded}}: UI.TreeOutline.TreeViewElement.ExpandEvent): void => {
if (expanded) {
expandedResults.add(searchResult);
onExpandSearchResult(searchResult);
} else {
expandedResults.delete(searchResult);
}
};
// clang-format off
render(html`
<devtools-tree hide-overflow .template=${html`
<ul role="tree">
${results.map(searchResult => html`
<li @expand=${(e: UI.TreeOutline.TreeViewElement.ExpandEvent) => onExpand(searchResult, e)}
role="treeitem"
class="search-result">
<style>${searchResultsPaneStyles}</style>
${renderSearchResult(searchResult)}
<ul role="group" ?hidden=${!expandedResults.has(searchResult)}>
${renderSearchMatches(searchResult, matches, onSelectMatch, onShowMoreMatches)}
</ul>
</li>`)}
</ul>
`}></devtools-tree>`,
target,
);
// clang-format on
};
const renderSearchResult = (searchResult: SearchResult): TemplateResult => {
// clang-format off
return html`
<span class="search-result-file-name">${searchResult.label()}
<span class="search-result-dash">${'\u2014'}</span>
<span class="search-result-qualifier">${searchResult.description()}</span>
</span>
<span class="search-result-matches-count"
aria-label=${i18nString(UIStrings.matchesCountS, {PH1: searchResult.matchesCount()})}>
${searchResult.matchesCount()}
</span>`;
// clang-format on
};
const renderSearchMatches =
(searchResult: SearchResult, matches: WeakMap<SearchResult, SearchMatch[]>,
onSelectMatch: (searchResult: SearchResult, matchIndex: number) => void,
onShowMoreMatches: (searchResult: SearchResult) => void): TemplateResult => {
const visibleMatches = matches.get(searchResult) ?? [];
const matchesLeftCount = searchResult.matchesCount() - visibleMatches.length;
// clang-format off
return html`
${visibleMatches.map(({lineContent, matchRanges, resultLabel}, i) => html`
<li role="treeitem" class="search-match" @click=${() => onSelectMatch(searchResult, i)}
@keydown=${(event: KeyboardEvent) => {
if (event.key === 'Enter') {
onSelectMatch(searchResult, i);
}
}}
>
<button class="devtools-link text-button link-style search-match-link"
jslog="Link; context: search-match; track: click" role="link" tabindex="0"
@click=${() => void Common.Revealer.reveal(searchResult.matchRevealable(i))}>
<span class="search-match-line-number"
aria-label=${typeof resultLabel === 'number' && !isNaN(resultLabel)
? i18nString(UIStrings.lineS, {PH1: resultLabel}) : resultLabel}>
${resultLabel}
</span>
<span class="search-match-content" aria-label="${lineContent} line"
${UI.TreeOutline.TreeSearch.highlight(matchRanges, undefined)}>
${lineContent}
</span>
</button>
</li>`)}
${
matchesLeftCount > 0 ? html`
<li role="treeitem" class="show-more-matches" @click=${() => onShowMoreMatches(searchResult)}>
${i18nString(UIStrings.showDMore, { PH1: matchesLeftCount })}
</li>` : ''}`;
// clang-format on
};
export class SearchResultsPane extends UI.Widget.VBox {
#searchConfig: Workspace.SearchConfig.SearchConfig|null = null;
#searchResults: SearchResult[] = [];
#resultsUpdated = false;
#expandedResults = new WeakSet<SearchResult>();
readonly #searchMatches = new WeakMap<SearchResult, SearchMatch[]>();
#view: View;
constructor(element: HTMLElement|undefined, view: View = DEFAULT_VIEW) {
super(element, {useShadowDom: true});
this.#view = view;
}
get searchResults(): SearchResult[] {
return this.#searchResults;
}
set searchResults(searchResults: SearchResult[]) {
if (this.#searchResults === searchResults) {
return;
}
if (this.#searchResults.length !== searchResults.length) {
this.#resultsUpdated = true;
} else if (this.#searchResults.length === searchResults.length) {
for (let i = 0; i < this.#searchResults.length; ++i) {
if (this.#searchResults[i] === searchResults[i]) {
continue;
}
this.#resultsUpdated = true;
break;
}
}
if (!this.#resultsUpdated) {
return;
}
this.#searchResults = searchResults;
this.requestUpdate();
}
get searchConfig(): Workspace.SearchConfig.SearchConfig|null {
return this.#searchConfig;
}
set searchConfig(searchConfig: Workspace.SearchConfig.SearchConfig|null) {
this.#searchConfig = searchConfig;
this.requestUpdate();
}
showAllMatches(): void {
for (const searchResult of this.#searchResults) {
const startMatchIndex = this.#searchMatches.get(searchResult)?.length ?? 0;
this.#appendSearchMatches(searchResult, startMatchIndex, searchResult.matchesCount());
this.#expandedResults.add(searchResult);
}
this.requestUpdate();
}
collapseAllResults(): void {
this.#expandedResults = new WeakSet<SearchResult>();
this.requestUpdate();
}
#onExpandSearchResult(searchResult: SearchResult): void {
const toIndex = Math.min(searchResult.matchesCount(), matchesShownAtOnce);
this.#appendSearchMatches(searchResult, 0, toIndex);
this.requestUpdate();
}
#appendSearchMatches(searchResult: SearchResult, fromIndex: number, toIndex: number): void {
if (!this.#searchConfig) {
return;
}
const queries = this.#searchConfig.queries();
const regexes = [];
for (let i = 0; i < queries.length; ++i) {
regexes.push(Platform.StringUtilities.createSearchRegex(
queries[i], !this.#searchConfig.ignoreCase(), this.#searchConfig.isRegex()));
}
const searchMatches = this.#searchMatches.get(searchResult) ?? [];
this.#searchMatches.set(searchResult, searchMatches);
if (searchMatches.length >= toIndex) {
return;
}
for (let i = fromIndex; i < toIndex; ++i) {
let lineContent = searchResult.matchLineContent(i);
let matchRanges: TextUtils.TextRange.SourceRange[] = [];
// Searching in scripts and network response bodies produces one result entry per match. We can skip re-doing the
// search since we have the exact match range.
// For matches found in headers or the request URL we re-do the search to find all match ranges.
const column = searchResult.matchColumn(i);
const matchLength = searchResult.matchLength(i);
if (column !== undefined && matchLength !== undefined) {
const {matchRange, lineSegment} =
lineSegmentForMatch(lineContent, new TextUtils.TextRange.SourceRange(column, matchLength));
lineContent = lineSegment;
matchRanges = [matchRange];
} else {
lineContent = lineContent.trim();
for (let j = 0; j < regexes.length; ++j) {
matchRanges = matchRanges.concat(this.#regexMatchRanges(lineContent, regexes[j]));
}
({lineSegment: lineContent, matchRanges} = lineSegmentForMultipleMatches(lineContent, matchRanges));
}
const resultLabel = searchResult.matchLabel(i);
searchMatches.push({lineContent, matchRanges, resultLabel});
}
}
override performUpdate(): void {
if (this.#resultsUpdated) {
let matchesExpandedCount = 0;
for (const searchResult of this.#searchResults) {
if (this.#expandedResults.has(searchResult)) {
matchesExpandedCount += this.#searchMatches.get(searchResult)?.length ?? 0;
}
}
for (const searchResult of this.#searchResults) {
if (matchesExpandedCount < matchesExpandedByDefault && !this.#expandedResults.has(searchResult)) {
this.#expandedResults.add(searchResult);
this.#onExpandSearchResult(searchResult);
matchesExpandedCount += this.#searchMatches.get(searchResult)?.length ?? 0;
}
}
this.#resultsUpdated = false;
}
this.#view(
{
results: this.#searchResults,
matches: this.#searchMatches,
expandedResults: this.#expandedResults,
onSelectMatch: (searchResult, matchIndex) => {
void Common.Revealer.reveal(searchResult.matchRevealable(matchIndex));
},
onExpandSearchResult: this.#onExpandSearchResult.bind(this),
onShowMoreMatches: this.#onShowMoreMatches.bind(this),
},
{}, this.contentElement);
}
#regexMatchRanges(lineContent: string, regex: RegExp): TextUtils.TextRange.SourceRange[] {
regex.lastIndex = 0;
let match;
const matchRanges = [];
while ((regex.lastIndex < lineContent.length) && (match = regex.exec(lineContent))) {
matchRanges.push(new TextUtils.TextRange.SourceRange(match.index, match[0].length));
}
return matchRanges;
}
#onShowMoreMatches(searchResult: SearchResult): void {
const startMatchIndex = this.#searchMatches.get(searchResult)?.length ?? 0;
this.#appendSearchMatches(searchResult, startMatchIndex, searchResult.matchesCount());
this.requestUpdate();
}
}
export const matchesExpandedByDefault = 200;
export const matchesShownAtOnce = 20;
const DEFAULT_OPTS = {
prefixLength: 25,
maxLength: 1000,
};
/**
* Takes a whole line and calculates the substring we want to actually display in the UI.
* Also returns a translated {matchRange} (the parameter is relative to {lineContent} but the
* caller needs it relative to {lineSegment}).
*
* {lineContent} is modified in the following way:
*
* * Whitespace is trimmed from the beginning (unless the match includes it).
* * We only leave {options.prefixLength} characters before the match (and add an ellipsis in
* case we removed anything)
* * Truncate the remainder to {options.maxLength} characters.
*/
export function lineSegmentForMatch(
lineContent: string, range: TextUtils.TextRange.SourceRange,
optionsArg: Partial<typeof DEFAULT_OPTS> =
DEFAULT_OPTS): {lineSegment: string, matchRange: TextUtils.TextRange.SourceRange} {
const options = {...DEFAULT_OPTS, ...optionsArg};
// Remove the whitespace at the beginning, but stop where the match starts.
const attemptedTrimmedLine = lineContent.trimStart();
const potentiallyRemovedWhitespaceLength = lineContent.length - attemptedTrimmedLine.length;
const actuallyRemovedWhitespaceLength = Math.min(range.offset, potentiallyRemovedWhitespaceLength);
// Apply {options.prefixLength} and {options.maxLength}.
const lineSegmentBegin = Math.max(actuallyRemovedWhitespaceLength, range.offset - options.prefixLength);
const lineSegmentEnd = Math.min(lineContent.length, lineSegmentBegin + options.maxLength);
const lineSegmentPrefix = lineSegmentBegin > actuallyRemovedWhitespaceLength ? '…' : '';
// Build the resulting line segment and match range.
const lineSegment = lineSegmentPrefix + lineContent.substring(lineSegmentBegin, lineSegmentEnd);
const rangeOffset = range.offset - lineSegmentBegin + lineSegmentPrefix.length;
const rangeLength = Math.min(range.length, lineSegment.length - rangeOffset);
const matchRange = new TextUtils.TextRange.SourceRange(rangeOffset, rangeLength);
return {lineSegment, matchRange};
}
/**
* Takes a line and multiple match ranges and trims/cuts the line accordingly.
* The match ranges are then adjusted to reflect the transformation.
*
* Ideally prefer `lineSegmentForMatch`, it can center the line on the match
* whereas this method risks cutting matches out of the string.
*/
function lineSegmentForMultipleMatches(lineContent: string, ranges: TextUtils.TextRange.SourceRange[]):
{lineSegment: string, matchRanges: TextUtils.TextRange.SourceRange[]} {
let trimBy = 0;
let matchRanges = ranges;
if (matchRanges.length > 0 && matchRanges[0].offset > 20) {
trimBy = 15;
}
let lineSegment = lineContent.substring(trimBy, 1000 + trimBy);
if (trimBy) {
matchRanges =
matchRanges.map(range => new TextUtils.TextRange.SourceRange(range.offset - trimBy + 1, range.length));
lineSegment = '…' + lineSegment;
}
return {lineSegment, matchRanges};
}