diff --git a/src/vs/base/browser/ui/findinput/findContants.ts b/src/vs/base/browser/ui/findinput/findContants.ts new file mode 100644 index 0000000000000..3597c2edbbe3c --- /dev/null +++ b/src/vs/base/browser/ui/findinput/findContants.ts @@ -0,0 +1 @@ +export const MATCHES_LIMIT = 19999; diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.css b/src/vs/base/browser/ui/findinput/nthMatchInput.css new file mode 100644 index 0000000000000..b321e857d0cf4 --- /dev/null +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.css @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + + /* ---------- Nth Match Input - FindWidget ---------- */ +.nth-match { + width: 45px; + min-width: 45px; + max-width: 60px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + bottom: 1.5px; + margin-right: 10px; + transition: width 300ms ease; +} + +.nth-match.elongated { + width: 60px; +} + +.nth-match input { + text-align: center; + overflow-x: hidden; + text-overflow: clip; + font-size: 12px; +} + + +/* ---------- Nth Match Input - SimpleFindWidget ---------- */ +.nth-match.simple-nth-match { + bottom: unset; +} diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.ts b/src/vs/base/browser/ui/findinput/nthMatchInput.ts new file mode 100644 index 0000000000000..5e6c165c06e76 --- /dev/null +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.ts @@ -0,0 +1,225 @@ +/*--------------------------------------------------------------------------------------------- + * 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 '../../dom.js'; +import { IKeyboardEvent } from '../../keyboardEvent.js'; +import { IMouseEvent } from '../../mouseEvent.js'; +import { IContextViewProvider } from '../contextview/contextview.js'; +import { InputBox, IInputBoxStyles, IMessage as InputBoxMessage } from '../inputbox/inputBox.js'; +import { Widget } from '../widget.js'; +import { Emitter, Event } from '../../../common/event.js'; +import { KeyCode } from '../../../common/keyCodes.js'; +import './nthMatchInput.css'; +import * as nls from '../../../../nls.js'; +import { MATCHES_LIMIT } from './findContants.js'; + +export interface INthMatchInputOptions { + readonly placeholder?: string; + readonly tooltip?: string; + readonly label: string; + readonly type: 'text'; + readonly min?: number; + readonly max?: number; + + readonly inputBoxStyles: IInputBoxStyles; +} + +export interface IStepEvent { + to: 'previous' | 'next'; +} + +const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); + +export class NthMatchInput extends Widget { + + private placeholder: string; + private tooltip: string; + private label: string; + private type: string; + private imeSessionInProgress = false; + + public readonly domNode: HTMLElement; + public readonly inputBox: InputBox; + public min: number; + public max: number; + + private readonly _onDidOptionChange = this._register(new Emitter()); + public readonly onDidOptionChange: Event = this._onDidOptionChange.event; + + private readonly _onKeyDown = this._register(new Emitter()); + public readonly onKeyDown: Event = this._onKeyDown.event; + + private readonly _onMouseDown = this._register(new Emitter()); + public readonly onMouseDown: Event = this._onMouseDown.event; + + private readonly _onInput = this._register(new Emitter()); + public readonly onInput: Event = this._onInput.event; + + private readonly _onKeyUp = this._register(new Emitter()); + public readonly onKeyUp: Event = this._onKeyUp.event; + + private readonly _onStep = this._register(new Emitter()); + public readonly onStep: Event = this._onStep.event; + + + constructor(parent: HTMLElement | null, contextViewProvider: IContextViewProvider | undefined, options: INthMatchInputOptions) { + super(); + this.placeholder = options.placeholder || ''; + this.tooltip = options.tooltip || ''; + this.label = options.label || NLS_DEFAULT_LABEL; + this.type = options.type || 'text'; + this.min = options.min || 1; + this.max = options.max || MATCHES_LIMIT; + + this.domNode = document.createElement('div'); + this.domNode.classList.add('monaco-findInput'); + + this.inputBox = this._register(new InputBox(this.domNode, contextViewProvider, { + placeholder: this.placeholder || '', + tooltip: this.tooltip || '', + ariaLabel: this.label || '', + inputBoxStyles: options.inputBoxStyles, + type: this.type + })); + + this.onkeydown(this.domNode, (event: IKeyboardEvent) => { + // Arrow-Key support for stepping to the previous match or to the next one. + if (event.equals(KeyCode.UpArrow)) { + this._onStep.fire({ to: 'previous' }); + } + else if (event.equals(KeyCode.DownArrow)) { + this._onStep.fire({ to: 'next' }); + } + }); + + this.onchange(this.domNode, () => { + this.updateInputWrapperWidth(); + }); + + parent?.appendChild(this.domNode); + + this._register(dom.addDisposableListener(this.inputBox.inputElement, 'compositionstart', (e: CompositionEvent) => { + this.imeSessionInProgress = true; + })); + this._register(dom.addDisposableListener(this.inputBox.inputElement, 'compositionend', (e: CompositionEvent) => { + this.imeSessionInProgress = false; + this._onInput.fire(); + })); + + this.onkeydown(this.inputBox.inputElement, (e) => this._onKeyDown.fire(e)); + this.onkeyup(this.inputBox.inputElement, (e) => this._onKeyUp.fire(e)); + this.oninput(this.inputBox.inputElement, (e) => this._onInput.fire()); + this.onmousedown(this.inputBox.inputElement, (e) => this._onMouseDown.fire(e)); + } + + public get isImeSessionInProgress(): boolean { + return this.imeSessionInProgress; + } + + public get onDidChange(): Event { + return this.inputBox.onDidChange; + } + + public layout(style: { collapsedFindWidget: boolean; narrowFindWidget: boolean; reducedFindWidget: boolean }) { + this.inputBox.layout(); + this.updateInputBoxPadding(style.collapsedFindWidget); + this.updateInputWrapperWidth(); + } + + public updateInputWrapperWidth() { + const currentInputValue = `${this.getSanitizedCurrentValue()}`; + const containerElem = (this.inputBox.element.parentElement as HTMLElement); + if ((currentInputValue.length >= 5)) { + if (!containerElem.classList.contains('elongated')) { + containerElem.classList.add(...['elongated']); + } + } + else if (currentInputValue.length <= 4) { + if (containerElem.classList.contains('elongated')) { + containerElem.classList.remove(...['elongated']); + } + } + } + + public enable(): void { + this.domNode.classList.remove('disabled'); + this.inputBox.enable(); + } + + public disable(): void { + this.domNode.classList.add('disabled'); + this.inputBox.disable(); + } + + public setEnabled(enabled: boolean): void { + if (enabled) { + this.enable(); + } else { + this.disable(); + } + } + + private updateInputBoxPadding(controlsHidden = false) { + if (controlsHidden) { + this.inputBox.paddingRight = 0; + } else { + this.inputBox.paddingRight = 0; + } + } + + public clear(): void { + this.clearValidation(); + this.setValue(''); + this.focus(); + } + + public getValue(): string { + return this.inputBox.value; + } + + public setValue(value: string): void { + if (this.inputBox.value !== value) { + this.inputBox.value = value; + } + this.updateInputWrapperWidth(); + } + + public select(): void { + this.inputBox.select(); + } + + public focus(): void { + this.inputBox.focus(); + } + + public validate(): void { + this.inputBox.validate(); + } + + public showMessage(message: InputBoxMessage): void { + this.inputBox.showMessage(message); + } + + public clearMessage(): void { + this.inputBox.hideMessage(); + } + + private clearValidation(): void { + this.inputBox.hideMessage(); + } + + public getSanitizedCurrentValue(): number { + if (!this || !this.getValue()) { + return this.min; + } + + // Enforce the numerical input and min/max constraints here. + const currentValueAsInt = parseInt(this.getValue(), 10); + return isNaN(currentValueAsInt) ? + this.min : currentValueAsInt > this.max ? + this.max : currentValueAsInt < this.min ? + this.min : currentValueAsInt; + } +} diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index c504148633d2e..487e4dff86e67 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -37,6 +37,7 @@ import { FindWidgetSearchHistory } from './findWidgetSearchHistory.js'; import { ReplaceWidgetHistory } from './replaceWidgetHistory.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { MATCHES_LIMIT } from '../../../../base/browser/ui/findinput/findContants.js'; const SEARCH_STRING_MAX_LENGTH = 524288; @@ -932,6 +933,119 @@ export class MoveToMatchFindAction extends EditorAction { } } +export class MoveToNthMatchFindAction extends EditorAction { + + protected _highlightDecorations: string[] = []; + + constructor(id?: string, label?: string, alias?: string) { + super({ + id: id || FIND_IDS.NthMatchFindAction, + label: label || nls.localize('findMatchAction.nthMatch', "Go to Nth Match..."), + alias: alias || 'Go to Nth Match...', + precondition: CONTEXT_FIND_WIDGET_VISIBLE + }); + } + + public run(accessor: ServicesAccessor, editor: ICodeEditor, args: any): void | Promise { + const controller = CommonFindController.get(editor); + if (!controller || !args?.n) { + return; + } + + const matchesCount = controller.getState().matchesCount; + if (matchesCount < 1) { + const notificationService = accessor.get(INotificationService); + notificationService.notify({ + severity: Severity.Warning, + message: nls.localize('findMatchAction.noResults', "No matches. Try searching for something else.") + }); + return; + } + + const toFindMatchIndex = (value: string): number | undefined => { + const index = parseInt(value); + if (isNaN(index)) { + return undefined; + } + + const matchCount = controller.getState().matchesCount; + if (index > 0 && index <= matchCount) { + return index - 1; // zero based + } else if (index < 0 && index >= -matchCount) { + // Always clamp to the start if + // the index is out-of-bounds. + return 0; + } + + return undefined; + }; + + const index = toFindMatchIndex((args?.n || '1')); + if (typeof index === 'number') { + // valid + controller.goToMatch(index); + const currentMatch = controller.getState().currentMatch; + if (currentMatch) { + this.addDecorations(editor, currentMatch); + } + else { + this.clearDecorations(editor); + } + } + else { + this.clearDecorations(editor); + } + } + + private clearDecorations(editor: ICodeEditor): void { + editor.changeDecorations(changeAccessor => { + this._highlightDecorations = changeAccessor.deltaDecorations(this._highlightDecorations, []); + }); + } + + private addDecorations(editor: ICodeEditor, range: IRange): void { + editor.changeDecorations(changeAccessor => { + this._highlightDecorations = changeAccessor.deltaDecorations(this._highlightDecorations, [ + { + range, + options: { + description: 'find-match-quick-access-range-highlight', + className: 'rangeHighlight', + isWholeLine: true + } + }, + { + range, + options: { + description: 'find-match-quick-access-range-highlight-overview', + overviewRuler: { + color: themeColorFromId(overviewRulerRangeHighlight), + position: OverviewRulerLane.Full + } + } + } + ]); + }); + } +} + +export class MoveToLastMatchFindAction extends MoveToNthMatchFindAction { + protected override _highlightDecorations: string[] = []; + + constructor() { + super( + FIND_IDS.LastMatchFindAction, + nls.localize('findMatchAction.goToLastMatchFindAction', "Go to Last Match..."), + 'Go to Last Match...' + ); + } + + public override run(accessor: ServicesAccessor, editor: ICodeEditor, args: any): void | Promise { + const lastMatchIndex = CommonFindController.get(editor)?.getState().matchesCount || MATCHES_LIMIT; + super.run(accessor, editor, { ...(args || {}), n: lastMatchIndex }); + } +} + export abstract class SelectionMatchFindAction extends EditorAction { public async run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { const controller = CommonFindController.get(editor); @@ -1063,6 +1177,8 @@ registerEditorContribution(CommonFindController.ID, FindController, EditorContri registerEditorAction(StartFindWithArgsAction); registerEditorAction(StartFindWithSelectionAction); registerEditorAction(MoveToMatchFindAction); +registerEditorAction(MoveToNthMatchFindAction); +registerEditorAction(MoveToLastMatchFindAction); registerEditorAction(NextSelectionMatchFindAction); registerEditorAction(PreviousSelectionMatchFindAction); diff --git a/src/vs/editor/contrib/find/browser/findModel.ts b/src/vs/editor/contrib/find/browser/findModel.ts index 5ec8c791d2583..0cc22a84e12f7 100644 --- a/src/vs/editor/contrib/find/browser/findModel.ts +++ b/src/vs/editor/contrib/find/browser/findModel.ts @@ -24,17 +24,19 @@ import { ReplaceAllCommand } from './replaceAllCommand.js'; import { parseReplaceString, ReplacePattern } from './replacePattern.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IKeybindings } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { MATCHES_LIMIT } from '../../../../base/browser/ui/findinput/findContants.js'; export const CONTEXT_FIND_WIDGET_VISIBLE = new RawContextKey('findWidgetVisible', false); export const CONTEXT_FIND_WIDGET_NOT_VISIBLE = CONTEXT_FIND_WIDGET_VISIBLE.toNegated(); // Keep ContextKey use of 'Focussed' to not break when clauses -export const CONTEXT_FIND_INPUT_FOCUSED = new RawContextKey('findInputFocussed', false); -export const CONTEXT_REPLACE_INPUT_FOCUSED = new RawContextKey('replaceInputFocussed', false); +export const CONTEXT_FIND_INPUT_FOCUSED = new RawContextKey('findInputFocused', false); +export const CONTEXT_REPLACE_INPUT_FOCUSED = new RawContextKey('replaceInputFocused', false); /** * Context key that is true when any element within the Find widget has focus. * This includes the Find input, Replace input, checkboxes, buttons, etc. */ export const CONTEXT_FIND_WIDGET_FOCUSED = new RawContextKey('findWidgetFocused', false); +export const CONTEXT_NTH_MATCH_INPUT_FOCUSED = new RawContextKey('nthMatchInputFocused', false); export const ToggleCaseSensitiveKeybinding: IKeybindings = { primary: KeyMod.Alt | KeyCode.KeyC, @@ -64,6 +66,8 @@ export const FIND_IDS = { NextMatchFindAction: 'editor.action.nextMatchFindAction', PreviousMatchFindAction: 'editor.action.previousMatchFindAction', GoToMatchFindAction: 'editor.action.goToMatchFindAction', + NthMatchFindAction: 'editor.action.nthMatchFindAction', + LastMatchFindAction: 'editor.action.lastMatchFindAction', NextSelectionMatchFindAction: 'editor.action.nextSelectionMatchFindAction', PreviousSelectionMatchFindAction: 'editor.action.previousSelectionMatchFindAction', StartFindReplaceAction: 'editor.action.startFindReplaceAction', @@ -78,7 +82,6 @@ export const FIND_IDS = { SelectAllMatchesAction: 'editor.action.selectAllMatches' }; -export const MATCHES_LIMIT = 19999; const RESEARCH_DELAY = 240; export class FindModelBoundToEditorModel { diff --git a/src/vs/editor/contrib/find/browser/findState.ts b/src/vs/editor/contrib/find/browser/findState.ts index f48d60c1763ed..2bf0f9ea672b6 100644 --- a/src/vs/editor/contrib/find/browser/findState.ts +++ b/src/vs/editor/contrib/find/browser/findState.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { MATCHES_LIMIT } from '../../../../base/browser/ui/findinput/findContants.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Range } from '../../../common/core/range.js'; -import { MATCHES_LIMIT } from './findModel.js'; export interface FindReplaceStateChangedEvent { moveCursor: boolean; diff --git a/src/vs/editor/contrib/find/browser/findWidget.css b/src/vs/editor/contrib/find/browser/findWidget.css index 62c6056c1d98d..0a3eda36e22f6 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.css +++ b/src/vs/editor/contrib/find/browser/findWidget.css @@ -113,25 +113,35 @@ } .monaco-editor .find-widget .matchesCount { - display: flex; - flex: initial; - margin: 0 0 0 3px; - padding: 2px 0 0 2px; - height: 25px; - vertical-align: middle; - box-sizing: border-box; - text-align: center; - line-height: 23px; + display: flex; + align-items: center; + flex: initial; + margin: 0 0 0 3px; + padding: 2px 0 0 2px; + height: 25px; + vertical-align: middle; + box-sizing: border-box; + text-align: center; + line-height: 23px; +} + +.monaco-editor .find-widget .matchesCount .last-match-btn { + width: fit-content; + padding: 4.5px; + margin-left: 10px; + margin-bottom: 2px; + box-shadow: 0 2px 4px 0 var(--vscode-widget-shadow); } .monaco-editor .find-widget .button { - width: 16px; + min-width: 16px; + margin-left: 5px; height: 16px; padding: 3px; border-radius: 5px; display: flex; flex: initial; - margin-left: 3px; + margin-left: 3.5px; background-position: center center; background-repeat: no-repeat; cursor: pointer; diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index 03e698c3e8777..3425803c7215b 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -11,6 +11,8 @@ import { Toggle } from '../../../../base/browser/ui/toggle/toggle.js'; import { IContextViewProvider } from '../../../../base/browser/ui/contextview/contextview.js'; import { FindInput } from '../../../../base/browser/ui/findinput/findInput.js'; import { ReplaceInput } from '../../../../base/browser/ui/findinput/replaceInput.js'; +import { NthMatchInput } from '../../../../base/browser/ui/findinput/nthMatchInput.js'; +import { MATCHES_LIMIT } from '../../../../base/browser/ui/findinput/findContants.js'; import { IMessage as InputBoxMessage } from '../../../../base/browser/ui/inputbox/inputBox.js'; import { ISashEvent, IVerticalSashLayoutProvider, Orientation, Sash } from '../../../../base/browser/ui/sash/sash.js'; import { Widget } from '../../../../base/browser/ui/widget.js'; @@ -25,7 +27,7 @@ import './findWidget.css'; import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, IViewZone, OverlayWidgetPositionPreference } from '../../../browser/editorBrowser.js'; import { ConfigurationChangedEvent, EditorOption } from '../../../common/config/editorOptions.js'; import { Range } from '../../../common/core/range.js'; -import { CONTEXT_FIND_INPUT_FOCUSED, CONTEXT_FIND_WIDGET_FOCUSED, CONTEXT_REPLACE_INPUT_FOCUSED, FIND_IDS, MATCHES_LIMIT } from './findModel.js'; +import { CONTEXT_FIND_INPUT_FOCUSED, CONTEXT_FIND_WIDGET_FOCUSED, CONTEXT_NTH_MATCH_INPUT_FOCUSED, CONTEXT_REPLACE_INPUT_FOCUSED, FIND_IDS } from './findModel.js'; import { FindReplaceState, FindReplaceStateChangedEvent } from './findState.js'; import * as nls from '../../../../nls.js'; import { AccessibilitySupport, IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; @@ -64,8 +66,11 @@ export interface IFindController { const NLS_FIND_DIALOG_LABEL = nls.localize('label.findDialog', "Find / Replace"); const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find"); const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find"); -const NLS_PREVIOUS_MATCH_BTN_LABEL = nls.localize('label.previousMatchButton', "Previous Match"); +const NLS_NTH_MATCH_INPUT_LABEL = nls.localize('label.nthMatchInput', "Nth Match"); +const NLS_NTH_MATCH_INPUT_PLACEHOLDER = nls.localize('placeholder.nthMatchEdit', "N"); +const NLS_LAST_MATCH_BTN_LABEL = nls.localize('label.lastMatchButton', "Last Highlighted Match"); const NLS_NEXT_MATCH_BTN_LABEL = nls.localize('label.nextMatchButton', "Next Match"); +const NLS_PREVIOUS_MATCH_BTN_LABEL = nls.localize('label.previousMatchButton', "Previous Match"); const NLS_TOGGLE_SELECTION_FIND_TITLE = nls.localize('label.toggleSelectionFind', "Find in Selection"); const NLS_CLOSE_BTN_LABEL = nls.localize('label.closeButton', "Close"); const NLS_REPLACE_INPUT_LABEL = nls.localize('label.replace', "Replace"); @@ -75,6 +80,7 @@ const NLS_REPLACE_ALL_BTN_LABEL = nls.localize('label.replaceAllButton', "Replac const NLS_TOGGLE_REPLACE_MODE_BTN_LABEL = nls.localize('label.toggleReplaceButton', "Toggle Replace"); const NLS_MATCHES_COUNT_LIMIT_TITLE = nls.localize('title.matchesCountLimit', "Only the first {0} results are highlighted, but all find operations work on the entire text.", MATCHES_LIMIT); export const NLS_MATCHES_LOCATION = nls.localize('label.matchesLocation', "{0} of {1}"); +export const NLS_MATCHES_PREPOSITION = nls.localize('label.matchesPreposition', " of "); export const NLS_NO_RESULTS = nls.localize('label.noResults', "No results"); const FIND_WIDGET_INITIAL_WIDTH = 419; @@ -132,9 +138,11 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private _cachedHeight: number | null = null; private _findInput!: FindInput; private _replaceInput!: ReplaceInput; + private _nthMatchInput!: NthMatchInput; private _toggleReplaceBtn!: SimpleButton; private _matchesCount!: HTMLElement; + private _lastMatchBtn!: SimpleButton; private _prevBtn!: SimpleButton; private _nextBtn!: SimpleButton; private _toggleSelectionFind!: Toggle; @@ -156,6 +164,8 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private _widgetFocusTracker: dom.IFocusTracker | undefined; private readonly _findWidgetFocused: IContextKey; private _lastFocusedElement: HTMLElement | null = null; + private readonly _nthMatchInputFocusTracker: dom.IFocusTracker; + private readonly _nthMatchInputFocused: IContextKey; private _viewZone?: FindWidgetViewZone; private _viewZoneId?: string; @@ -279,6 +289,14 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._lastFocusedElement = e.target; } })); + this._nthMatchInputFocused = CONTEXT_NTH_MATCH_INPUT_FOCUSED.bindTo(contextKeyService); + this._nthMatchInputFocusTracker = this._register(dom.trackFocus(this._nthMatchInput.domNode)); + this._register(this._nthMatchInputFocusTracker.onDidFocus(() => { + this._nthMatchInputFocused.set(true); + })); + this._register(this._nthMatchInputFocusTracker.onDidBlur(() => { + this._nthMatchInputFocused.set(false); + })); this._codeEditor.addOverlayWidget(this); if (this._codeEditor.getOption(EditorOption.find).addExtraSpaceOnTop) { @@ -462,8 +480,22 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._matchesCount.title = ''; } - // remove previous content - this._matchesCount.firstChild?.remove(); + + // Remove previous content. + // Only destroy this._matchesCount if there are no results AND there are no blurable input controls within. + // Needless blur events cause focus-retention issues later, so we want to avoid them if we can. + // See dom.ts ---> class FocusTracker {...} for more. + if ( + this._matchesCount.firstChild?.nodeValue === NLS_NO_RESULTS + && ( + !this._matchesCount.querySelector('input') && + !this._matchesCount.querySelector('textarea') + ) + ) { + this._matchesCount.innerText = ''; + } + + // Otherwise, just query the children of this._matchesCount, and update them. let label: string; if (this._state.matchesCount > 0) { @@ -476,18 +508,73 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL matchesPosition = '?'; } label = strings.format(NLS_MATCHES_LOCATION, matchesPosition, matchesCount); + + this._nthMatchInput.setValue(`${matchesPosition}`); + this._nthMatchInput.min = this._state.matchesCount >= 1 ? 1 : 0; + this._nthMatchInput.max = this._state.matchesCount; + this._lastMatchBtn.domNode.innerText = `${matchesCount}`; + + if (([...this._matchesCount.childNodes].length === 0)) { + this._matchesCount.appendChild(this._nthMatchInput.domNode); + this._matchesCount.appendChild(document.createTextNode(NLS_MATCHES_PREPOSITION)); + this._matchesCount.appendChild(this._lastMatchBtn.domNode); + } + } else { label = NLS_NO_RESULTS; + // It's okay to destroy the contents here, + // since there are no matches and therefore no blurable input controls. + this._matchesCount.innerText = ''; + this._matchesCount.appendChild(document.createTextNode(label)); } - this._matchesCount.appendChild(document.createTextNode(label)); - alertFn(this._getAriaLabel(label, this._state.currentMatch, this._state.searchString)); - MAX_MATCHES_COUNT_WIDTH = Math.max(MAX_MATCHES_COUNT_WIDTH, this._matchesCount.clientWidth); + MAX_MATCHES_COUNT_WIDTH = Math.min(MAX_MATCHES_COUNT_WIDTH, this._matchesCount.clientWidth); // Conserve horizontal space } // ----- actions + + private getNthMatchInput(): NthMatchInput { + + const min = 1; + const max = this._state.matchesCount || MATCHES_LIMIT; + const fullDisplayText = NLS_NTH_MATCH_INPUT_LABEL + this._keybindingLabelFor(FIND_IDS.NthMatchFindAction); + const input = new NthMatchInput(this._domNode, this._contextViewProvider, { + label: fullDisplayText, + tooltip: fullDisplayText, + placeholder: NLS_NTH_MATCH_INPUT_PLACEHOLDER, + type: 'text', + min: min, + max: max, + inputBoxStyles: defaultInputBoxStyles, + }); + + this._register(input.onStep((e) => { + if (e.to === 'next') { + assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.NextMatchFindAction)).run().then(undefined, onUnexpectedError); + } + else { + assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction)).run().then(undefined, onUnexpectedError); + } + input.updateInputWrapperWidth(); + })); + + this._register(input.onInput((e) => { + if (!input.getValue()) { + return; + } + const n = input.getSanitizedCurrentValue(); + input.setValue(String(n)); + assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.NthMatchFindAction)).run({ n }).then(undefined, onUnexpectedError); + })); + + input.domNode.classList.add(...['monaco-inputbox', 'nth-match', 'editor-nth-match']); + input.setValue(`${this._state.matchesPosition}`); + + return input; + } + private _getAriaLabel(label: string, currentMatch: Range | null, searchString: string): string { let result: string; if (label === NLS_NO_RESULTS) { @@ -534,8 +621,10 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL const findInputIsNonEmpty = (this._state.searchString.length > 0); const matchesCount = this._state.matchesCount ? true : false; + const canNavigateForward = this._state.canNavigateForward(); + this._lastMatchBtn.setEnabled(this._isVisible && findInputIsNonEmpty && matchesCount && canNavigateForward); this._prevBtn.setEnabled(this._isVisible && findInputIsNonEmpty && matchesCount && this._state.canNavigateBack()); - this._nextBtn.setEnabled(this._isVisible && findInputIsNonEmpty && matchesCount && this._state.canNavigateForward()); + this._nextBtn.setEnabled(/* false */this._isVisible && findInputIsNonEmpty && matchesCount && canNavigateForward); this._replaceBtn.setEnabled(this._isVisible && this._isReplaceVisible && findInputIsNonEmpty); this._replaceAllBtn.setEnabled(this._isVisible && this._isReplaceVisible && findInputIsNonEmpty); @@ -577,6 +666,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } this._tryUpdateWidgetWidth(); + this._updateMatchesCount(); this._updateButtons(); this._revealTimeouts.push(setTimeout(() => { @@ -1023,12 +1113,14 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._state.change({ searchString: this._findInput.getValue() }, true); } this._onFindInputKeyDown(e); + this._nthMatchInput.updateInputWrapperWidth(); })); this._register(this._findInput.inputBox.onDidChange(() => { if (this._ignoreChangeEvent || !this._codeEditor.getOption(EditorOption.find).findOnType) { return; } this._state.change({ searchString: this._findInput.getValue() }, true); + this._nthMatchInput.updateInputWrapperWidth(); })); this._register(this._findInput.onDidOptionChange(() => { this._state.change({ @@ -1036,6 +1128,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL wholeWord: this._findInput.getWholeWords(), matchCase: this._findInput.getCaseSensitive() }, true); + this._nthMatchInput.updateInputWrapperWidth(); })); this._register(this._findInput.onCaseSensitiveKeyDown((e) => { if (e.equals(KeyMod.Shift | KeyCode.Tab)) { @@ -1066,8 +1159,21 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._matchesCount.className = 'matchesCount'; this._updateMatchesCount(); + this._nthMatchInput = this.getNthMatchInput(); + const hoverLifecycleOptions: IHoverLifecycleOptions = { groupId: 'find-widget' }; + this._lastMatchBtn = this._register(new SimpleButton({ + label: NLS_LAST_MATCH_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.LastMatchFindAction), + hoverLifecycleOptions, + onTrigger: () => { + assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.LastMatchFindAction)).run({ n: this._state.matchesCount }).then(undefined, onUnexpectedError); + this._nthMatchInput.setValue(String(this._state.matchesCount)); + } + }, this._hoverService)); + this._lastMatchBtn.domNode.classList.add(...['last-match-btn']); + + // Previous button this._prevBtn = this._register(new SimpleButton({ label: NLS_PREVIOUS_MATCH_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.PreviousMatchFindAction), diff --git a/src/vs/editor/contrib/find/test/browser/findController.test.ts b/src/vs/editor/contrib/find/test/browser/findController.test.ts index 1823dd31e1ab9..c1e6998a376e8 100644 --- a/src/vs/editor/contrib/find/test/browser/findController.test.ts +++ b/src/vs/editor/contrib/find/test/browser/findController.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { MATCHES_LIMIT } from '../../../../../base/browser/ui/findinput/findContants.js'; import { Delayer } from '../../../../../base/common/async.js'; import * as platform from '../../../../../base/common/platform.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -13,7 +14,7 @@ import { EditOperation } from '../../../../common/core/editOperation.js'; import { Position } from '../../../../common/core/position.js'; import { Range } from '../../../../common/core/range.js'; import { Selection } from '../../../../common/core/selection.js'; -import { CommonFindController, FindStartFocusAction, IFindStartOptions, NextMatchFindAction, NextSelectionMatchFindAction, StartFindAction, StartFindReplaceAction, StartFindWithSelectionAction } from '../../browser/findController.js'; +import { CommonFindController, FindStartFocusAction, IFindStartOptions, MoveToLastMatchFindAction, MoveToNthMatchFindAction, NextMatchFindAction, NextSelectionMatchFindAction, StartFindAction, StartFindReplaceAction, StartFindWithSelectionAction } from '../../browser/findController.js'; import { CONTEXT_FIND_INPUT_FOCUSED } from '../../browser/findModel.js'; import { withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; @@ -543,6 +544,269 @@ suite('FindController', () => { findController.dispose(); }); }); + + test('Nth Match: Highlight the correct match so the `matchesPosition` value is equal to the nthMatchInput value', async () => { + const testCodeEditorLines = [ + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + ]; + await withAsyncTestCodeEditor(testCodeEditorLines, { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + const moveToNthMatchFindAction = new MoveToNthMatchFindAction(); + + // Open the find widget. + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + // Search for 'DEF'. + findState.change({ searchString: 'DEF' }, true); + + // Type in a numeric value for N that is probably in-bounds. + const nthMatchSimulatedUserInput = '4'; + await editor.runAction(moveToNthMatchFindAction, { n: nthMatchSimulatedUserInput }); + + // Assert that the number of matches is the same as the number of occurrences in the editor. + assert.strictEqual(findState.matchesCount, testCodeEditorLines.filter(line => line === 'DEF').length); + + // Assert that the matchesPosition of the highlighted match is equal to nthMatchSimulatedUserInput. + assert.strictEqual(findState.matchesPosition, parseInt(nthMatchSimulatedUserInput)); + + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + + test('Nth Match: LEFT Bound Check - Highlight the FIRST match if the user input for N IS numerically parsable but IS LESS THAN 1', async () => { + const testCodeEditorLines = [ + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + ]; + await withAsyncTestCodeEditor(testCodeEditorLines, { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + const moveToNthMatchFindAction = new MoveToNthMatchFindAction(); + + // Open the find widget. + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + // Search for 'DEF'. + findState.change({ searchString: 'DEF' }, true); + + // Type in a numeric value for N that is out-of-bounds to the LEFT. + const nthMatchSimulatedUserInput = '-2'; + await editor.runAction(moveToNthMatchFindAction, { n: nthMatchSimulatedUserInput }); + + // Assert that the number of matches is the same as the number of occurrences in the editor. + assert.strictEqual(findState.matchesCount, testCodeEditorLines.filter(line => line === 'DEF').length); + + // Assert that the matchesPosition value of the highlighted match is 1, + // the position of the first match. + assert.strictEqual(findState.matchesPosition, 1); + + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + + test('Nth Match: LEFT Bound Check - Highlight the FIRST match if the user input for N IS NOT numerically parsable', async () => { + const testCodeEditorLines = [ + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + ]; + await withAsyncTestCodeEditor(testCodeEditorLines, { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + const moveToNthMatchFindAction = new MoveToNthMatchFindAction(); + + + // Open the find widget. + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + // Search for 'DEF'. + findState.change({ searchString: 'DEF' }, true); + + // Type in an alphabetical value for N. + const nthMatchSimulatedUserInput = 'abc'; + await editor.runAction(moveToNthMatchFindAction, { n: nthMatchSimulatedUserInput }); + + // Assert that the number of matches is the same as the number of occurrences in the editor. + assert.strictEqual(findState.matchesCount, testCodeEditorLines.filter(line => line === 'DEF').length); + + // Assert that the matchesPosition value of the highlighted match is 1, + // the position of the first match. + assert.strictEqual(findState.matchesPosition, 1); + + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + + test('Nth Match: RIGHT Bound Check - Highlight the LAST match if the user input for N IS numerically parsable but IS GREATER THAN `matchesCount`', async () => { + const testCodeEditorLines = [ + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + ]; + await withAsyncTestCodeEditor(testCodeEditorLines, { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + const moveToNthMatchFindAction = new MoveToNthMatchFindAction(); + + // Open the find widget. + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + // Search for 'DEF'. + findState.change({ searchString: 'DEF' }, true); + + // Type in a numeric value for N that exceeds the maximum. + // The nthMatchInput validates user input so that the + // value is bound between 1 and (matchesCount | MATCHES_LIMIT). + const rawUserInput = findState.matchesCount + 100; + const nthMatchSimulatedUserInput = `${Math.min(findState.matchesCount, Math.min(rawUserInput, MATCHES_LIMIT))}`; + await editor.runAction(moveToNthMatchFindAction, { n: nthMatchSimulatedUserInput }); + + // Assert that the number of matches is the same as the number of occurrences in the editor. + assert.strictEqual(findState.matchesCount, testCodeEditorLines.filter(line => line === 'DEF').length); + + // Assert that the matchesPosition value of the highlighted match is equal to matchesCount, + // the position of the last highlightable match. + assert.strictEqual(findState.matchesPosition, findState.matchesCount); + + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + + test('Nth Match: RIGHT Bound Check - Highlight the LAST match if the user input for N IS numerically parsable but IS GREATER THAN `MATCHES_LIMIT`', async () => { + const testCodeEditorLines = [ + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + ]; + await withAsyncTestCodeEditor(testCodeEditorLines, { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + const moveToNthMatchFindAction = new MoveToNthMatchFindAction(); + + // Open the find widget. + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + // Search for 'DEF'. + findState.change({ searchString: 'DEF' }, true); + + // Type a numeric value for N that exceeds the maximum. + // The nthMatchInput validates user input so that + // N is bound between 1 and (matchesCount | MATCHES_LIMIT). + const rawUserInput = MATCHES_LIMIT + 100; + const nthMatchSimulatedUserInput = `${Math.min(findState.matchesCount, Math.min(rawUserInput, MATCHES_LIMIT))}`; + await editor.runAction(moveToNthMatchFindAction, { n: nthMatchSimulatedUserInput }); + + // Assert that the number of matches is the same as the number of occurrences in the editor. + assert.strictEqual(findState.matchesCount, testCodeEditorLines.filter(line => line === 'DEF').length); + + // Assert that the matchesPosition value of the highlighted match is equal to matchesCount, + // the position of the last highlightable match. + assert.strictEqual(findState.matchesPosition, findState.matchesCount); + + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + + + test('Nth Match: Last Match - Highlight the LAST match so the `matchesPostion` value is the same as the `matchesCount` or `MATCHES_LIMIT` value', async () => { + const testCodeEditorLines = [ + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + 'ABC', + 'DEF', + 'XYZ', + ]; + await withAsyncTestCodeEditor(testCodeEditorLines, { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + const moveToLastMatchFindAction = new MoveToLastMatchFindAction(); + + // Open the find widget. + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + // Search for 'DEF'. + findState.change({ searchString: 'DEF' }, true); + + // Simulate a click on the matchesCount widget (lastMatchButton) to trigger MoveToLastMatchFindAction. + await editor.runAction(moveToLastMatchFindAction, { n: findState.matchesCount || MATCHES_LIMIT }); + + // Assert that the number of matches is the same as the number of occurrences in the editor. + assert.strictEqual(findState.matchesCount, testCodeEditorLines.filter(line => line === 'DEF').length); + + // Assert that the highlighted match is the last highlightable match in the editor. + assert.strictEqual(findState.matchesPosition, findState.matchesCount); + + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); }); suite('FindController query options persistence', () => { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts index 241f78d087c4a..1ae406e63c768 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts @@ -16,7 +16,7 @@ import { Lazy } from '../../../../../base/common/lazy.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IBrowserViewModel } from '../../common/browserView.js'; import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; -import { SimpleFindWidget } from '../../../codeEditor/browser/find/simpleFindWidget.js'; +import { SimpleWebFindWidget } from '../../../codeEditor/browser/find/simpleWebFindWidget.js'; import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; @@ -33,7 +33,7 @@ const CONTEXT_BROWSER_FIND_WIDGET_FOCUSED = new RawContextKey('browserF * Uses the SimpleFindWidget base class and communicates with the browser view model * to perform find operations in the rendered web page. */ -class BrowserFindWidget extends SimpleFindWidget { +class BrowserFindWidget extends SimpleWebFindWidget { private _model: IBrowserViewModel | undefined; private readonly _modelDisposables = this._register(new DisposableStore()); private readonly _findWidgetVisible: IContextKey; @@ -192,6 +192,7 @@ class BrowserFindWidget extends SimpleFindWidget { protected _onFindInputFocusTrackerBlur(): void { // No-op } + } /** diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css index 6fb4864cf8723..042c4d5520477 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css @@ -58,10 +58,25 @@ } .monaco-workbench .simple-find-part .matchesCount { - width: 73px; - max-width: 73px; + display: flex; + align-items: center; + justify-content: space-between; min-width: 73px; + max-width: 150px; padding-left: 5px; + margin-right: 5px; +} + +.monaco-workbench .simple-find-part .matchesCount input { + height: 25px; +} + +.monaco-workbench .simple-find-part .matchesCount .last-match-btn { + width: fit-content; + height: 18px; + margin-left: 10px; + padding: 3px; + box-shadow: 0 2px 4px 0 var(--vscode-widget-shadow); } .monaco-workbench .simple-find-part.reduced-find-widget .matchesCount { diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts index 9e1f0e696f3c4..b2948e1e1c0ec 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts @@ -7,6 +7,7 @@ import './simpleFindWidget.css'; import * as nls from '../../../../../nls.js'; import * as dom from '../../../../../base/browser/dom.js'; import { FindInput } from '../../../../../base/browser/ui/findinput/findInput.js'; +import { NthMatchInput } from '../../../../../base/browser/ui/findinput/nthMatchInput.js'; import { Widget } from '../../../../../base/browser/ui/widget.js'; import { Delayer, disposableTimeout } from '../../../../../base/common/async.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; @@ -33,6 +34,10 @@ import { IAccessibilityService } from '../../../../../platform/accessibility/com const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find"); const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find"); +const NLS_NTH_MATCH_INPUT_LABEL = nls.localize('label.nthMatchEdit', "Nth Match"); +const NLS_NTH_MATCH_INPUT_PLACEHOLDER = nls.localize('placeholder.nthMatchEdit', "N"); +const NLS_MATCHES_PREPOSITION = nls.localize('label.matchesPreposition', " of "); +const NLS_LAST_MATCH_BTN_LABEL = nls.localize('label.lastMatchButton', "Last Highlighted Match"); const NLS_PREVIOUS_MATCH_BTN_LABEL = nls.localize('label.previousMatchButton', "Previous Match"); const NLS_NEXT_MATCH_BTN_LABEL = nls.localize('label.nextMatchButton', "Next Match"); const NLS_CLOSE_BTN_LABEL = nls.localize('label.closeButton', "Close"); @@ -44,6 +49,8 @@ interface IFindOptions { appendCaseSensitiveActionId?: string; appendRegexActionId?: string; appendWholeWordsActionId?: string; + nthMatchActionId?: string; + lastMatchActionId?: string; previousMatchActionId?: string; nextMatchActionId?: string; closeWidgetActionId?: string; @@ -58,11 +65,14 @@ const MATCHES_COUNT_WIDTH = 73; export abstract class SimpleFindWidget extends Widget implements IVerticalSashLayoutProvider { private readonly _findInput: FindInput; + public readonly _nthMatchInput: NthMatchInput; private readonly _domNode: HTMLElement; private readonly _innerDomNode: HTMLElement; private readonly _focusTracker: dom.IFocusTracker; private readonly _findInputFocusTracker: dom.IFocusTracker; + private readonly _nthMatchInputFocusTracker: dom.IFocusTracker; private readonly _updateHistoryDelayer: Delayer; + private readonly lastMatchBtn: SimpleButton; private readonly prevBtn: SimpleButton; private readonly nextBtn: SimpleButton; private readonly _matchesLimit: number; @@ -120,6 +130,8 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa inputBoxStyles: defaultInputBoxStyles, toggleStyles: defaultToggleStyles }, contextKeyService)); + + // Find History with update delayer this._updateHistoryDelayer = this._register(new Delayer(500)); @@ -156,6 +168,21 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa const hoverLifecycleOptions: IHoverLifecycleOptions = { groupId: 'simple-find-widget' }; + this._nthMatchInput = this.getNthMatchInput(contextViewService, options); + + this.lastMatchBtn = this._register(new SimpleButton({ + label: NLS_LAST_MATCH_BTN_LABEL + (options.lastMatchActionId ? this._getKeybinding(options.lastMatchActionId) : ''), + hoverLifecycleOptions, + onTrigger: () => { + const countParts = this.lastMatchBtn.domNode.innerText?.split('+') || []; + const trueCount = parseInt(countParts[0]); + const n = !isNaN(trueCount) ? trueCount : this._matchesLimit; + this.findNth(n); + this._nthMatchInput.setValue(String(n)); + } + }, hoverService)); + this.lastMatchBtn.domNode.classList.add(...['last-match-btn']); + this.prevBtn = this._register(new SimpleButton({ label: NLS_PREVIOUS_MATCH_BTN_LABEL + (options.previousMatchActionId ? this._getKeybinding(options.previousMatchActionId) : ''), icon: findPreviousMatchIcon, @@ -211,6 +238,10 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa this._register(this._findInputFocusTracker.onDidFocus(this._onFindInputFocusTrackerFocus.bind(this))); this._register(this._findInputFocusTracker.onDidBlur(this._onFindInputFocusTrackerBlur.bind(this))); + this._nthMatchInputFocusTracker = this._register(dom.trackFocus(this._nthMatchInput.domNode)); + this._register(this._nthMatchInputFocusTracker.onDidFocus(this._onNthMatchInputFocusTrackerFocus.bind(this))); + this._register(this._nthMatchInputFocusTracker.onDidBlur(this._onNthMatchInputFocusTrackerBlur.bind(this))); + this._register(dom.addDisposableListener(this._innerDomNode, 'click', (event) => { event.stopPropagation(); })); @@ -221,6 +252,9 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa this._matchesCount.className = 'matchesCount'; this._findInput.domNode.insertAdjacentElement('afterend', this._matchesCount); this._register(this._findInput.onDidChange(async () => { + // Query the search engine to get the latest results. + // Otherwise, the cached results might not reflect the changed input. + this.findNth(this._nthMatchInput.getSanitizedCurrentValue()); await this.updateResultCount(); })); this._register(this._findInput.onDidOptionChange(async () => { @@ -272,11 +306,14 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa public abstract find(previous: boolean): void; public abstract findFirst(): void; + public abstract findNth(n: number): void; protected abstract _onInputChanged(): boolean; protected abstract _onFocusTrackerFocus(): void; protected abstract _onFocusTrackerBlur(): void; protected abstract _onFindInputFocusTrackerFocus(): void; protected abstract _onFindInputFocusTrackerBlur(): void; + protected abstract _onNthMatchInputFocusTrackerFocus(): void; + protected abstract _onNthMatchInputFocusTrackerBlur(): void; protected abstract _getResultCount(): Promise<{ resultIndex: number; resultCount: number } | undefined>; protected get inputValue() { @@ -407,6 +444,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa protected updateButtons(foundMatch: boolean) { const hasInput = this.inputValue.length > 0; + this.lastMatchBtn.setEnabled(this._isVisible && hasInput && foundMatch); this.prevBtn.setEnabled(this._isVisible && hasInput && foundMatch); this.nextBtn.setEnabled(this._isVisible && hasInput && foundMatch); } @@ -425,7 +463,22 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa } const count = await this._getResultCount(); - this._matchesCount.textContent = ''; + + // Only destroy this._matchesCount if there are no results, or there are no blurable input controls within. + // Needless blur events cause focus-retention issues later, so we want to avoid them if we can. + // See dom.ts ---> class FocusTracker {...} for more. + if ( + this._matchesCount.firstChild?.nodeValue === NLS_NO_RESULTS + && ( + !this._matchesCount.querySelector('input') && + !this._matchesCount.querySelector('textarea') + ) + ) { + this._matchesCount.innerText = ''; + } + + // Otherwise, just query the children of this._matchesCount, and update them. + const showRedOutline = (this.inputValue.length > 0 && count?.resultCount === 0); this._matchesCount.classList.toggle('no-results', showRedOutline); let label = ''; @@ -438,12 +491,31 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa if (matchesPosition === '0') { matchesPosition = '?'; } + label = strings.format(NLS_MATCHES_LOCATION, matchesPosition, matchesCount); + + const countParts = matchesCount?.split('+') || []; + const trueCount = parseInt(countParts[0]); + + this._nthMatchInput.setValue(`${matchesPosition}`); + this._nthMatchInput.min = 1; + this._nthMatchInput.max = !isNaN(trueCount) ? trueCount : this._matchesLimit; + this.lastMatchBtn.domNode.innerText = `${matchesCount}`; + + if (([...this._matchesCount.childNodes].length === 0)) { + this._matchesCount.appendChild(this._nthMatchInput.domNode); + this._matchesCount.appendChild(document.createTextNode(NLS_MATCHES_PREPOSITION)); + this._matchesCount.appendChild(this.lastMatchBtn.domNode); + } + } else { label = NLS_NO_RESULTS; + // It's okay to destroy contents here, + // since there are no matches and therefore no blurable input controls. + this._matchesCount.innerText = ''; + this._matchesCount.appendChild(document.createTextNode(label)); } status(this._announceSearchResults(label, this.inputValue)); - this._matchesCount.appendChild(document.createTextNode(label)); this._foundMatch = !!count && count.resultCount > 0; this.updateButtons(this._foundMatch); } @@ -494,6 +566,52 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa return nls.localize('ariaSearchNoResultWithLineNumNoCurrentMatch', "{0} found for '{1}'", label, searchString); } + + private getNthMatchInput(contextViewService: IContextViewService, options: IFindOptions): NthMatchInput { + const countParts = this._matchesCount?.innerText?.split(NLS_MATCHES_PREPOSITION) ?? []; + + const truePosition = parseInt(countParts[0]?.trim()); + const trueCount = parseInt(countParts[1]?.trim()); + + const min = 1; + const max = !isNaN(trueCount) ? trueCount : this._matchesLimit; + + const fullDisplayText = NLS_NTH_MATCH_INPUT_LABEL + (options.nthMatchActionId ? this._getKeybinding(options.nthMatchActionId) : ''); + + const input = new NthMatchInput(this._domNode, contextViewService, { + label: fullDisplayText, + tooltip: fullDisplayText, + placeholder: NLS_NTH_MATCH_INPUT_PLACEHOLDER, + type: 'text', + min: min, + max: max, + inputBoxStyles: defaultInputBoxStyles, + }); + + this._register(input.onStep((e) => { + if (e.to === 'next') { + this.find(false); + } + else { + this.find(true); + } + input.updateInputWrapperWidth(); + })); + + this._register(input.onInput((e) => { + if (!input.getValue()) { + return; + } + const inputValueAsSanitizedInt = input.getSanitizedCurrentValue(); + input.setValue(`${inputValueAsSanitizedInt}`); + this.findNth(inputValueAsSanitizedInt); + })); + + input.domNode.classList.add(...['monaco-inputbox', 'nth-match', 'simple-nth-match']); + input.setValue(!isNaN(truePosition) ? String(truePosition) : String(min)); + + return input; + } } export const simpleFindWidgetSashBorder = registerColor('simpleFindWidget.sashBorder', { dark: '#454545', light: '#C8C8C8', hcDark: '#6FC3DF', hcLight: '#0F4A85' }, nls.localize('simpleFindWidget.sashBorder', 'Border color of the sash border.')); diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleWebFindWidget.css b/src/vs/workbench/contrib/codeEditor/browser/find/simpleWebFindWidget.css new file mode 100644 index 0000000000000..042c4d5520477 --- /dev/null +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleWebFindWidget.css @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench .simple-find-part-wrapper { + overflow: hidden; + z-index: 10; + position: absolute; + top: 0; + right: 18px; + max-width: calc(100% - 28px - 28px - 8px); + pointer-events: none; + padding: 0 10px 10px; +} + +.simple-find-part .monaco-inputbox > .ibwrapper > input { + text-overflow: clip; +} + +.monaco-workbench .simple-find-part { + visibility: hidden; /* Use visibility to maintain flex layout while hidden otherwise interferes with transition */ + z-index: 10; + position: relative; + top: -45px; + display: flex; + padding: 4px; + align-items: center; + pointer-events: all; + transition: top 200ms linear; + background-color: var(--vscode-editorWidget-background) !important; + color: var(--vscode-editorWidget-foreground); + box-shadow: var(--vscode-shadow-lg); + border: 1px solid var(--vscode-widget-border); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + font-size: 12px; +} + +.monaco-workbench.monaco-reduce-motion .monaco-editor .find-widget { + transition: top 0ms linear; +} + +.monaco-workbench .simple-find-part.visible { + visibility: visible; +} + +.monaco-workbench .simple-find-part.suppress-transition { + transition: none; +} + +.monaco-workbench .simple-find-part.visible-transition { + top: 0; +} + +.monaco-workbench .simple-find-part .monaco-findInput { + flex: 1; +} + +.monaco-workbench .simple-find-part .matchesCount { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 73px; + max-width: 150px; + padding-left: 5px; + margin-right: 5px; +} + +.monaco-workbench .simple-find-part .matchesCount input { + height: 25px; +} + +.monaco-workbench .simple-find-part .matchesCount .last-match-btn { + width: fit-content; + height: 18px; + margin-left: 10px; + padding: 3px; + box-shadow: 0 2px 4px 0 var(--vscode-widget-shadow); +} + +.monaco-workbench .simple-find-part.reduced-find-widget .matchesCount { + display: none; +} + +.monaco-workbench .simple-find-part .button { + min-width: 20px; + width: 20px; + height: 20px; + line-height: 20px; + display: flex; + flex: initial; + justify-content: center; + margin-left: 3px; + background-position: center center; + background-repeat: no-repeat; + cursor: pointer; +} + +.monaco-workbench div.simple-find-part div.button.disabled { + opacity: 0.3 !important; + cursor: default; +} + +div.simple-find-part-wrapper div.button { + border-radius: 5px; +} + +.no-results.matchesCount { + color: var(--vscode-errorForeground); +} + +div.simple-find-part-wrapper div.button:hover:not(.disabled) { + background-color: var(--vscode-toolbar-hoverBackground); + outline: 1px dashed var(--vscode-toolbar-hoverOutline); + outline-offset: -1px; +} + +.monaco-workbench .simple-find-part .monaco-sash { + left: 0 !important; + border-left: 1px solid; + border-bottom-left-radius: 4px; +} + +.monaco-workbench .simple-find-part .monaco-sash.vertical:before { + width: 2px; + left: calc(50% - (var(--vscode-sash-hover-size) / 4)); +} diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleWebFindWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleWebFindWidget.ts new file mode 100644 index 0000000000000..98bd36cf20a2c --- /dev/null +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleWebFindWidget.ts @@ -0,0 +1,504 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './simpleWebFindWidget.css'; +import * as nls from '../../../../../nls.js'; +import * as dom from '../../../../../base/browser/dom.js'; +import { FindInput } from '../../../../../base/browser/ui/findinput/findInput.js'; +import { Widget } from '../../../../../base/browser/ui/widget.js'; +import { Delayer, disposableTimeout } from '../../../../../base/common/async.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { FindReplaceState, INewFindReplaceState } from '../../../../../editor/contrib/find/browser/findState.js'; +import { IMessage as InputBoxMessage } from '../../../../../base/browser/ui/inputbox/inputBox.js'; +import { SimpleButton, findPreviousMatchIcon, findNextMatchIcon, NLS_NO_RESULTS, NLS_MATCHES_LOCATION } from '../../../../../editor/contrib/find/browser/findWidget.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { ContextScopedFindInput } from '../../../../../platform/history/browser/contextScopedHistoryWidget.js'; +import { widgetClose } from '../../../../../platform/theme/common/iconRegistry.js'; +import { registerThemingParticipant } from '../../../../../platform/theme/common/themeService.js'; +import * as strings from '../../../../../base/common/strings.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { showHistoryKeybindingHint } from '../../../../../platform/history/browser/historyWidgetKeybindingHint.js'; +import { status } from '../../../../../base/browser/ui/aria/aria.js'; +import { defaultInputBoxStyles, defaultToggleStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { ISashEvent, IVerticalSashLayoutProvider, Orientation, Sash } from '../../../../../base/browser/ui/sash/sash.js'; +import { registerColor } from '../../../../../platform/theme/common/colorRegistry.js'; +import type { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import type { IHoverLifecycleOptions } from '../../../../../base/browser/ui/hover/hover.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; + +const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find"); +const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find"); +const NLS_PREVIOUS_MATCH_BTN_LABEL = nls.localize('label.previousMatchButton', "Previous Match"); +const NLS_NEXT_MATCH_BTN_LABEL = nls.localize('label.nextMatchButton', "Next Match"); +const NLS_CLOSE_BTN_LABEL = nls.localize('label.closeButton', "Close"); + +interface IFindOptions { + showCommonFindToggles?: boolean; + checkImeCompletionState?: boolean; + showResultCount?: boolean; + appendCaseSensitiveActionId?: string; + appendRegexActionId?: string; + appendWholeWordsActionId?: string; + previousMatchActionId?: string; + nextMatchActionId?: string; + closeWidgetActionId?: string; + matchesLimit?: number; + type?: 'Terminal' | 'Webview'; + initialWidth?: number; + enableSash?: boolean; +} + +const SIMPLE_FIND_WIDGET_INITIAL_WIDTH = 310; +const MATCHES_COUNT_WIDTH = 73; + +export abstract class SimpleWebFindWidget extends Widget implements IVerticalSashLayoutProvider { + private readonly _findInput: FindInput; + private readonly _domNode: HTMLElement; + private readonly _innerDomNode: HTMLElement; + private readonly _focusTracker: dom.IFocusTracker; + private readonly _findInputFocusTracker: dom.IFocusTracker; + private readonly _updateHistoryDelayer: Delayer; + private readonly prevBtn: SimpleButton; + private readonly nextBtn: SimpleButton; + private readonly _matchesLimit: number; + private _matchesCount: HTMLElement | undefined; + + private _isVisible: boolean = false; + private _foundMatch: boolean = false; + private _width: number = 0; + + /** + * Tracks whether the accessibility help hint has been announced in the ARIA label. + * Reset to false when the widget is hidden, allowing the hint to be announced again + * on the next reveal. + */ + private _accessibilityHelpHintAnnounced: boolean = false; + private _labelResetTimeout: IDisposable | undefined; + + readonly state: FindReplaceState; + + constructor( + options: IFindOptions, + contextViewService: IContextViewService, + contextKeyService: IContextKeyService, + hoverService: IHoverService, + private readonly _keybindingService: IKeybindingService, + private readonly _configurationService: IConfigurationService, + private readonly _accessibilityService: IAccessibilityService, + ) { + super(); + + this.state = this._register(new FindReplaceState()); + this._matchesLimit = options.matchesLimit ?? Number.MAX_SAFE_INTEGER; + + this._findInput = this._register(new ContextScopedFindInput(null, contextViewService, { + label: NLS_FIND_INPUT_LABEL, + placeholder: NLS_FIND_INPUT_PLACEHOLDER, + validation: (value: string): InputBoxMessage | null => { + if (value.length === 0 || !this._findInput.getRegex()) { + return null; + } + try { + new RegExp(value); + return null; + } catch (e) { + this._foundMatch = false; + this.updateButtons(this._foundMatch); + return { content: e.message }; + } + }, + showCommonFindToggles: options.showCommonFindToggles, + appendCaseSensitiveLabel: options.appendCaseSensitiveActionId ? this._getKeybinding(options.appendCaseSensitiveActionId) : undefined, + appendRegexLabel: options.appendRegexActionId ? this._getKeybinding(options.appendRegexActionId) : undefined, + appendWholeWordsLabel: options.appendWholeWordsActionId ? this._getKeybinding(options.appendWholeWordsActionId) : undefined, + showHistoryHint: () => showHistoryKeybindingHint(_keybindingService), + inputBoxStyles: defaultInputBoxStyles, + toggleStyles: defaultToggleStyles + }, contextKeyService)); + // Find History with update delayer + this._updateHistoryDelayer = this._register(new Delayer(500)); + + this._register(this._findInput.onInput(async (e) => { + if (!options.checkImeCompletionState || !this._findInput.isImeSessionInProgress) { + this._foundMatch = this._onInputChanged(); + if (options.showResultCount) { + await this.updateResultCount(); + } + this.updateButtons(this._foundMatch); + this.focusFindBox(); + this._delayedUpdateHistory(); + } + })); + + this._findInput.setRegex(!!this.state.isRegex); + this._findInput.setCaseSensitive(!!this.state.matchCase); + this._findInput.setWholeWords(!!this.state.wholeWord); + + this._register(this._findInput.onDidOptionChange(() => { + this.state.change({ + isRegex: this._findInput.getRegex(), + wholeWord: this._findInput.getWholeWords(), + matchCase: this._findInput.getCaseSensitive() + }, true); + })); + + this._register(this.state.onFindReplaceStateChange(() => { + this._findInput.setRegex(this.state.isRegex); + this._findInput.setWholeWords(this.state.wholeWord); + this._findInput.setCaseSensitive(this.state.matchCase); + this.findFirst(); + })); + + const hoverLifecycleOptions: IHoverLifecycleOptions = { groupId: 'simple-find-widget' }; + + this.prevBtn = this._register(new SimpleButton({ + label: NLS_PREVIOUS_MATCH_BTN_LABEL + (options.previousMatchActionId ? this._getKeybinding(options.previousMatchActionId) : ''), + icon: findPreviousMatchIcon, + hoverLifecycleOptions, + onTrigger: () => { + this.find(true); + } + }, hoverService)); + + this.nextBtn = this._register(new SimpleButton({ + label: NLS_NEXT_MATCH_BTN_LABEL + (options.nextMatchActionId ? this._getKeybinding(options.nextMatchActionId) : ''), + icon: findNextMatchIcon, + hoverLifecycleOptions, + onTrigger: () => { + this.find(false); + } + }, hoverService)); + + const closeBtn = this._register(new SimpleButton({ + label: NLS_CLOSE_BTN_LABEL + (options.closeWidgetActionId ? this._getKeybinding(options.closeWidgetActionId) : ''), + icon: widgetClose, + hoverLifecycleOptions, + onTrigger: () => { + this.hide(); + } + }, hoverService)); + + this._innerDomNode = document.createElement('div'); + this._innerDomNode.classList.add('simple-find-part'); + this._innerDomNode.appendChild(this._findInput.domNode); + this._innerDomNode.appendChild(this.prevBtn.domNode); + this._innerDomNode.appendChild(this.nextBtn.domNode); + this._innerDomNode.appendChild(closeBtn.domNode); + + // _domNode wraps _innerDomNode, ensuring that + this._domNode = document.createElement('div'); + this._domNode.classList.add('simple-find-part-wrapper'); + this._domNode.appendChild(this._innerDomNode); + + this.onkeyup(this._innerDomNode, e => { + if (e.equals(KeyCode.Escape)) { + this.hide(); + e.preventDefault(); + return; + } + }); + + this._focusTracker = this._register(dom.trackFocus(this._innerDomNode)); + this._register(this._focusTracker.onDidFocus(this._onFocusTrackerFocus.bind(this))); + this._register(this._focusTracker.onDidBlur(this._onFocusTrackerBlur.bind(this))); + + this._findInputFocusTracker = this._register(dom.trackFocus(this._findInput.domNode)); + this._register(this._findInputFocusTracker.onDidFocus(this._onFindInputFocusTrackerFocus.bind(this))); + this._register(this._findInputFocusTracker.onDidBlur(this._onFindInputFocusTrackerBlur.bind(this))); + + this._register(dom.addDisposableListener(this._innerDomNode, 'click', (event) => { + event.stopPropagation(); + })); + + if (options?.showResultCount) { + this._domNode.classList.add('result-count'); + this._matchesCount = document.createElement('div'); + this._matchesCount.className = 'matchesCount'; + this._findInput.domNode.insertAdjacentElement('afterend', this._matchesCount); + this._register(this._findInput.onDidChange(async () => { + await this.updateResultCount(); + })); + this._register(this._findInput.onDidOptionChange(async () => { + this._foundMatch = this._onInputChanged(); + await this.updateResultCount(); + this.focusFindBox(); + this._delayedUpdateHistory(); + })); + } + + let initialMinWidth = options?.initialWidth; + if (initialMinWidth) { + initialMinWidth = initialMinWidth < SIMPLE_FIND_WIDGET_INITIAL_WIDTH ? SIMPLE_FIND_WIDGET_INITIAL_WIDTH : initialMinWidth; + this._domNode.style.width = `${initialMinWidth}px`; + } + + if (options?.enableSash) { + const _initialMinWidth = initialMinWidth ?? SIMPLE_FIND_WIDGET_INITIAL_WIDTH; + let originalWidth = _initialMinWidth; + + // sash + const resizeSash = this._register(new Sash(this._innerDomNode, this, { orientation: Orientation.VERTICAL, size: 1 })); + this._register(resizeSash.onDidStart(() => { + originalWidth = parseFloat(dom.getComputedStyle(this._domNode).width); + })); + + this._register(resizeSash.onDidChange((e: ISashEvent) => { + const width = originalWidth + e.startX - e.currentX; + if (width < _initialMinWidth) { + return; + } + this._domNode.style.width = `${width}px`; + })); + + this._register(resizeSash.onDidReset(e => { + const currentWidth = parseFloat(dom.getComputedStyle(this._domNode).width); + if (currentWidth === _initialMinWidth) { + this._domNode.style.width = '100%'; + } else { + this._domNode.style.width = `${_initialMinWidth}px`; + } + })); + } + } + + public getVerticalSashLeft(_sash: Sash): number { + return 0; + } + + public abstract find(previous: boolean): void; + public abstract findFirst(): void; + protected abstract _onInputChanged(): boolean; + protected abstract _onFocusTrackerFocus(): void; + protected abstract _onFocusTrackerBlur(): void; + protected abstract _onFindInputFocusTrackerFocus(): void; + protected abstract _onFindInputFocusTrackerBlur(): void; + protected abstract _getResultCount(): Promise<{ resultIndex: number; resultCount: number } | undefined>; + + protected get inputValue() { + return this._findInput.getValue(); + } + + public get focusTracker(): dom.IFocusTracker { + return this._focusTracker; + } + + private _getKeybinding(actionId: string): string { + return this._keybindingService.appendKeybinding('', actionId); + } + + override dispose() { + super.dispose(); + + this._domNode?.remove(); + } + + public isVisible(): boolean { + return this._isVisible; + } + + public getDomNode() { + return this._domNode; + } + + public getFindInputDomNode() { + return this._findInput.domNode; + } + + public reveal(initialInput?: string, animated = true): void { + if (initialInput) { + this._findInput.setValue(initialInput); + } + + if (this._isVisible) { + this._findInput.select(); + return; + } + + this._isVisible = true; + this._updateFindInputAriaLabel(); + this.updateResultCount(); + this.layout(); + + setTimeout(() => { + this._innerDomNode.classList.toggle('suppress-transition', !animated); + this._innerDomNode.classList.add('visible', 'visible-transition'); + this._innerDomNode.setAttribute('aria-hidden', 'false'); + this._findInput.select(); + + if (!animated) { + setTimeout(() => { + this._innerDomNode.classList.remove('suppress-transition'); + }, 0); + } + }, 0); + } + + public show(initialInput?: string): void { + if (initialInput && !this._isVisible) { + this._findInput.setValue(initialInput); + } + + this._isVisible = true; + this.layout(); + + setTimeout(() => { + this._innerDomNode.classList.add('visible', 'visible-transition'); + + this._innerDomNode.setAttribute('aria-hidden', 'false'); + }, 0); + } + + public hide(animated = true): void { + if (this._isVisible) { + // Reset the accessibility help hint flag so it can be announced again on next reveal + this._accessibilityHelpHintAnnounced = false; + this._innerDomNode.classList.toggle('suppress-transition', !animated); + this._innerDomNode.classList.remove('visible-transition'); + this._innerDomNode.setAttribute('aria-hidden', 'true'); + // Need to delay toggling visibility until after Transition, then visibility hidden - removes from tabIndex list + setTimeout(() => { + this._isVisible = false; + this.updateButtons(this._foundMatch); + this._innerDomNode.classList.remove('visible', 'suppress-transition'); + }, animated ? 200 : 0); + } + } + + public layout(width: number = this._width): void { + this._width = width; + + if (!this._isVisible) { + return; + } + + if (this._matchesCount) { + let reducedFindWidget = false; + if (SIMPLE_FIND_WIDGET_INITIAL_WIDTH + MATCHES_COUNT_WIDTH + 28 >= width) { + reducedFindWidget = true; + } + this._innerDomNode.classList.toggle('reduced-find-widget', reducedFindWidget); + } + } + + protected _delayedUpdateHistory() { + this._updateHistoryDelayer.trigger(this._updateHistory.bind(this)); + } + + protected _updateHistory() { + this._findInput.inputBox.addToHistory(); + } + + protected _getRegexValue(): boolean { + return this._findInput.getRegex(); + } + + protected _getWholeWordValue(): boolean { + return this._findInput.getWholeWords(); + } + + protected _getCaseSensitiveValue(): boolean { + return this._findInput.getCaseSensitive(); + } + + protected updateButtons(foundMatch: boolean) { + const hasInput = this.inputValue.length > 0; + this.prevBtn.setEnabled(this._isVisible && hasInput && foundMatch); + this.nextBtn.setEnabled(this._isVisible && hasInput && foundMatch); + } + + protected focusFindBox() { + // Focus back onto the find box, which + // requires focusing onto the next button first + this.nextBtn.focus(); + this._findInput.inputBox.focus(); + } + + async updateResultCount(): Promise { + if (!this._matchesCount) { + this.updateButtons(this._foundMatch); + return; + } + + const count = await this._getResultCount(); + this._matchesCount.textContent = ''; + const showRedOutline = (this.inputValue.length > 0 && count?.resultCount === 0); + this._matchesCount.classList.toggle('no-results', showRedOutline); + let label = ''; + if (count?.resultCount) { + let matchesCount: string = String(count.resultCount); + if (count.resultCount >= this._matchesLimit) { + matchesCount += '+'; + } + let matchesPosition: string = String(count.resultIndex + 1); + if (matchesPosition === '0') { + matchesPosition = '?'; + } + label = strings.format(NLS_MATCHES_LOCATION, matchesPosition, matchesCount); + } else { + label = NLS_NO_RESULTS; + } + status(this._announceSearchResults(label, this.inputValue)); + this._matchesCount.appendChild(document.createTextNode(label)); + this._foundMatch = !!count && count.resultCount > 0; + this.updateButtons(this._foundMatch); + } + + changeState(state: INewFindReplaceState) { + this.state.change(state, false); + } + + /** + * Updates the ARIA label of the find input box. + * When a screen reader is active and the accessibility verbosity setting is enabled, + * includes a hint about pressing Alt+F1 for accessibility help on first reveal. + * The hint is only announced once per show/hide cycle to prevent double-speak. + */ + private _updateFindInputAriaLabel(): void { + let findLabel = NLS_FIND_INPUT_LABEL; + + // Include accessibility help hint on first reveal when screen reader is active + // Note: Using raw string for setting ID - this setting may not be registered yet + if (!this._accessibilityHelpHintAnnounced && this._configurationService.getValue('accessibility.verbosity.find') && this._accessibilityService.isScreenReaderOptimized()) { + const keybinding = this._keybindingService.lookupKeybinding('editor.action.accessibilityHelp')?.getAriaLabel(); + if (keybinding) { + findLabel += ', ' + nls.localize('accessibilityHelpHintInLabel', "Press {0} for accessibility help", keybinding); + this._accessibilityHelpHintAnnounced = true; + + // Reset to plain label after delay to avoid repeated announcement on focus changes + this._labelResetTimeout?.dispose(); + this._labelResetTimeout = disposableTimeout(() => { + if (this._isVisible) { + this._findInput.inputBox.setAriaLabel(NLS_FIND_INPUT_LABEL); + } + }, 1000); + } + } + + this._findInput.inputBox.setAriaLabel(findLabel); + } + + private _announceSearchResults(label: string, searchString?: string): string { + if (!searchString) { + return nls.localize('ariaSearchNoInput', "Enter search input"); + } + if (label === NLS_NO_RESULTS) { + return searchString === '' + ? nls.localize('ariaSearchNoResultEmpty', "{0} found", label) + : nls.localize('ariaSearchNoResult', "{0} found for '{1}'", label, searchString); + } + + return nls.localize('ariaSearchNoResultWithLineNumNoCurrentMatch', "{0} found for '{1}'", label, searchString); + } +} + +export const simpleFindWidgetSashBorder = registerColor('simpleFindWidget.sashBorder', { dark: '#454545', light: '#C8C8C8', hcDark: '#6FC3DF', hcLight: '#0F4A85' }, nls.localize('simpleFindWidget.sashBorder', 'Border color of the sash border.')); + +registerThemingParticipant((theme, collector) => { + const resizeBorderBackground = theme.getColor(simpleFindWidgetSashBorder); + collector.addRule(`.monaco-workbench .simple-find-part .monaco-sash { background-color: ${resizeBorderBackground}; border-color: ${resizeBorderBackground} }`); +}); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts index 79eab4e36088d..5e390f546de1d 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts @@ -6,13 +6,14 @@ import * as DOM from '../../../../../../base/browser/dom.js'; import { IKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; import { alert as alertFn } from '../../../../../../base/browser/ui/aria/aria.js'; +import { MATCHES_LIMIT } from '../../../../../../base/browser/ui/findinput/findContants.js'; import { KeyCode, KeyMod } from '../../../../../../base/common/keyCodes.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import * as strings from '../../../../../../base/common/strings.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { FindMatch } from '../../../../../../editor/common/model.js'; -import { MATCHES_LIMIT, CONTEXT_FIND_WIDGET_VISIBLE } from '../../../../../../editor/contrib/find/browser/findModel.js'; +import { CONTEXT_FIND_WIDGET_VISIBLE } from '../../../../../../editor/contrib/find/browser/findModel.js'; import { FindReplaceState } from '../../../../../../editor/contrib/find/browser/findState.js'; import { NLS_MATCHES_LOCATION, NLS_NO_RESULTS } from '../../../../../../editor/contrib/find/browser/findWidget.js'; import { FindWidgetSearchHistory } from '../../../../../../editor/contrib/find/browser/findWidgetSearchHistory.js'; diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index d9e376596ef97..40c3b79714b0c 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -811,6 +811,9 @@ export interface ISearchOptions { caseSensitive?: boolean; /** Whether the search should start at the current search position (not the next row). */ incremental?: boolean; + + /** The 1-based index of the desired match relative to its peer matches */ + n?: number; } export interface ITerminalInstance extends IBaseTerminalInstance { @@ -1297,7 +1300,7 @@ export interface ITerminalInstance extends IBaseTerminalInstance { } export const enum XtermTerminalConstants { - SearchHighlightLimit = 20000 + SearchHighlightLimit = 19999 } export interface IXtermAttachToElementOptions { @@ -1385,6 +1388,12 @@ export interface IXtermTerminal extends IDisposable { */ findPrevious(term: string, searchOptions: ISearchOptions): Promise; + /** + * Find the Nth instance of the term, + * where N is provided through searchOptions and is the 1-based index of the match relative to its peers. + */ + findNth(term: string, searchOptions: ISearchOptions): Promise; + /** * Forces the terminal to redraw its viewport. */ diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 34bae81f28ee8..ef4a777237775 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -619,6 +619,11 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach return (await this._getSearchAddon()).findPrevious(term, searchOptions); } + async findNth(term: string, searchOptions: ISearchOptions): Promise { + this._updateFindColors(searchOptions); + return (await this._getSearchAddon()).findNth(term, searchOptions); + } + private _updateFindColors(searchOptions: ISearchOptions): void { const theme = this._themeService.getColorTheme(); // Theme color names align with monaco/vscode whereas xterm.js has some different naming. diff --git a/src/vs/workbench/contrib/terminal/common/terminalContextKey.ts b/src/vs/workbench/contrib/terminal/common/terminalContextKey.ts index 82b0adcfc2d92..efc8e318f0961 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalContextKey.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalContextKey.ts @@ -35,6 +35,7 @@ export const enum TerminalContextKeyStrings { FindVisible = 'terminalFindVisible', FindInputFocused = 'terminalFindInputFocused', FindFocused = 'terminalFindFocused', + NthMatchInput = 'terminalNthMatchInputFocused', TabsSingularSelection = 'terminalTabsSingularSelection', SplitTerminal = 'terminalSplitTerminal', SplitPaneActive = 'terminalSplitPaneActive', @@ -123,6 +124,9 @@ export namespace TerminalContextKeys { /** Whether NO elements within the active terminal's find widget is focused. */ export const notFindFocus = findInputFocus.toNegated(); + /** Whether the find widget's nth match edit field is focused in the active terminal. */ + export const nthMatchInputFocus = new RawContextKey(TerminalContextKeyStrings.NthMatchInput, false, true); + /** Whether terminal processes can be launched in the current workspace. */ export const processSupported = new RawContextKey(TerminalContextKeyStrings.ProcessSupported, false, localize('terminalProcessSupportedContextKey', "Whether terminal processes can be launched in the current workspace.")); diff --git a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts index fd3df87da9302..175ec364b0602 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts @@ -30,6 +30,7 @@ export class TerminalFindWidget extends SimpleFindWidget { private _findInputFocused: IContextKey; private _findWidgetFocused: IContextKey; private _findWidgetVisible: IContextKey; + private _nthMatchInputFocused: IContextKey; private _overrideCopyOnSelectionDisposable = this._register(new MutableDisposable()); private _selectionDisposable = this._register(new MutableDisposable()); @@ -58,6 +59,7 @@ export class TerminalFindWidget extends SimpleFindWidget { appendWholeWordsActionId: TerminalFindCommandId.ToggleFindWholeWord, previousMatchActionId: TerminalFindCommandId.FindPrevious, nextMatchActionId: TerminalFindCommandId.FindNext, + nthMatchActionId: TerminalFindCommandId.FindNth, closeWidgetActionId: TerminalFindCommandId.FindHide, type: 'Terminal', matchesLimit: XtermTerminalConstants.SearchHighlightLimit @@ -69,6 +71,7 @@ export class TerminalFindWidget extends SimpleFindWidget { this._findInputFocused = TerminalContextKeys.findInputFocus.bindTo(contextKeyService); this._findWidgetFocused = TerminalContextKeys.findFocus.bindTo(contextKeyService); this._findWidgetVisible = TerminalContextKeys.findVisible.bindTo(contextKeyService); + this._nthMatchInputFocused = TerminalContextKeys.nthMatchInputFocus.bindTo(contextKeyService); const innerDom = this.getDomNode().firstChild; if (innerDom) { this._register(dom.addDisposableListener(innerDom, 'mousedown', (event) => { @@ -93,7 +96,10 @@ export class TerminalFindWidget extends SimpleFindWidget { })); this._register(themeService.onDidColorThemeChange(() => { if (this.isVisible()) { - this.find(true, true); + // Update the terminal theming while preserving the current match highlight. + // Perform a trivial jump to the current match position, + // which should trigger the terminal's decoration logic. + this.findNth(this._nthMatchInput.getSanitizedCurrentValue()); } })); this._register(configurationService.onDidChangeConfiguration((e) => { @@ -136,6 +142,14 @@ export class TerminalFindWidget extends SimpleFindWidget { } } + public override findNth(n: number): void { + const xterm = this._instance.xterm; + if (!xterm) { + return; + } + this._findNthWithEvent(xterm, this.inputValue, { regex: this._getRegexValue(), wholeWord: this._getWholeWordValue(), caseSensitive: this._getCaseSensitiveValue(), n }); + } + override reveal(): void { const initialInput = this._instance.hasSelection() && !this._instance.selection!.includes('\n') ? this._instance.selection : undefined; const inputValue = initialInput ?? this.inputValue; @@ -202,6 +216,14 @@ export class TerminalFindWidget extends SimpleFindWidget { this._findInputFocused.reset(); } + protected _onNthMatchInputFocusTrackerBlur() { + this._nthMatchInputFocused.reset(); + } + + protected _onNthMatchInputFocusTrackerFocus() { + this._nthMatchInputFocused.set(true); + } + findFirst() { const instance = this._instance; if (instance.hasSelection()) { @@ -228,4 +250,10 @@ export class TerminalFindWidget extends SimpleFindWidget { this._registerSelectionChangeListener(xterm); return foundMatch; } + + private async _findNthWithEvent(xterm: IXtermTerminal, term: string, options: ISearchOptions): Promise { + const foundMatch = await xterm.findNth(term, options); + this._registerSelectionChangeListener(xterm); + return foundMatch; + } } diff --git a/src/vs/workbench/contrib/terminalContrib/find/common/terminal.find.ts b/src/vs/workbench/contrib/terminalContrib/find/common/terminal.find.ts index 99d0cdf130a79..2b05f848975fc 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/common/terminal.find.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/common/terminal.find.ts @@ -8,6 +8,7 @@ export const enum TerminalFindCommandId { FindHide = 'workbench.action.terminal.hideFind', FindNext = 'workbench.action.terminal.findNext', FindPrevious = 'workbench.action.terminal.findPrevious', + FindNth = 'workbench.action.terminal.findNth', ToggleFindRegex = 'workbench.action.terminal.toggleFindRegex', ToggleFindWholeWord = 'workbench.action.terminal.toggleFindWholeWord', ToggleFindCaseSensitive = 'workbench.action.terminal.toggleFindCaseSensitive', @@ -19,6 +20,7 @@ export const defaultTerminalFindCommandToSkipShell = [ TerminalFindCommandId.FindHide, TerminalFindCommandId.FindNext, TerminalFindCommandId.FindPrevious, + TerminalFindCommandId.FindNth, TerminalFindCommandId.ToggleFindRegex, TerminalFindCommandId.ToggleFindWholeWord, TerminalFindCommandId.ToggleFindCaseSensitive, diff --git a/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts b/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts index c72a7bc0e96e5..c6a522029fac8 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts @@ -10,7 +10,7 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; -import { SimpleFindWidget } from '../../codeEditor/browser/find/simpleFindWidget.js'; +import { SimpleWebFindWidget } from '../../codeEditor/browser/find/simpleWebFindWidget.js'; import { KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_FOCUSED } from './webview.js'; export interface WebviewFindDelegate { @@ -23,7 +23,7 @@ export interface WebviewFindDelegate { focus(): void; } -export class WebviewFindWidget extends SimpleFindWidget { +export class WebviewFindWidget extends SimpleWebFindWidget { protected async _getResultCount(dataChanged?: boolean): Promise<{ resultIndex: number; resultCount: number } | undefined> { return undefined; } @@ -63,6 +63,7 @@ export class WebviewFindWidget extends SimpleFindWidget { } } + public override hide(animated = true) { super.hide(animated); this._delegate.stopFind(true);