/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { Button, IButtonOptions } from 'vs/base/browser/ui/button/button'; import { FindInput, IFindInputOptions } from 'vs/base/browser/ui/findinput/findInput'; import { ReplaceInput } from 'vs/base/browser/ui/findinput/replaceInput'; import { IMessage } from 'vs/base/browser/ui/inputbox/inputBox'; import { Widget } from 'vs/base/browser/ui/widget'; import { Action } from 'vs/base/common/actions'; import { Delayer } from 'vs/base/common/async'; import { Emitter, Event } from 'vs/base/common/event'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import * as strings from 'vs/base/common/strings'; import { CONTEXT_FIND_WIDGET_NOT_VISIBLE } from 'vs/editor/contrib/find/findModel'; import * as nls from 'vs/nls'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ContextKeyExpr, IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { ISearchConfigurationProperties } from 'vs/workbench/services/search/common/search'; import { attachFindReplaceInputBoxStyler } from 'vs/platform/theme/common/styler'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { ContextScopedFindInput, ContextScopedReplaceInput } from 'vs/platform/browser/contextScopedHistoryWidget'; import { appendKeyBindingLabel, isSearchViewFocused } from 'vs/workbench/contrib/search/browser/searchActions'; import * as Constants from 'vs/workbench/contrib/search/common/constants'; import { IPanelService } from 'vs/workbench/services/panel/common/panelService'; import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { IAccessibilityService, AccessibilitySupport } from 'vs/platform/accessibility/common/accessibility'; import { isMacintosh } from 'vs/base/common/platform'; export interface ISearchWidgetOptions { value?: string; replaceValue?: string; isRegex?: boolean; isCaseSensitive?: boolean; isWholeWords?: boolean; searchHistory?: string[]; replaceHistory?: string[]; preserveCase?: boolean; } class ReplaceAllAction extends Action { private static fgInstance: ReplaceAllAction | null = null; static readonly ID: string = 'search.action.replaceAll'; static get INSTANCE(): ReplaceAllAction { if (ReplaceAllAction.fgInstance === null) { ReplaceAllAction.fgInstance = new ReplaceAllAction(); } return ReplaceAllAction.fgInstance; } private _searchWidget: SearchWidget | null = null; constructor() { super(ReplaceAllAction.ID, '', 'codicon-replace-all', false); } set searchWidget(searchWidget: SearchWidget) { this._searchWidget = searchWidget; } run(): Promise { if (this._searchWidget) { return this._searchWidget.triggerReplaceAll(); } return Promise.resolve(null); } } const ctrlKeyMod = (isMacintosh ? KeyMod.WinCtrl : KeyMod.CtrlCmd); function stopPropagationForMultiLineUpwards(event: IKeyboardEvent, value: string, textarea: HTMLTextAreaElement | null) { const isMultiline = !!value.match(/\n/); if (textarea && isMultiline && textarea.selectionStart > 0) { event.stopPropagation(); return; } } function stopPropagationForMultiLineDownwards(event: IKeyboardEvent, value: string, textarea: HTMLTextAreaElement | null) { const isMultiline = !!value.match(/\n/); if (textarea && isMultiline && textarea.selectionEnd < textarea.value.length) { event.stopPropagation(); return; } } export class SearchWidget extends Widget { private static readonly REPLACE_ALL_DISABLED_LABEL = nls.localize('search.action.replaceAll.disabled.label', "Replace All (Submit Search to Enable)"); private static readonly REPLACE_ALL_ENABLED_LABEL = (keyBindingService2: IKeybindingService): string => { const kb = keyBindingService2.lookupKeybinding(ReplaceAllAction.ID); return appendKeyBindingLabel(nls.localize('search.action.replaceAll.enabled.label', "Replace All"), kb, keyBindingService2); } domNode!: HTMLElement; searchInput!: FindInput; searchInputFocusTracker!: dom.IFocusTracker; private searchInputBoxFocused: IContextKey; private replaceContainer!: HTMLElement; replaceInput!: ReplaceInput; replaceInputFocusTracker!: dom.IFocusTracker; private replaceInputBoxFocused: IContextKey; private toggleReplaceButton!: Button; private replaceAllAction!: ReplaceAllAction; private replaceActive: IContextKey; private replaceActionBar!: ActionBar; private _replaceHistoryDelayer: Delayer; private ignoreGlobalFindBufferOnNextFocus = false; private previousGlobalFindBufferValue: string | null = null; private _onSearchSubmit = this._register(new Emitter()); readonly onSearchSubmit: Event = this._onSearchSubmit.event; private _onSearchCancel = this._register(new Emitter()); readonly onSearchCancel: Event = this._onSearchCancel.event; private _onReplaceToggled = this._register(new Emitter()); readonly onReplaceToggled: Event = this._onReplaceToggled.event; private _onReplaceStateChange = this._register(new Emitter()); readonly onReplaceStateChange: Event = this._onReplaceStateChange.event; private _onPreserveCaseChange = this._register(new Emitter()); readonly onPreserveCaseChange: Event = this._onPreserveCaseChange.event; private _onReplaceValueChanged = this._register(new Emitter()); readonly onReplaceValueChanged: Event = this._onReplaceValueChanged.event; private _onReplaceAll = this._register(new Emitter()); readonly onReplaceAll: Event = this._onReplaceAll.event; private _onBlur = this._register(new Emitter()); readonly onBlur: Event = this._onBlur.event; private _onDidHeightChange = this._register(new Emitter()); readonly onDidHeightChange: Event = this._onDidHeightChange.event; constructor( container: HTMLElement, options: ISearchWidgetOptions, @IContextViewService private readonly contextViewService: IContextViewService, @IThemeService private readonly themeService: IThemeService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IKeybindingService private readonly keyBindingService: IKeybindingService, @IClipboardService private readonly clipboardServce: IClipboardService, @IConfigurationService private readonly configurationService: IConfigurationService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService ) { super(); this.replaceActive = Constants.ReplaceActiveKey.bindTo(this.contextKeyService); this.searchInputBoxFocused = Constants.SearchInputBoxFocusedKey.bindTo(this.contextKeyService); this.replaceInputBoxFocused = Constants.ReplaceInputBoxFocusedKey.bindTo(this.contextKeyService); this._replaceHistoryDelayer = new Delayer(500); this.render(container, options); this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('editor.accessibilitySupport')) { this.updateAccessibilitySupport(); } }); this.accessibilityService.onDidChangeAccessibilitySupport(() => this.updateAccessibilitySupport()); this.updateAccessibilitySupport(); } focus(select: boolean = true, focusReplace: boolean = false, suppressGlobalSearchBuffer = false): void { this.ignoreGlobalFindBufferOnNextFocus = suppressGlobalSearchBuffer; if (focusReplace && this.isReplaceShown()) { this.replaceInput.focus(); if (select) { this.replaceInput.select(); } } else { this.searchInput.focus(); if (select) { this.searchInput.select(); } } } setWidth(width: number) { this.searchInput.inputBox.layout(); this.replaceInput.width = width - 28; this.replaceInput.inputBox.layout(); } clear() { this.searchInput.clear(); this.replaceInput.setValue(''); this.setReplaceAllActionState(false); } isReplaceShown(): boolean { return !dom.hasClass(this.replaceContainer, 'disabled'); } isReplaceActive(): boolean { return !!this.replaceActive.get(); } getReplaceValue(): string { return this.replaceInput.getValue(); } toggleReplace(show?: boolean): void { if (show === undefined || show !== this.isReplaceShown()) { this.onToggleReplaceButton(); } } getSearchHistory(): string[] { return this.searchInput.inputBox.getHistory(); } getReplaceHistory(): string[] { return this.replaceInput.inputBox.getHistory(); } clearHistory(): void { this.searchInput.inputBox.clearHistory(); } showNextSearchTerm() { this.searchInput.inputBox.showNextValue(); } showPreviousSearchTerm() { this.searchInput.inputBox.showPreviousValue(); } showNextReplaceTerm() { this.replaceInput.inputBox.showNextValue(); } showPreviousReplaceTerm() { this.replaceInput.inputBox.showPreviousValue(); } searchInputHasFocus(): boolean { return !!this.searchInputBoxFocused.get(); } replaceInputHasFocus(): boolean { return this.replaceInput.inputBox.hasFocus(); } focusReplaceAllAction(): void { this.replaceActionBar.focus(true); } focusRegexAction(): void { this.searchInput.focusOnRegex(); } private render(container: HTMLElement, options: ISearchWidgetOptions): void { this.domNode = dom.append(container, dom.$('.search-widget')); this.domNode.style.position = 'relative'; this.renderToggleReplaceButton(this.domNode); this.renderSearchInput(this.domNode, options); this.renderReplaceInput(this.domNode, options); } private isScreenReaderOptimized() { const detected = this.accessibilityService.getAccessibilitySupport() === AccessibilitySupport.Enabled; const config = this.configurationService.getValue('editor').accessibilitySupport; return config === 'on' || (config === 'auto' && detected); } private updateAccessibilitySupport(): void { this.searchInput.setFocusInputOnOptionClick(!this.isScreenReaderOptimized()); } private renderToggleReplaceButton(parent: HTMLElement): void { const opts: IButtonOptions = { buttonBackground: undefined, buttonBorder: undefined, buttonForeground: undefined, buttonHoverBackground: undefined }; this.toggleReplaceButton = this._register(new Button(parent, opts)); this.toggleReplaceButton.element.setAttribute('aria-expanded', 'false'); this.toggleReplaceButton.element.classList.add('codicon'); this.toggleReplaceButton.element.classList.add('codicon-chevron-right'); this.toggleReplaceButton.icon = 'toggle-replace-button'; // TODO@joh need to dispose this listener eventually this.toggleReplaceButton.onDidClick(() => this.onToggleReplaceButton()); this.toggleReplaceButton.element.title = nls.localize('search.replace.toggle.button.title', "Toggle Replace"); } private renderSearchInput(parent: HTMLElement, options: ISearchWidgetOptions): void { const inputOptions: IFindInputOptions = { label: nls.localize('label.Search', 'Search: Type Search Term and press Enter to search or Escape to cancel'), validation: (value: string) => this.validateSearchInput(value), placeholder: nls.localize('search.placeHolder', "Search"), appendCaseSensitiveLabel: appendKeyBindingLabel('', this.keyBindingService.lookupKeybinding(Constants.ToggleCaseSensitiveCommandId), this.keyBindingService), appendWholeWordsLabel: appendKeyBindingLabel('', this.keyBindingService.lookupKeybinding(Constants.ToggleWholeWordCommandId), this.keyBindingService), appendRegexLabel: appendKeyBindingLabel('', this.keyBindingService.lookupKeybinding(Constants.ToggleRegexCommandId), this.keyBindingService), history: options.searchHistory, flexibleHeight: true }; const searchInputContainer = dom.append(parent, dom.$('.search-container.input-box')); this.searchInput = this._register(new ContextScopedFindInput(searchInputContainer, this.contextViewService, inputOptions, this.contextKeyService, true)); this._register(attachFindReplaceInputBoxStyler(this.searchInput, this.themeService)); this.searchInput.onKeyDown((keyboardEvent: IKeyboardEvent) => this.onSearchInputKeyDown(keyboardEvent)); this.searchInput.setValue(options.value || ''); this.searchInput.setRegex(!!options.isRegex); this.searchInput.setCaseSensitive(!!options.isCaseSensitive); this.searchInput.setWholeWords(!!options.isWholeWords); this._register(this.onSearchSubmit(() => { this.searchInput.inputBox.addToHistory(); })); this._register(this.searchInput.onCaseSensitiveKeyDown((keyboardEvent: IKeyboardEvent) => this.onCaseSensitiveKeyDown(keyboardEvent))); this._register(this.searchInput.onRegexKeyDown((keyboardEvent: IKeyboardEvent) => this.onRegexKeyDown(keyboardEvent))); this._register(this.searchInput.inputBox.onDidChange(() => this.onSearchInputChanged())); this._register(this.searchInput.inputBox.onDidHeightChange(() => this._onDidHeightChange.fire())); this._register(this.onReplaceValueChanged(() => { this._replaceHistoryDelayer.trigger(() => this.replaceInput.inputBox.addToHistory()); })); this.searchInputFocusTracker = this._register(dom.trackFocus(this.searchInput.inputBox.inputElement)); this._register(this.searchInputFocusTracker.onDidFocus(() => { this.searchInputBoxFocused.set(true); const useGlobalFindBuffer = this.searchConfiguration.globalFindClipboard; if (!this.ignoreGlobalFindBufferOnNextFocus && useGlobalFindBuffer) { const globalBufferText = this.clipboardServce.readFindText(); if (this.previousGlobalFindBufferValue !== globalBufferText) { this.searchInput.inputBox.addToHistory(); this.searchInput.setValue(globalBufferText); this.searchInput.select(); } this.previousGlobalFindBufferValue = globalBufferText; } this.ignoreGlobalFindBufferOnNextFocus = false; })); this._register(this.searchInputFocusTracker.onDidBlur(() => this.searchInputBoxFocused.set(false))); } private renderReplaceInput(parent: HTMLElement, options: ISearchWidgetOptions): void { this.replaceContainer = dom.append(parent, dom.$('.replace-container.disabled')); const replaceBox = dom.append(this.replaceContainer, dom.$('.replace-input')); this.replaceInput = this._register(new ContextScopedReplaceInput(replaceBox, this.contextViewService, { label: nls.localize('label.Replace', 'Replace: Type replace term and press Enter to preview or Escape to cancel'), placeholder: nls.localize('search.replace.placeHolder', "Replace"), history: options.replaceHistory, flexibleHeight: true }, this.contextKeyService, true)); this._register(this.replaceInput.onDidOptionChange(viaKeyboard => { if (!viaKeyboard) { this._onPreserveCaseChange.fire(this.replaceInput.getPreserveCase()); } })); this._register(attachFindReplaceInputBoxStyler(this.replaceInput, this.themeService)); this.replaceInput.onKeyDown((keyboardEvent) => this.onReplaceInputKeyDown(keyboardEvent)); this.replaceInput.setValue(options.replaceValue || ''); this._register(this.replaceInput.inputBox.onDidChange(() => this._onReplaceValueChanged.fire())); this._register(this.replaceInput.inputBox.onDidHeightChange(() => this._onDidHeightChange.fire())); this.replaceAllAction = ReplaceAllAction.INSTANCE; this.replaceAllAction.searchWidget = this; this.replaceAllAction.label = SearchWidget.REPLACE_ALL_DISABLED_LABEL; this.replaceActionBar = this._register(new ActionBar(this.replaceContainer)); this.replaceActionBar.push([this.replaceAllAction], { icon: true, label: false }); this.onkeydown(this.replaceActionBar.domNode, (keyboardEvent) => this.onReplaceActionbarKeyDown(keyboardEvent)); this.replaceInputFocusTracker = this._register(dom.trackFocus(this.replaceInput.inputBox.inputElement)); this._register(this.replaceInputFocusTracker.onDidFocus(() => this.replaceInputBoxFocused.set(true))); this._register(this.replaceInputFocusTracker.onDidBlur(() => this.replaceInputBoxFocused.set(false))); } triggerReplaceAll(): Promise { this._onReplaceAll.fire(); return Promise.resolve(null); } private onToggleReplaceButton(): void { dom.toggleClass(this.replaceContainer, 'disabled'); dom.toggleClass(this.toggleReplaceButton.element, 'codicon-chevron-right'); dom.toggleClass(this.toggleReplaceButton.element, 'codicon-chevron-down'); this.toggleReplaceButton.element.setAttribute('aria-expanded', this.isReplaceShown() ? 'true' : 'false'); this.updateReplaceActiveState(); this._onReplaceToggled.fire(); } setReplaceAllActionState(enabled: boolean): void { if (this.replaceAllAction.enabled !== enabled) { this.replaceAllAction.enabled = enabled; this.replaceAllAction.label = enabled ? SearchWidget.REPLACE_ALL_ENABLED_LABEL(this.keyBindingService) : SearchWidget.REPLACE_ALL_DISABLED_LABEL; this.updateReplaceActiveState(); } } private updateReplaceActiveState(): void { const currentState = this.isReplaceActive(); const newState = this.isReplaceShown() && this.replaceAllAction.enabled; if (currentState !== newState) { this.replaceActive.set(newState); this._onReplaceStateChange.fire(newState); this.replaceInput.inputBox.layout(); } } private validateSearchInput(value: string): IMessage | null { if (value.length === 0) { return null; } if (!this.searchInput.getRegex()) { return null; } try { // tslint:disable-next-line: no-unused-expression new RegExp(value, 'u'); } catch (e) { return { content: e.message }; } if (strings.regExpContainsBackreference(value)) { if (!this.searchConfiguration.usePCRE2) { return { content: nls.localize('regexp.backreferenceValidationFailure', "Backreferences are not supported") }; } } return null; } private onSearchInputChanged(): void { this.searchInput.clearMessage(); this.setReplaceAllActionState(false); } private onSearchInputKeyDown(keyboardEvent: IKeyboardEvent) { if (keyboardEvent.equals(ctrlKeyMod | KeyCode.Enter)) { this.searchInput.inputBox.insertAtCursor('\n'); keyboardEvent.preventDefault(); } if (keyboardEvent.equals(KeyCode.Enter)) { this.submitSearch(); keyboardEvent.preventDefault(); } else if (keyboardEvent.equals(KeyCode.Escape)) { this._onSearchCancel.fire(); keyboardEvent.preventDefault(); } else if (keyboardEvent.equals(KeyCode.Tab)) { if (this.isReplaceShown()) { this.replaceInput.focus(); } else { this.searchInput.focusOnCaseSensitive(); } keyboardEvent.preventDefault(); } else if (keyboardEvent.equals(KeyCode.UpArrow)) { stopPropagationForMultiLineUpwards(keyboardEvent, this.searchInput.getValue(), this.searchInput.domNode.querySelector('textarea')); } else if (keyboardEvent.equals(KeyCode.DownArrow)) { stopPropagationForMultiLineDownwards(keyboardEvent, this.searchInput.getValue(), this.searchInput.domNode.querySelector('textarea')); } } private onCaseSensitiveKeyDown(keyboardEvent: IKeyboardEvent) { if (keyboardEvent.equals(KeyMod.Shift | KeyCode.Tab)) { if (this.isReplaceShown()) { this.replaceInput.focus(); keyboardEvent.preventDefault(); } } } private onRegexKeyDown(keyboardEvent: IKeyboardEvent) { if (keyboardEvent.equals(KeyCode.Tab)) { if (this.isReplaceActive()) { this.focusReplaceAllAction(); } else { this._onBlur.fire(); } keyboardEvent.preventDefault(); } } private onReplaceInputKeyDown(keyboardEvent: IKeyboardEvent) { if (keyboardEvent.equals(ctrlKeyMod | KeyCode.Enter)) { this.replaceInput.inputBox.insertAtCursor('\n'); keyboardEvent.preventDefault(); } if (keyboardEvent.equals(KeyCode.Enter)) { this.submitSearch(); keyboardEvent.preventDefault(); } else if (keyboardEvent.equals(KeyCode.Tab)) { this.searchInput.focusOnCaseSensitive(); keyboardEvent.preventDefault(); } else if (keyboardEvent.equals(KeyMod.Shift | KeyCode.Tab)) { this.searchInput.focus(); keyboardEvent.preventDefault(); } else if (keyboardEvent.equals(KeyCode.UpArrow)) { stopPropagationForMultiLineUpwards(keyboardEvent, this.replaceInput.getValue(), this.replaceInput.domNode.querySelector('textarea')); } else if (keyboardEvent.equals(KeyCode.DownArrow)) { stopPropagationForMultiLineDownwards(keyboardEvent, this.replaceInput.getValue(), this.replaceInput.domNode.querySelector('textarea')); } } private onReplaceActionbarKeyDown(keyboardEvent: IKeyboardEvent) { if (keyboardEvent.equals(KeyMod.Shift | KeyCode.Tab)) { this.focusRegexAction(); keyboardEvent.preventDefault(); } } private submitSearch(): void { this.searchInput.validate(); if (!this.searchInput.inputBox.isInputValid()) { return; } const value = this.searchInput.getValue(); const useGlobalFindBuffer = this.searchConfiguration.globalFindClipboard; if (value) { if (useGlobalFindBuffer) { this.clipboardServce.writeFindText(value); } this._onSearchSubmit.fire(); } } dispose(): void { this.setReplaceAllActionState(false); super.dispose(); } private get searchConfiguration(): ISearchConfigurationProperties { return this.configurationService.getValue('search'); } } export function registerContributions() { KeybindingsRegistry.registerCommandAndKeybindingRule({ id: ReplaceAllAction.ID, weight: KeybindingWeight.WorkbenchContrib, when: ContextKeyExpr.and(Constants.SearchViewVisibleKey, Constants.ReplaceActiveKey, CONTEXT_FIND_WIDGET_NOT_VISIBLE), primary: KeyMod.Alt | KeyMod.CtrlCmd | KeyCode.Enter, handler: accessor => { if (isSearchViewFocused(accessor.get(IViewletService), accessor.get(IPanelService))) { ReplaceAllAction.INSTANCE.run(); } } }); }