blob: e3872c5c67236aa85a561572107460fb1a59bc63 [file] [log] [blame]
// 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;
}
}