// ***************************************************************************** // Copyright (C) 2018 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { Widget, Message, BaseWidget, Key, StatefulWidget, MessageLoop, KeyCode, codicon } from '@theia/core/lib/browser'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { SearchInWorkspaceResultTreeWidget } from './search-in-workspace-result-tree-widget'; import { SearchInWorkspaceOptions } from '../common/search-in-workspace-interface'; import * as React from '@theia/core/shared/react'; import { createRoot, Root } from '@theia/core/shared/react-dom/client'; import { Event, Emitter, Disposable } from '@theia/core/lib/common'; import { WorkspaceSearchFilterService, WorkspaceService } from '@theia/workspace/lib/browser'; import { SearchInWorkspaceContextKeyService } from './search-in-workspace-context-key-service'; import { CancellationTokenSource } from '@theia/core'; import { ProgressBarFactory } from '@theia/core/lib/browser/progress-bar-factory'; import { EditorManager } from '@theia/editor/lib/browser'; import { SearchInWorkspacePreferences } from '../common/search-in-workspace-preferences'; import { SearchInWorkspaceInput } from './components/search-in-workspace-input'; import { SearchInWorkspaceTextArea } from './components/search-in-workspace-textarea'; import { nls } from '@theia/core/lib/common/nls'; import { Deferred } from '@theia/core/lib/common/promise-util'; export interface SearchFieldState { className: string; enabled: boolean; title: string; } @injectable() export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidget { static ID = 'search-in-workspace'; static LABEL = nls.localizeByDefault('Search'); protected matchCaseState: SearchFieldState; protected wholeWordState: SearchFieldState; protected regExpState: SearchFieldState; protected useExcludeSettingsState: SearchFieldState; protected showSearchDetails = false; protected _hasResults = false; protected get hasResults(): boolean { return this._hasResults; } protected set hasResults(hasResults: boolean) { this.contextKeyService.hasSearchResult.set(hasResults); this._hasResults = hasResults; } protected resultNumber = 0; protected searchFieldContainerIsFocused = false; protected searchInWorkspaceOptions: SearchInWorkspaceOptions; protected searchTerm = ''; protected replaceTerm = ''; private searchRef = React.createRef(); private replaceRef = React.createRef(); private includeRef = React.createRef(); private excludeRef = React.createRef(); private refsAreSet = new Deferred(); protected _showReplaceField = false; protected get showReplaceField(): boolean { return this._showReplaceField; } protected set showReplaceField(showReplaceField: boolean) { this.contextKeyService.replaceActive.set(showReplaceField); this._showReplaceField = showReplaceField; } protected contentNode: HTMLElement; protected searchFormContainer: HTMLElement; protected resultContainer: HTMLElement; protected readonly onDidUpdateEmitter = new Emitter(); readonly onDidUpdate: Event = this.onDidUpdateEmitter.event; @inject(SearchInWorkspaceResultTreeWidget) readonly resultTreeWidget: SearchInWorkspaceResultTreeWidget; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(SearchInWorkspaceContextKeyService) protected readonly contextKeyService: SearchInWorkspaceContextKeyService; @inject(ProgressBarFactory) protected readonly progressBarFactory: ProgressBarFactory; @inject(EditorManager) protected readonly editorManager: EditorManager; @inject(SearchInWorkspacePreferences) protected readonly searchInWorkspacePreferences: SearchInWorkspacePreferences; @inject(WorkspaceSearchFilterService) protected readonly searchFilterService: WorkspaceSearchFilterService; protected searchFormContainerRoot: Root; @postConstruct() protected init(): void { this.id = SearchInWorkspaceWidget.ID; this.title.label = SearchInWorkspaceWidget.LABEL; this.title.caption = SearchInWorkspaceWidget.LABEL; this.title.iconClass = codicon('search'); this.title.closable = true; this.contentNode = document.createElement('div'); this.contentNode.classList.add('t-siw-search-container'); this.searchFormContainer = document.createElement('div'); this.searchFormContainer.classList.add('searchHeader'); this.contentNode.appendChild(this.searchFormContainer); this.searchFormContainerRoot = createRoot(this.searchFormContainer); this.node.tabIndex = 0; this.node.appendChild(this.contentNode); this.matchCaseState = { className: codicon('case-sensitive'), enabled: false, title: nls.localizeByDefault('Match Case') }; this.wholeWordState = { className: codicon('whole-word'), enabled: false, title: nls.localizeByDefault('Match Whole Word') }; this.regExpState = { className: codicon('regex'), enabled: false, title: nls.localizeByDefault('Use Regular Expression') }; this.useExcludeSettingsState = { className: codicon('eye'), enabled: true, title: nls.localizeByDefault('Use Exclude Settings and Ignore Files') }; this.searchInWorkspaceOptions = { matchCase: false, matchWholeWord: false, useRegExp: false, multiline: false, includeIgnored: false, include: [], exclude: [], maxResults: 2000 }; this.toDispose.push(this.resultTreeWidget.onChange(r => { this.hasResults = r.size > 0; this.resultNumber = 0; const results = Array.from(r.values()); results.forEach(rootFolder => rootFolder.children.forEach(file => this.resultNumber += file.children.length) ); this.update(); })); this.toDispose.push(this.resultTreeWidget.onFocusInput(b => { this.focusInputField(); })); this.toDispose.push(this.searchInWorkspacePreferences.onPreferenceChanged(e => { if (e.preferenceName === 'search.smartCase') { this.performSearch(); } })); this.toDispose.push(this.searchFilterService.onExclusionGlobsChanged(() => { this.performSearch(); })); this.toDispose.push(this.resultTreeWidget); this.toDispose.push(this.resultTreeWidget.onExpansionChanged(() => { this.onDidUpdateEmitter.fire(); })); this.toDispose.push(this.progressBarFactory({ container: this.node, insertMode: 'prepend', locationId: 'search' })); } storeState(): object { return { matchCaseState: this.matchCaseState, wholeWordState: this.wholeWordState, regExpState: this.regExpState, useExcludeSettingsState: this.useExcludeSettingsState, showSearchDetails: this.showSearchDetails, searchInWorkspaceOptions: this.searchInWorkspaceOptions, searchTerm: this.searchTerm, replaceTerm: this.replaceTerm, showReplaceField: this.showReplaceField, searchHistoryState: this.searchRef.current?.state, replaceHistoryState: this.replaceRef.current?.state, includeHistoryState: this.includeRef.current?.state, excludeHistoryState: this.excludeRef.current?.state, // For compatibility in case of downgrade to an earlier Theia version that expects to find this state includeIgnoredState: { ...this.useExcludeSettingsState, enabled: !this.useExcludeSettingsState.enabled, title: nls.localize('theia/search-in-workspace/includeIgnoredFiles', 'Include Ignored Files') }, }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any restoreState(oldState: any): void { this.matchCaseState = oldState.matchCaseState; this.wholeWordState = oldState.wholeWordState; this.regExpState = oldState.regExpState; if (oldState.useExcludeSettingsState) { this.useExcludeSettingsState = oldState.useExcludeSettingsState; } else if (oldState.includeIgnoredState) { this.useExcludeSettingsState = oldState.includeIgnoredState; this.useExcludeSettingsState.enabled = !this.useExcludeSettingsState.enabled; } // Override the title of the restored state, as we could have changed languages in between this.matchCaseState.title = nls.localizeByDefault('Match Case'); this.wholeWordState.title = nls.localizeByDefault('Match Whole Word'); this.regExpState.title = nls.localizeByDefault('Use Regular Expression'); this.useExcludeSettingsState.title = nls.localizeByDefault('Use Exclude Settings and Ignore Files'); this.showSearchDetails = oldState.showSearchDetails; this.searchInWorkspaceOptions = oldState.searchInWorkspaceOptions; this.searchInWorkspaceOptions.includeIgnored = !this.useExcludeSettingsState.enabled; this.searchTerm = oldState.searchTerm; this.replaceTerm = oldState.replaceTerm; this.showReplaceField = oldState.showReplaceField; this.resultTreeWidget.replaceTerm = this.replaceTerm; this.resultTreeWidget.showReplaceButtons = this.showReplaceField; this.searchRef.current?.setState(oldState.searchHistoryState); this.replaceRef.current?.setState(oldState.replaceHistoryState); this.includeRef.current?.setState(oldState.includeHistoryState); this.excludeRef.current?.setState(oldState.excludeHistoryState); this.refresh(); } findInFolder(uris: string[]): void { this.showSearchDetails = true; const values = Array.from(new Set(uris.map(uri => `${uri}/**`))); const value = values.join(', '); this.searchInWorkspaceOptions.include = values; if (this.includeRef.current) { this.includeRef.current.value = value; this.includeRef.current.addToHistory(); } this.update(); } /** * Update the search term and input field. * @param term the search term. * @param showReplaceField controls if the replace field should be displayed. */ updateSearchTerm(term: string, showReplaceField?: boolean): void { this.searchTerm = term; if (this.searchRef.current) { this.searchRef.current.value = term; this.searchRef.current.addToHistory(); } if (showReplaceField) { this.showReplaceField = true; } this.refresh(); } hasResultList(): boolean { return this.hasResults; } hasSearchTerm(): boolean { return this.searchTerm !== ''; } refresh(): void { this.performSearch(); this.update(); } getCancelIndicator(): CancellationTokenSource | undefined { return this.resultTreeWidget.cancelIndicator; } collapseAll(): void { this.resultTreeWidget.collapseAll(); this.update(); } expandAll(): void { this.resultTreeWidget.expandAll(); this.update(); } areResultsCollapsed(): boolean { return this.resultTreeWidget.areResultsCollapsed(); } clear(): void { this.searchTerm = ''; this.replaceTerm = ''; this.searchInWorkspaceOptions.include = []; this.searchInWorkspaceOptions.exclude = []; this.useExcludeSettingsState.enabled = true; this.matchCaseState.enabled = false; this.wholeWordState.enabled = false; this.regExpState.enabled = false; if (this.searchRef.current) { this.searchRef.current.value = ''; } if (this.replaceRef.current) { this.replaceRef.current.value = ''; } if (this.includeRef.current) { this.includeRef.current.value = ''; } if (this.excludeRef.current) { this.excludeRef.current.value = ''; } this.performSearch(); this.update(); } protected override onAfterAttach(msg: Message): void { super.onAfterAttach(msg); this.searchFormContainerRoot.render({this.renderSearchHeader()}{this.renderSearchInfo()}); Widget.attach(this.resultTreeWidget, this.contentNode); this.toDisposeOnDetach.push(Disposable.create(() => { Widget.detach(this.resultTreeWidget); })); } protected override onUpdateRequest(msg: Message): void { super.onUpdateRequest(msg); const searchInfo = this.renderSearchInfo(); if (searchInfo) { this.searchFormContainerRoot.render({this.renderSearchHeader()}{searchInfo}); this.onDidUpdateEmitter.fire(undefined); } } protected override onResize(msg: Widget.ResizeMessage): void { super.onResize(msg); this.searchRef.current?.forceUpdate(); this.replaceRef.current?.forceUpdate(); MessageLoop.sendMessage(this.resultTreeWidget, Widget.ResizeMessage.UnknownSize); } protected override onAfterShow(msg: Message): void { super.onAfterShow(msg); this.focusInputField(); this.contextKeyService.searchViewletVisible.set(true); } protected override onAfterHide(msg: Message): void { super.onAfterHide(msg); this.contextKeyService.searchViewletVisible.set(false); } protected override onActivateRequest(msg: Message): void { super.onActivateRequest(msg); this.focusInputField(); } protected async focusInputField(): Promise { // Wait until React rendering is sufficiently progressed before trying to focus the input field. await this.refsAreSet.promise; if (this.searchRef.current?.textarea.current) { this.searchRef.current.textarea.current.focus(); this.searchRef.current.textarea.current.select(); } } protected renderSearchHeader(): React.ReactNode { const searchAndReplaceContainer = this.renderSearchAndReplace(); const searchDetails = this.renderSearchDetails(); return
this.refsAreSet.resolve()}>{searchAndReplaceContainer}{searchDetails}
; } protected renderSearchAndReplace(): React.ReactNode { const toggleContainer = this.renderReplaceFieldToggle(); const searchField = this.renderSearchField(); const replaceField = this.renderReplaceField(); return
{toggleContainer}
{searchField} {replaceField}
; } protected renderReplaceFieldToggle(): React.ReactNode { const toggle = ; return
{ const elArr = document.getElementsByClassName('replace-toggle'); if (elArr && elArr.length > 0) { (elArr[0] as HTMLElement).focus(); } this.showReplaceField = !this.showReplaceField; this.resultTreeWidget.showReplaceButtons = this.showReplaceField; this.update(); }}> {toggle}
; } protected renderNotification(): React.ReactNode { if (this.workspaceService.tryGetRoots().length <= 0 && this.editorManager.all.length <= 0) { return
{nls.localize('theia/search-in-workspace/noFolderSpecified', 'You have not opened or specified a folder. Only open files are currently searched.')}
; } return
= this.searchInWorkspaceOptions.maxResults ? 'show' : ''}`}>
{nls.localize('theia/search-in-workspace/resultSubset', 'This is only a subset of all results. Use a more specific search term to narrow down the result list.')}
; } protected readonly focusSearchFieldContainer = () => this.doFocusSearchFieldContainer(); protected doFocusSearchFieldContainer(): void { this.searchFieldContainerIsFocused = true; this.update(); } protected readonly blurSearchFieldContainer = () => this.doBlurSearchFieldContainer(); protected doBlurSearchFieldContainer(): void { this.searchFieldContainerIsFocused = false; this.update(); } private _searchTimeout: number; protected readonly search = (e: React.KeyboardEvent) => { e.persist(); const searchOnType = this.searchInWorkspacePreferences['search.searchOnType']; if (searchOnType) { const delay = this.searchInWorkspacePreferences['search.searchOnTypeDebouncePeriod'] || 0; window.clearTimeout(this._searchTimeout); this._searchTimeout = window.setTimeout(() => this.doSearch(e), delay); } }; protected readonly onKeyDownSearch = (e: React.KeyboardEvent) => { if (Key.ENTER.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode) { this.performSearch(); } }; protected doSearch(e: React.KeyboardEvent): void { if (e.target) { const searchValue = (e.target as HTMLInputElement).value; if (this.searchTerm === searchValue) { return; } else { this.searchTerm = searchValue; this.performSearch(); } } } protected performSearch(): void { const searchOptions: SearchInWorkspaceOptions = { ...this.searchInWorkspaceOptions, followSymlinks: this.shouldFollowSymlinks(), matchCase: this.shouldMatchCase(), multiline: this.searchTerm.includes('\n') }; this.resultTreeWidget.search(this.searchTerm, searchOptions); } protected shouldFollowSymlinks(): boolean { return this.searchInWorkspacePreferences['search.followSymlinks']; } /** * Determine if search should be case sensitive. */ protected shouldMatchCase(): boolean { if (this.matchCaseState.enabled) { return this.matchCaseState.enabled; } // search.smartCase makes siw search case-sensitive if the search term contains uppercase letter(s). return ( !!this.searchInWorkspacePreferences['search.smartCase'] && this.searchTerm !== this.searchTerm.toLowerCase() ); } protected renderSearchField(): React.ReactNode { const input = ; const notification = this.renderNotification(); const optionContainer = this.renderOptionContainer(); const tooMany = this.searchInWorkspaceOptions.maxResults && this.resultNumber >= this.searchInWorkspaceOptions.maxResults ? 'tooManyResults' : ''; const className = `search-field-container ${tooMany} ${this.searchFieldContainerIsFocused ? 'focused' : ''}`; return
{input} {optionContainer}
{notification}
; } protected handleFocusSearchInputBox = (event: React.FocusEvent) => { event.target.placeholder = SearchInWorkspaceWidget.LABEL + nls.localizeByDefault(' ({0} for history)', '⇅'); this.contextKeyService.setSearchInputBoxFocus(true); }; protected handleBlurSearchInputBox = (event: React.FocusEvent) => { event.target.placeholder = SearchInWorkspaceWidget.LABEL; this.contextKeyService.setSearchInputBoxFocus(false); }; protected readonly updateReplaceTerm = (e: React.KeyboardEvent) => this.doUpdateReplaceTerm(e); protected doUpdateReplaceTerm(e: React.KeyboardEvent): void { if (e.target) { this.replaceTerm = (e.target as HTMLInputElement).value; this.resultTreeWidget.replaceTerm = this.replaceTerm; if (KeyCode.createKeyCode(e.nativeEvent).key?.keyCode === Key.ENTER.keyCode) { this.performSearch(); } this.update(); } } protected renderReplaceField(): React.ReactNode { const replaceAllButtonContainer = this.renderReplaceAllButtonContainer(); const replace = nls.localizeByDefault('Replace'); return
{replaceAllButtonContainer}
; } protected handleFocusReplaceInputBox = (event: React.FocusEvent) => { event.target.placeholder = nls.localizeByDefault('Replace') + nls.localizeByDefault(' ({0} for history)', '⇅'); this.contextKeyService.setReplaceInputBoxFocus(true); }; protected handleBlurReplaceInputBox = (event: React.FocusEvent) => { event.target.placeholder = nls.localizeByDefault('Replace'); this.contextKeyService.setReplaceInputBoxFocus(false); }; protected renderReplaceAllButtonContainer(): React.ReactNode { // The `Replace All` button is enabled if there is a search term present with results. const enabled: boolean = this.searchTerm !== '' && this.resultNumber > 0; return
{ if (enabled) { this.resultTreeWidget.replace(undefined); } }}>
; } protected renderOptionContainer(): React.ReactNode { const matchCaseOption = this.renderOptionElement(this.matchCaseState); const wholeWordOption = this.renderOptionElement(this.wholeWordState); const regexOption = this.renderOptionElement(this.regExpState); return
{matchCaseOption}{wholeWordOption}{regexOption}
; } protected renderOptionElement(opt: SearchFieldState): React.ReactNode { return this.handleOptionClick(opt)}>; } protected handleOptionClick(option: SearchFieldState): void { option.enabled = !option.enabled; this.updateSearchOptions(); this.searchFieldContainerIsFocused = true; this.performSearch(); this.update(); } protected updateSearchOptions(): void { this.searchInWorkspaceOptions.matchCase = this.matchCaseState.enabled; this.searchInWorkspaceOptions.matchWholeWord = this.wholeWordState.enabled; this.searchInWorkspaceOptions.useRegExp = this.regExpState.enabled; this.searchInWorkspaceOptions.includeIgnored = !this.useExcludeSettingsState.enabled; } protected renderSearchDetails(): React.ReactNode { const expandButton = this.renderExpandGlobFieldsButton(); const globFieldContainer = this.renderGlobFieldContainer(); return
{expandButton}{globFieldContainer}
; } protected renderGlobFieldContainer(): React.ReactNode { const includeField = this.renderGlobField('include'); const excludeField = this.renderGlobField('exclude'); return
{includeField}{excludeField}
; } protected renderExpandGlobFieldsButton(): React.ReactNode { return
{ this.showSearchDetails = !this.showSearchDetails; this.update(); }}>
; } protected renderGlobField(kind: 'include' | 'exclude'): React.ReactNode { const currentValue = this.searchInWorkspaceOptions[kind]; const value = currentValue && currentValue.join(', ') || ''; const useExcludeSettingsToggle = kind === 'exclude' ?
{this.renderOptionElement(this.useExcludeSettingsState)}
: undefined; return
{nls.localizeByDefault('files to ' + kind)}
{ if (e.target) { const targetValue = (e.target as HTMLInputElement).value || ''; let shouldSearch = Key.ENTER.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode; const currentOptions = (this.searchInWorkspaceOptions[kind] || []).slice().map(s => s.trim()).sort(); const candidateOptions = this.splitOnComma(targetValue).map(s => s.trim()).sort(); const sameAs = (left: string[], right: string[]) => { if (left.length !== right.length) { return false; } for (let i = 0; i < left.length; i++) { if (left[i] !== right[i]) { return false; } } return true; }; if (!sameAs(currentOptions, candidateOptions)) { this.searchInWorkspaceOptions[kind] = this.splitOnComma(targetValue); shouldSearch = true; } if (shouldSearch) { this.performSearch(); } } }} onFocus={kind === 'include' ? this.handleFocusIncludesInputBox : this.handleFocusExcludesInputBox} onBlur={kind === 'include' ? this.handleBlurIncludesInputBox : this.handleBlurExcludesInputBox} ref={kind === 'include' ? this.includeRef : this.excludeRef} /> {useExcludeSettingsToggle}
; } protected handleFocusIncludesInputBox = () => this.contextKeyService.setPatternIncludesInputBoxFocus(true); protected handleBlurIncludesInputBox = () => this.contextKeyService.setPatternIncludesInputBoxFocus(false); protected handleFocusExcludesInputBox = () => this.contextKeyService.setPatternExcludesInputBoxFocus(true); protected handleBlurExcludesInputBox = () => this.contextKeyService.setPatternExcludesInputBoxFocus(false); protected splitOnComma(patterns: string): string[] { return patterns.length > 0 ? patterns.split(',').map(s => s.trim()) : []; } protected renderSearchInfo(): React.ReactNode { const message = this.getSearchResultMessage() || ''; return
{message}
; } protected getSearchResultMessage(): string | undefined { if (!this.searchTerm) { return undefined; } if (this.resultNumber === 0) { const isIncludesPresent = this.searchInWorkspaceOptions.include && this.searchInWorkspaceOptions.include.length > 0; const isExcludesPresent = this.searchInWorkspaceOptions.exclude && this.searchInWorkspaceOptions.exclude.length > 0; let message: string; if (isIncludesPresent && isExcludesPresent) { message = nls.localizeByDefault("No results found in '{0}' excluding '{1}' - ", this.searchInWorkspaceOptions.include!.toString(), this.searchInWorkspaceOptions.exclude!.toString()); } else if (isIncludesPresent) { message = nls.localizeByDefault("No results found in '{0}' - ", this.searchInWorkspaceOptions.include!.toString()); } else if (isExcludesPresent) { message = nls.localizeByDefault("No results found excluding '{0}' - ", this.searchInWorkspaceOptions.exclude!.toString()); } else { message = nls.localizeByDefault('No results found') + ' - '; } // We have to trim here as vscode will always add a trailing " - " string return message.substring(0, message.length - 2).trim(); } else { if (this.resultNumber === 1 && this.resultTreeWidget.fileNumber === 1) { return nls.localizeByDefault('{0} result in {1} file', this.resultNumber.toString(), this.resultTreeWidget.fileNumber.toString()); } else if (this.resultTreeWidget.fileNumber === 1) { return nls.localizeByDefault('{0} results in {1} file', this.resultNumber.toString(), this.resultTreeWidget.fileNumber.toString()); } else if (this.resultTreeWidget.fileNumber > 0) { return nls.localizeByDefault('{0} results in {1} files', this.resultNumber.toString(), this.resultTreeWidget.fileNumber.toString()); } else { // if fileNumber === 0, return undefined so that `onUpdateRequest()` would not re-render component return undefined; } } } }