UNPKG

chrome-devtools-frontend

Version:
580 lines (522 loc) 21.5 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 '../../ui/legacy/legacy.js'; import * as Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Workspace from '../../models/workspace/workspace.js'; import * as Buttons from '../../ui/components/buttons/buttons.js'; import * as IconButton from '../../ui/components/icon_button/icon_button.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import {SearchResultsPane} from './SearchResultsPane.js'; import type {SearchResult, SearchScope} from './SearchScope.js'; import searchViewStyles from './searchView.css.js'; const UIStrings = { /** *@description Placeholder text of a search bar */ find: 'Find', /** *@description Tooltip text on a toggle to enable search by matching case of the input */ enableCaseSensitive: 'Enable case sensitive search', /** *@description Tooltip text on a toggle to disable search by matching case of the input */ disableCaseSensitive: 'Disable case sensitive search', /** *@description Tooltip text on a toggle to enable searching with regular expression */ enableRegularExpression: 'Enable regular expressions', /** *@description Tooltip text on a toggle to disable searching with regular expression */ disableRegularExpression: 'Disable regular expressions', /** *@description Text to refresh the page */ refresh: 'Refresh', /** *@description Tooltip text to clear the search input field */ clearInput: 'Clear', /** *@description Text to clear content */ clear: 'Clear search', /** *@description Search message element text content in Search View of the Search tab */ indexing: 'Indexing…', /** *@description Text to indicate the searching is in progress */ searching: 'Searching…', /** *@description Text in Search View of the Search tab */ indexingInterrupted: 'Indexing interrupted.', /** *@description Search results message element text content in Search View of the Search tab */ foundMatchingLineInFile: 'Found 1 matching line in 1 file.', /** *@description Search results message element text content in Search View of the Search tab *@example {2} PH1 */ foundDMatchingLinesInFile: 'Found {PH1} matching lines in 1 file.', /** *@description Search results message element text content in Search View of the Search tab *@example {2} PH1 *@example {2} PH2 */ foundDMatchingLinesInDFiles: 'Found {PH1} matching lines in {PH2} files.', /** *@description Search results message element text content in Search View of the Search tab */ noMatchesFound: 'No matches found', /** *@description Search results message element text content in Search View of the Search tab */ nothingMatchedTheQuery: 'Nothing matched your search query', /** *@description Text in Search View of the Search tab */ searchFinished: 'Search finished.', /** *@description Text in Search View of the Search tab */ searchInterrupted: 'Search interrupted.', /** *@description Text in Search View of the Search tab if user hasn't started the search *@example {Enter} PH1 */ typeAndPressSToSearch: 'Type and press {PH1} to search', /** *@description Text in Search view of the Search tab if user hasn't started the search */ noSearchResult: 'No search results', } as const; const str_ = i18n.i18n.registerUIStrings('panels/search/SearchView.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); function createSearchToggleButton(iconName: string, jslogContext: string): Buttons.Button.Button { const button = new Buttons.Button.Button(); button.data = { variant: Buttons.Button.Variant.ICON_TOGGLE, iconName, toggledIconName: iconName, toggleType: Buttons.Button.ToggleType.PRIMARY, size: Buttons.Button.Size.SMALL, toggled: false, jslogContext, }; return button; } export class SearchView extends UI.Widget.VBox { private focusOnShow: boolean; private isIndexing: boolean; private searchId: number; private searchMatchesCount: number; private searchResultsCount: number; private nonEmptySearchResultsCount: number; private searchingView: UI.Widget.Widget|null; private notFoundView: UI.Widget.Widget|null; private searchConfig: Workspace.SearchConfig.SearchConfig|null; private pendingSearchConfig: Workspace.SearchConfig.SearchConfig|null; private searchResultsPane: SearchResultsPane|null; private progressIndicator: UI.ProgressIndicator.ProgressIndicator|null; private visiblePane: UI.Widget.Widget|null; private readonly searchPanelElement: HTMLElement; private readonly searchResultsElement: HTMLElement; protected readonly search: HTMLInputElement; protected readonly matchCaseButton: Buttons.Button.Button; protected readonly regexButton: Buttons.Button.Button; private searchMessageElement: HTMLElement; private readonly searchProgressPlaceholderElement: HTMLElement; private searchResultsMessageElement: HTMLElement; private readonly advancedSearchConfig: Common.Settings.Setting<{ query: string, ignoreCase: boolean, isRegex: boolean, }>; private searchScope: SearchScope|null; // We throttle adding search results, otherwise we trigger DOM layout for each // result added. #throttler: Common.Throttler.Throttler; #pendingSearchResults: SearchResult[] = []; #emptyStartView: UI.EmptyWidget.EmptyWidget; constructor(settingKey: string, throttler: Common.Throttler.Throttler) { super(true); this.setMinimumSize(0, 40); this.registerRequiredCSS(searchViewStyles); this.focusOnShow = false; this.isIndexing = false; this.searchId = 1; this.searchMatchesCount = 0; this.searchResultsCount = 0; this.nonEmptySearchResultsCount = 0; this.searchingView = null; this.notFoundView = null; this.searchConfig = null; this.pendingSearchConfig = null; this.searchResultsPane = null; this.progressIndicator = null; this.visiblePane = null; this.#throttler = throttler; this.contentElement.setAttribute('jslog', `${VisualLogging.panel('search').track({resize: true})}`); this.contentElement.classList.add('search-view'); this.contentElement.addEventListener('keydown', event => { this.onKeyDownOnPanel((event)); }); this.searchPanelElement = this.contentElement.createChild('div', 'search-drawer-header'); this.searchResultsElement = this.contentElement.createChild('div'); this.searchResultsElement.className = 'search-results'; const searchContainer = document.createElement('div'); searchContainer.classList.add('search-container'); const searchElements = searchContainer.createChild('div', 'toolbar-item-search'); const searchIcon = IconButton.Icon.create('search'); searchElements.appendChild(searchIcon); this.search = UI.UIUtils.createHistoryInput('search', 'search-toolbar-input'); this.search.addEventListener('keydown', event => { this.onKeyDown((event)); }); this.search.setAttribute( 'jslog', `${VisualLogging.textField().track({change: true, keydown: 'ArrowUp|ArrowDown|Enter'})}`); searchElements.appendChild(this.search); this.search.placeholder = i18nString(UIStrings.find); this.search.setAttribute('results', '0'); this.search.setAttribute('size', '100'); UI.ARIAUtils.setLabel(this.search, this.search.placeholder); const clearInputFieldButton = new Buttons.Button.Button(); clearInputFieldButton.data = { variant: Buttons.Button.Variant.ICON, iconName: 'cross-circle-filled', jslogContext: 'clear-input', size: Buttons.Button.Size.SMALL, title: i18nString(UIStrings.clearInput), }; clearInputFieldButton.classList.add('clear-button'); clearInputFieldButton.addEventListener('click', () => { this.onSearchInputClear(); }); clearInputFieldButton.tabIndex = -1; searchElements.appendChild(clearInputFieldButton); const regexIconName = 'regular-expression'; this.regexButton = createSearchToggleButton(regexIconName, regexIconName); this.regexButton.addEventListener('click', () => this.regexButtonToggled()); searchElements.appendChild(this.regexButton); const matchCaseIconName = 'match-case'; this.matchCaseButton = createSearchToggleButton(matchCaseIconName, matchCaseIconName); this.matchCaseButton.addEventListener('click', () => this.matchCaseButtonToggled()); searchElements.appendChild(this.matchCaseButton); this.searchPanelElement.appendChild(searchContainer); const toolbar = this.searchPanelElement.createChild('devtools-toolbar', 'search-toolbar'); toolbar.setAttribute('jslog', `${VisualLogging.toolbar()}`); const refreshButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.refresh), 'refresh', undefined, 'search.refresh'); const clearButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.clear), 'clear', undefined, 'search.clear'); toolbar.appendToolbarItem(refreshButton); toolbar.appendToolbarItem(clearButton); refreshButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => this.onAction()); clearButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => { this.resetSearch(); this.onSearchInputClear(); }); const searchStatusBarElement = this.contentElement.createChild('div', 'search-toolbar-summary'); this.searchMessageElement = searchStatusBarElement.createChild('div', 'search-message'); this.searchProgressPlaceholderElement = searchStatusBarElement.createChild('div', 'flex-centered'); this.searchResultsMessageElement = searchStatusBarElement.createChild('div', 'search-message'); this.advancedSearchConfig = Common.Settings.Settings.instance().createLocalSetting( settingKey + '-search-config', new Workspace.SearchConfig.SearchConfig('', true, false).toPlainObject()); this.load(); this.searchScope = null; this.#emptyStartView = new UI.EmptyWidget.EmptyWidget( i18nString(UIStrings.noSearchResult), i18nString(UIStrings.typeAndPressSToSearch, { PH1: UI.KeyboardShortcut.KeyboardShortcut.shortcutToString(UI.KeyboardShortcut.Keys.Enter) })); this.showPane(this.#emptyStartView); } regexButtonToggled(): void { this.regexButton.title = this.regexButton.toggled ? i18nString(UIStrings.disableRegularExpression) : i18nString(UIStrings.enableRegularExpression); } matchCaseButtonToggled(): void { this.matchCaseButton.title = this.matchCaseButton.toggled ? i18nString(UIStrings.disableCaseSensitive) : i18nString(UIStrings.enableCaseSensitive); } private buildSearchConfig(): Workspace.SearchConfig.SearchConfig { return new Workspace.SearchConfig.SearchConfig( this.search.value, !this.matchCaseButton.toggled, this.regexButton.toggled); } toggle(queryCandidate: string, searchImmediately?: boolean): void { this.search.value = queryCandidate; if (this.isShowing()) { this.focus(); } else { this.focusOnShow = true; } this.initScope(); if (searchImmediately) { this.onAction(); } else { this.startIndexing(); } } createScope(): SearchScope { throw new Error('Not implemented'); } private initScope(): void { this.searchScope = this.createScope(); } override wasShown(): void { super.wasShown(); if (this.focusOnShow) { this.focus(); this.focusOnShow = false; } } private onIndexingFinished(): void { if (!this.progressIndicator) { return; } const finished = !this.progressIndicator.isCanceled(); this.progressIndicator.done(); this.progressIndicator = null; this.isIndexing = false; this.searchMessageElement.textContent = finished ? '' : i18nString(UIStrings.indexingInterrupted); if (!finished) { this.pendingSearchConfig = null; } if (!this.pendingSearchConfig) { return; } const searchConfig = this.pendingSearchConfig; this.pendingSearchConfig = null; this.innerStartSearch(searchConfig); } private startIndexing(): void { this.isIndexing = true; if (this.progressIndicator) { this.progressIndicator.done(); } this.progressIndicator = new UI.ProgressIndicator.ProgressIndicator(); this.searchMessageElement.textContent = i18nString(UIStrings.indexing); this.progressIndicator.show(this.searchProgressPlaceholderElement); if (this.searchScope) { this.searchScope.performIndexing( new Common.Progress.ProgressProxy(this.progressIndicator, this.onIndexingFinished.bind(this))); } } private onSearchInputClear(): void { this.search.value = ''; this.save(); this.focus(); this.showPane(this.#emptyStartView); } private onSearchResult(searchId: number, searchResult: SearchResult): void { if (searchId !== this.searchId || !this.progressIndicator) { return; } if (this.progressIndicator?.isCanceled()) { this.onIndexingFinished(); return; } if (!this.searchResultsPane) { this.searchResultsPane = new SearchResultsPane((this.searchConfig as Workspace.SearchConfig.SearchConfig)); this.showPane(this.searchResultsPane); } this.#pendingSearchResults.push(searchResult); void this.#throttler.schedule(async () => this.#addPendingSearchResults()); } #addPendingSearchResults(): void { for (const searchResult of this.#pendingSearchResults) { this.addSearchResult(searchResult); if (searchResult.matchesCount()) { this.searchResultsPane?.addSearchResult(searchResult); } } this.#pendingSearchResults = []; } private onSearchFinished(searchId: number, finished: boolean): void { if (searchId !== this.searchId || !this.progressIndicator) { return; } if (!this.searchResultsPane) { this.nothingFound(); } this.searchFinished(finished); this.searchConfig = null; UI.ARIAUtils.alert(this.searchMessageElement.textContent + ' ' + this.searchResultsMessageElement.textContent); } private innerStartSearch(searchConfig: Workspace.SearchConfig.SearchConfig): void { this.searchConfig = searchConfig; if (this.progressIndicator) { this.progressIndicator.done(); } this.progressIndicator = new UI.ProgressIndicator.ProgressIndicator(); this.searchStarted(this.progressIndicator); if (this.searchScope) { void this.searchScope.performSearch( searchConfig, this.progressIndicator, this.onSearchResult.bind(this, this.searchId), this.onSearchFinished.bind(this, this.searchId)); } } private resetSearch(): void { this.stopSearch(); this.showPane(null); this.searchResultsPane = null; this.searchMessageElement.textContent = ''; this.searchResultsMessageElement.textContent = ''; } private stopSearch(): void { if (this.progressIndicator && !this.isIndexing) { this.progressIndicator.cancel(); } if (this.searchScope) { this.searchScope.stopSearch(); } this.searchConfig = null; } private searchStarted(progressIndicator: UI.ProgressIndicator.ProgressIndicator): void { this.searchMatchesCount = 0; this.searchResultsCount = 0; this.nonEmptySearchResultsCount = 0; if (!this.searchingView) { this.searchingView = new UI.EmptyWidget.EmptyWidget(i18nString(UIStrings.searching), ''); } this.showPane(this.searchingView); this.searchMessageElement.textContent = i18nString(UIStrings.searching); progressIndicator.show(this.searchProgressPlaceholderElement); this.updateSearchResultsMessage(); } private updateSearchResultsMessage(): void { if (this.searchMatchesCount && this.searchResultsCount) { if (this.searchMatchesCount === 1 && this.nonEmptySearchResultsCount === 1) { this.searchResultsMessageElement.textContent = i18nString(UIStrings.foundMatchingLineInFile); } else if (this.searchMatchesCount > 1 && this.nonEmptySearchResultsCount === 1) { this.searchResultsMessageElement.textContent = i18nString(UIStrings.foundDMatchingLinesInFile, {PH1: this.searchMatchesCount}); } else { this.searchResultsMessageElement.textContent = i18nString( UIStrings.foundDMatchingLinesInDFiles, {PH1: this.searchMatchesCount, PH2: this.nonEmptySearchResultsCount}); } } else { this.searchResultsMessageElement.textContent = ''; } } private showPane(panel: UI.Widget.Widget|null): void { if (this.visiblePane) { this.visiblePane.detach(); } if (panel) { panel.show(this.searchResultsElement); } this.visiblePane = panel; } private nothingFound(): void { if (!this.notFoundView) { this.notFoundView = new UI.EmptyWidget.EmptyWidget( i18nString(UIStrings.noMatchesFound), i18nString(UIStrings.nothingMatchedTheQuery)); } this.showPane(this.notFoundView); } private addSearchResult(searchResult: SearchResult): void { const matchesCount = searchResult.matchesCount(); this.searchMatchesCount += matchesCount; this.searchResultsCount++; if (matchesCount) { this.nonEmptySearchResultsCount++; } this.updateSearchResultsMessage(); } private searchFinished(finished: boolean): void { this.searchMessageElement.textContent = finished ? i18nString(UIStrings.searchFinished) : i18nString(UIStrings.searchInterrupted); } override focus(): void { this.search.focus(); this.search.select(); } override willHide(): void { this.stopSearch(); } private onKeyDown(event: KeyboardEvent): void { this.save(); switch (event.keyCode) { case UI.KeyboardShortcut.Keys.Enter.code: this.onAction(); break; } } /** * Handles keydown event on panel itself for handling expand/collapse all shortcut * * We use `event.code` instead of `event.key` here to check whether the shortcut is triggered. * The reason is, `event.key` is dependent on the modification keys, locale and keyboard layout. * Usually it is useful when we care about the character that needs to be printed. * * However, our aim in here is to assign a shortcut to the physical key combination on the keyboard * not on the character that the key combination prints. * * For example, `Cmd + [` shortcut in global shortcuts map to focusing on previous panel. * In Turkish - Q keyboard layout, the key combination that triggers the shortcut prints `ğ` * character. Whereas in Turkish - Q Legacy keyboard layout, the shortcut that triggers focusing * on previous panel prints `[` character. So, if we use `event.key` and check * whether it is `[`, we break the shortcut in Turkish - Q keyboard layout. * * @param event KeyboardEvent */ private onKeyDownOnPanel(event: KeyboardEvent): void { const isMac = Host.Platform.isMac(); // "Command + Alt + ]" for Mac const shouldShowAllForMac = isMac && event.metaKey && !event.ctrlKey && event.altKey && event.code === 'BracketRight'; // "Ctrl + Shift + }" for other platforms const shouldShowAllForOtherPlatforms = !isMac && event.ctrlKey && !event.metaKey && event.shiftKey && event.code === 'BracketRight'; // "Command + Alt + [" for Mac const shouldCollapseAllForMac = isMac && event.metaKey && !event.ctrlKey && event.altKey && event.code === 'BracketLeft'; // "Command + Alt + {" for other platforms const shouldCollapseAllForOtherPlatforms = !isMac && event.ctrlKey && !event.metaKey && event.shiftKey && event.code === 'BracketLeft'; if (shouldShowAllForMac || shouldShowAllForOtherPlatforms) { this.searchResultsPane?.showAllMatches(); void VisualLogging.logKeyDown(event.currentTarget, event, 'show-all-matches'); } else if (shouldCollapseAllForMac || shouldCollapseAllForOtherPlatforms) { this.searchResultsPane?.collapseAllResults(); void VisualLogging.logKeyDown(event.currentTarget, event, 'collapse-all-results'); } } private save(): void { this.advancedSearchConfig.set(this.buildSearchConfig().toPlainObject()); } private load(): void { const searchConfig = Workspace.SearchConfig.SearchConfig.fromPlainObject(this.advancedSearchConfig.get()); this.search.value = searchConfig.query(); this.matchCaseButton.toggled = !searchConfig.ignoreCase(); this.matchCaseButtonToggled(); this.regexButton.toggled = searchConfig.isRegex(); this.regexButtonToggled(); } private onAction(): void { const searchConfig = this.buildSearchConfig(); if (!searchConfig.query()?.length) { return; } this.resetSearch(); ++this.searchId; this.initScope(); if (!this.isIndexing) { this.startIndexing(); } this.pendingSearchConfig = searchConfig; } get throttlerForTest(): Common.Throttler.Throttler { return this.#throttler; } }