UNPKG

chrome-devtools-frontend

Version:
332 lines (287 loc) 13.2 kB
// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable rulesdir/no-imperative-dom-api */ 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 Components from '../../ui/legacy/components/utils/utils.js'; import * as UI from '../../ui/legacy/legacy.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_); export class SearchResultsPane extends UI.Widget.VBox { private readonly searchConfig: Workspace.SearchConfig.SearchConfig; private readonly searchResults: SearchResult[]; private readonly treeElements: SearchResultsTreeElement[]; private treeOutline: UI.TreeOutline.TreeOutlineInShadow; private matchesExpandedCount: number; constructor(searchConfig: Workspace.SearchConfig.SearchConfig) { super(true); this.searchConfig = searchConfig; this.searchResults = []; this.treeElements = []; this.treeOutline = new UI.TreeOutline.TreeOutlineInShadow(); this.treeOutline.registerRequiredCSS(searchResultsPaneStyles); this.treeOutline.hideOverflow(); this.contentElement.appendChild(this.treeOutline.element); this.matchesExpandedCount = 0; } addSearchResult(searchResult: SearchResult): void { this.searchResults.push(searchResult); this.addTreeElement(searchResult); } showAllMatches(): void { this.treeElements.forEach(treeElement => { treeElement.expand(); treeElement.showAllMatches(); }); } collapseAllResults(): void { this.treeElements.forEach(treeElement => { treeElement.collapse(); }); } private addTreeElement(searchResult: SearchResult): void { const treeElement = new SearchResultsTreeElement(this.searchConfig, searchResult); this.treeOutline.appendChild(treeElement); if (!this.treeOutline.selectedTreeElement) { treeElement.select(/* omitFocus */ true, /* selectedByUser */ true); } // Expand until at least a certain number of matches is expanded. if (this.matchesExpandedCount < matchesExpandedByDefault) { treeElement.expand(); } this.matchesExpandedCount += searchResult.matchesCount(); this.treeElements.push(treeElement); } } export const matchesExpandedByDefault = 200; export const matchesShownAtOnce = 20; export class SearchResultsTreeElement extends UI.TreeOutline.TreeElement { private searchConfig: Workspace.SearchConfig.SearchConfig; private searchResult: SearchResult; private initialized: boolean; override toggleOnClick: boolean; constructor(searchConfig: Workspace.SearchConfig.SearchConfig, searchResult: SearchResult) { super('', true); this.searchConfig = searchConfig; this.searchResult = searchResult; this.initialized = false; this.toggleOnClick = true; } override onexpand(): void { if (this.initialized) { return; } this.updateMatchesUI(); this.initialized = true; } showAllMatches(): void { this.removeChildren(); this.appendSearchMatches(0, this.searchResult.matchesCount()); } private updateMatchesUI(): void { this.removeChildren(); const toIndex = Math.min(this.searchResult.matchesCount(), matchesShownAtOnce); if (toIndex < this.searchResult.matchesCount()) { this.appendSearchMatches(0, toIndex - 1); this.appendShowMoreMatchesElement(toIndex - 1); } else { this.appendSearchMatches(0, toIndex); } } override onattach(): void { this.updateSearchMatches(); } private updateSearchMatches(): void { this.listItemElement.classList.add('search-result'); const fileNameSpan = span(this.searchResult.label(), 'search-result-file-name'); fileNameSpan.appendChild(span('\u2014', 'search-result-dash')); fileNameSpan.appendChild(span(this.searchResult.description(), 'search-result-qualifier')); this.tooltip = this.searchResult.description(); this.listItemElement.appendChild(fileNameSpan); const matchesCountSpan = document.createElement('span'); matchesCountSpan.className = 'search-result-matches-count'; matchesCountSpan.textContent = `${this.searchResult.matchesCount()}`; UI.ARIAUtils.setLabel( matchesCountSpan, i18nString(UIStrings.matchesCountS, {PH1: this.searchResult.matchesCount()})); this.listItemElement.appendChild(matchesCountSpan); if (this.expanded) { this.updateMatchesUI(); } function span(text: string, className: string): Element { const span = document.createElement('span'); span.className = className; span.textContent = text; return span; } } private appendSearchMatches(fromIndex: number, toIndex: number): void { const searchResult = this.searchResult; 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())); } 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 anchor = Components.Linkifier.Linkifier.linkifyRevealable( searchResult.matchRevealable(i), '', undefined, undefined, undefined, 'search-match'); anchor.classList.add('search-match-link'); anchor.tabIndex = 0; const labelSpan = document.createElement('span'); labelSpan.classList.add('search-match-line-number'); const resultLabel = searchResult.matchLabel(i); labelSpan.textContent = resultLabel; if (typeof resultLabel === 'number' && !isNaN(resultLabel)) { UI.ARIAUtils.setLabel(labelSpan, i18nString(UIStrings.lineS, {PH1: resultLabel})); } else { UI.ARIAUtils.setLabel(labelSpan, resultLabel); } anchor.appendChild(labelSpan); const contentSpan = this.createContentSpan(lineContent, matchRanges); anchor.appendChild(contentSpan); const searchMatchElement = new UI.TreeOutline.TreeElement(); this.appendChild(searchMatchElement); searchMatchElement.listItemElement.className = 'search-match'; searchMatchElement.listItemElement.appendChild(anchor); searchMatchElement.listItemElement.addEventListener('keydown', event => { if (event.key === 'Enter') { event.consume(true); void Common.Revealer.reveal(searchResult.matchRevealable(i)); } }); searchMatchElement.tooltip = lineContent; } } private appendShowMoreMatchesElement(startMatchIndex: number): void { const matchesLeftCount = this.searchResult.matchesCount() - startMatchIndex; const showMoreMatchesText = i18nString(UIStrings.showDMore, {PH1: matchesLeftCount}); const showMoreMatchesTreeElement = new UI.TreeOutline.TreeElement(showMoreMatchesText); this.appendChild(showMoreMatchesTreeElement); showMoreMatchesTreeElement.listItemElement.classList.add('show-more-matches'); showMoreMatchesTreeElement.onselect = this.showMoreMatchesElementSelected.bind(this, showMoreMatchesTreeElement, startMatchIndex); } private createContentSpan(lineContent: string, matchRanges: TextUtils.TextRange.SourceRange[]): Element { const contentSpan = document.createElement('span'); contentSpan.className = 'search-match-content'; contentSpan.textContent = lineContent; UI.ARIAUtils.setLabel(contentSpan, `${lineContent} line`); UI.UIUtils.highlightRangesWithStyleClass(contentSpan, matchRanges, 'highlighted-search-result'); return contentSpan; } private 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; } private showMoreMatchesElementSelected( showMoreMatchesTreeElement: UI.TreeOutline.TreeElement, startMatchIndex: number): boolean { this.removeChild(showMoreMatchesTreeElement); this.appendSearchMatches(startMatchIndex, this.searchResult.matchesCount()); return false; } } 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}; }