From 90dc328d2748b0a1c75aeb26e155ce3ee62c39a5 Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Thu, 2 Apr 2026 11:11:22 -0400 Subject: [PATCH 01/15] findWidget - Editable Match Location Field: - Proof-of-concept UI changes - Add an editable numerical input field to the findWidget that allows the user to jump to matches a la random access (jump functionality pending) --- .../contrib/find/browser/findWidget.css | 24 +++++++++++ .../editor/contrib/find/browser/findWidget.ts | 41 ++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/vs/editor/contrib/find/browser/findWidget.css b/src/vs/editor/contrib/find/browser/findWidget.css index 5d3ef3277d799..00fdaf8c0cdac 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.css +++ b/src/vs/editor/contrib/find/browser/findWidget.css @@ -126,6 +126,30 @@ line-height: 23px; } +/* Editable numerical input for match location */ +.monaco-editor .find-widget .matchesCount .editable-match-location { + width: 20px; + min-width: 20px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, transparent); + bottom: 2px; + margin-right: 0.5em; + text-align: center; +} + + +/* +Hide spin-box stepper controls (up and down arrows) to keep +the UI consistent with the rest of the widget. +(For now, Chromium support ONLY) + */ +.monaco-editor .find-widget .matchesCount .editable-match-location::-webkit-inner-spin-button, +.monaco-editor .find-widget .matchesCount .editable-match-location::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + .monaco-editor .find-widget .button { width: 16px; height: 16px; diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index bb3054e0db15b..7b35bebcb1589 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -410,7 +410,9 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } // remove previous content - this._matchesCount.firstChild?.remove(); + // this._matchesCount.firstChild?.remove(); + [...this._matchesCount.childNodes].forEach(x => x.parentNode?.removeChild(x)); + let label: string; if (this._state.matchesCount > 0) { @@ -427,7 +429,12 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL label = NLS_NO_RESULTS; } - this._matchesCount.appendChild(document.createTextNode(label)); + const matchesLocationInput = this.createMatchesLocationInput(); + matchesLocationInput.value = `${this._state.matchesPosition}`; + + this._matchesCount.appendChild(matchesLocationInput); + this._matchesCount.appendChild(document.createTextNode(' of ')); + this._matchesCount.appendChild(document.createTextNode(`${this._state.matchesCount}`)); alertFn(this._getAriaLabel(label, this._state.currentMatch, this._state.searchString)); MAX_MATCHES_COUNT_WIDTH = Math.max(MAX_MATCHES_COUNT_WIDTH, this._matchesCount.clientWidth); @@ -435,6 +442,36 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL // ----- actions + + private createMatchesLocationInput(): HTMLInputElement { + const inputEl = document.createElement('input'); + + // Set input type and upper/lower bounds + inputEl.type = 'number'; + inputEl.max = `${this._state.matchesCount}`; + inputEl.min = '1'; + + inputEl.classList.add(...['monaco-inputbox', 'editable-match-location']); + + inputEl.onfocus = () => { + inputEl.classList.add(...['synthetic-focus']); + }; + + inputEl.onblur = () => { + inputEl.classList.remove(...['synthetic-focus']); + }; + + inputEl.oninput = (event) => { + + }; + + inputEl.onchange = (event) => { + + }; + + return inputEl; + + } private _getAriaLabel(label: string, currentMatch: Range | null, searchString: string): string { if (label === NLS_NO_RESULTS) { return searchString === '' From 32eeec1324939b57dd22909fc9ea68b87c137ad7 Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Thu, 2 Apr 2026 14:38:57 -0400 Subject: [PATCH 02/15] findWidget - Editable Match Location Field: - Proof-of-concept API/functionality changes - More compliance and alignment with existing data model and class hiearchy - Misc bug fixes pending - Cleanup pending --- .../ui/findinput/matchLocationInput.css | 33 ++ .../ui/findinput/matchLocationInput.ts | 451 ++++++++++++++++++ .../contrib/find/browser/findWidget.css | 24 - .../editor/contrib/find/browser/findWidget.ts | 242 +++++++++- 4 files changed, 705 insertions(+), 45 deletions(-) create mode 100644 src/vs/base/browser/ui/findinput/matchLocationInput.css create mode 100644 src/vs/base/browser/ui/findinput/matchLocationInput.ts diff --git a/src/vs/base/browser/ui/findinput/matchLocationInput.css b/src/vs/base/browser/ui/findinput/matchLocationInput.css new file mode 100644 index 0000000000000..11a91624fbab8 --- /dev/null +++ b/src/vs/base/browser/ui/findinput/matchLocationInput.css @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* ---------- Match Location input ---------- */ + +/* Editable numerical input for match location */ +.monaco-editor .find-widget .matchesCount .editable-match-location /* input */ { + width: 20px; + min-width: 20px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, transparent); + bottom: 2px; + margin-right: 0.5em; + /* text-align: center; */ +} + +.monaco-editor .find-widget .matchesCount .editable-match-location input { + text-align: center; +} + + +/* +Hide spin-box stepper controls (up and down arrows) to keep +the UI consistent with the rest of the widget. +(For now, Chromium support ONLY) + */ +.monaco-editor .find-widget .matchesCount .editable-match-location input[type="number"]::-webkit-inner-spin-button, +.monaco-editor .find-widget .matchesCount .editable-match-location input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} diff --git a/src/vs/base/browser/ui/findinput/matchLocationInput.ts b/src/vs/base/browser/ui/findinput/matchLocationInput.ts new file mode 100644 index 0000000000000..d92acbe208dd9 --- /dev/null +++ b/src/vs/base/browser/ui/findinput/matchLocationInput.ts @@ -0,0 +1,451 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IToggleStyles, Toggle } from '../toggle/toggle.js'; +import { IContextViewProvider } from '../contextview/contextview.js'; +// import { CaseSensitiveToggle, RegexToggle, WholeWordsToggle } from './findInputToggles.js'; +import { + // HistoryInputBox, + InputBox, + IInputBoxStyles, + IInputValidator, + 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 './matchLocationInput.css'; +import * as nls from '../../../../nls.js'; +import { DisposableStore, MutableDisposable } from '../../../common/lifecycle.js'; +import { createInstantHoverDelegate } from '../hover/hoverDelegateFactory.js'; // TODO: Remove? + + +export interface IMatchLocationInputOptions { + readonly placeholder?: string; + readonly width?: number; + readonly validation?: IInputValidator; + readonly label: string; + readonly type: 'number'; + readonly min?: number; + readonly max?: number; + readonly flexibleHeight?: boolean; + readonly flexibleWidth?: boolean; + readonly flexibleMaxHeight?: number; + + readonly showCommonFindToggles?: boolean; + // readonly appendCaseSensitiveLabel?: string; + // readonly appendWholeWordsLabel?: string; + // readonly appendRegexLabel?: string; + // readonly history?: string[]; + // readonly additionalToggles?: Toggle[]; + // readonly showHistoryHint?: () => boolean; + readonly toggleStyles: IToggleStyles; + readonly inputBoxStyles: IInputBoxStyles; +} + +export interface IStepEvent { + direction: 'up' | 'down'; +} + +export interface IJumpEvent { + toIndex: number; +} + +const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); + +export class MatchLocationInput extends Widget { + + // static readonly OPTION_CHANGE: string = 'optionChange'; + + private placeholder: string; + private validation?: IInputValidator; + private label: string; + private type: string; + private min: number; + private max: number; + private readonly showCommonFindToggles: boolean; + private fixFocusOnOptionClickEnabled = true; + private imeSessionInProgress = false; + private readonly additionalTogglesDisposables: MutableDisposable = this._register(new MutableDisposable()); + + // protected readonly controls: HTMLDivElement; + // protected readonly regex?: RegexToggle; + // protected readonly wholeWords?: WholeWordsToggle; + // protected readonly caseSensitive?: CaseSensitiveToggle; + // protected additionalToggles: Toggle[] = []; + public readonly domNode: HTMLElement; + public readonly inputBox: InputBox; + + 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; + + + private readonly _onJump = this._register(new Emitter()); + public readonly onJump: Event = this._onJump.event; + + // private _onCaseSensitiveKeyDown = this._register(new Emitter()); + // public readonly onCaseSensitiveKeyDown: Event = this._onCaseSensitiveKeyDown.event; + + // private _onRegexKeyDown = this._register(new Emitter()); + // public readonly onRegexKeyDown: Event = this._onRegexKeyDown.event; + + constructor(parent: HTMLElement | null, contextViewProvider: IContextViewProvider | undefined, options: IMatchLocationInputOptions) { + super(); + this.placeholder = options.placeholder || ''; + this.validation = options.validation; + this.label = options.label || NLS_DEFAULT_LABEL; + this.type = options.type || 'number'; + this.min = options.min || 0; + this.max = options.max || 0; + this.showCommonFindToggles = !!options.showCommonFindToggles; + + // const appendCaseSensitiveLabel = options.appendCaseSensitiveLabel || ''; + // const appendWholeWordsLabel = options.appendWholeWordsLabel || ''; + // const appendRegexLabel = options.appendRegexLabel || ''; + // const history = options.history || []; + const flexibleHeight = !!options.flexibleHeight; + const flexibleWidth = !!options.flexibleWidth; + const flexibleMaxHeight = options.flexibleMaxHeight; + + this.domNode = document.createElement('div'); + this.domNode.classList.add('monaco-findInput'); + + this.inputBox = this._register(new InputBox(this.domNode, contextViewProvider, { + placeholder: this.placeholder || '', + ariaLabel: this.label || '', + validationOptions: { + validation: this.validation + }, + // history, + // showHistoryHint: options.showHistoryHint, + flexibleHeight, + flexibleWidth, + flexibleMaxHeight, + inputBoxStyles: options.inputBoxStyles, + type: this.type + })); + + const hoverDelegate = this._register(createInstantHoverDelegate()); + + // if (this.showCommonFindToggles) { + // this.regex = this._register(new RegexToggle({ + // appendTitle: appendRegexLabel, + // isChecked: false, + // hoverDelegate, + // ...options.toggleStyles + // })); + // this._register(this.regex.onChange(viaKeyboard => { + // this._onDidOptionChange.fire(viaKeyboard); + // if (!viaKeyboard && this.fixFocusOnOptionClickEnabled) { + // this.inputBox.focus(); + // } + // this.validate(); + // })); + // this._register(this.regex.onKeyDown(e => { + // this._onRegexKeyDown.fire(e); + // })); + + // this.wholeWords = this._register(new WholeWordsToggle({ + // appendTitle: appendWholeWordsLabel, + // isChecked: false, + // hoverDelegate, + // ...options.toggleStyles + // })); + // this._register(this.wholeWords.onChange(viaKeyboard => { + // this._onDidOptionChange.fire(viaKeyboard); + // if (!viaKeyboard && this.fixFocusOnOptionClickEnabled) { + // this.inputBox.focus(); + // } + // this.validate(); + // })); + + // this.caseSensitive = this._register(new CaseSensitiveToggle({ + // appendTitle: appendCaseSensitiveLabel, + // isChecked: false, + // hoverDelegate, + // ...options.toggleStyles + // })); + // this._register(this.caseSensitive.onChange(viaKeyboard => { + // this._onDidOptionChange.fire(viaKeyboard); + // if (!viaKeyboard && this.fixFocusOnOptionClickEnabled) { + // this.inputBox.focus(); + // } + // this.validate(); + // })); + // this._register(this.caseSensitive.onKeyDown(e => { + // this._onCaseSensitiveKeyDown.fire(e); + // })); + + // Arrow-Key support to step the matched location up or down + // const indexes = [this.caseSensitive.domNode, this.wholeWords.domNode, this.regex.domNode]; + this.onkeydown(this.domNode, (event: IKeyboardEvent) => { + + const valueParsedAsInt = parseInt(this.inputBox.value); + + if (event.equals(KeyCode.UpArrow)) { + const valueAfterChange = (!isNaN(valueParsedAsInt) ? valueParsedAsInt + 1 : 0); + if (valueAfterChange > this.max) { // Out of bounds. Not sure by how much so 'jump' inbounds from the right + this.inputBox.value = `${this.max || 0}`; + this._onJump.fire({ toIndex: this.max }); + } + else { + this.inputBox.value = `${valueAfterChange}`; + this._onStep.fire({ direction: 'up' }); + } + } + + else if (event.equals(KeyCode.DownArrow)) { + const valueAfterChange = (!isNaN(valueParsedAsInt) ? valueParsedAsInt - 1 : 0); + if (valueAfterChange < this.min) { // Out of bounds. Not sure by how much so 'jump' inbounds from the left + this.inputBox.value = `${this.min || 0}`; + this._onJump.fire({ toIndex: this.min }); + // this._onStep.fire({ direction: 'up' }); + } + else { + this.inputBox.value = `${valueAfterChange}`; + this._onStep.fire({ direction: 'down' }); + } + // assertIsDefined(this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction)).run().then(undefined, onUnexpectedError); + } + + else if (event.equals(KeyCode.Escape)) { + this.inputBox.blur(); + } + + + dom.EventHelper.stop(event, true); + }); + // } + + // this.controls = document.createElement('div'); + // this.controls.className = 'controls'; + // this.controls.style.display = this.showCommonFindToggles ? '' : 'none'; + // if (this.caseSensitive) { + // this.controls.append(this.caseSensitive.domNode); + // } + // if (this.wholeWords) { + // this.controls.appendChild(this.wholeWords.domNode); + // } + // if (this.regex) { + // this.controls.appendChild(this.regex.domNode); + // } + + // this.setAdditionalToggles(options?.additionalToggles); + + // if (this.controls) { + // this.domNode.appendChild(this.controls); + // } + + 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); + } + + public enable(): void { + this.domNode.classList.remove('disabled'); + this.inputBox.enable(); + // this.regex?.enable(); + // this.wholeWords?.enable(); + // this.caseSensitive?.enable(); + + // for (const toggle of this.additionalToggles) { + // toggle.enable(); + // } + } + + public disable(): void { + this.domNode.classList.add('disabled'); + this.inputBox.disable(); + // this.regex?.disable(); + // this.wholeWords?.disable(); + // this.caseSensitive?.disable(); + + // for (const toggle of this.additionalToggles) { + // toggle.disable(); + // } + } + + public setFocusInputOnOptionClick(value: boolean): void { + this.fixFocusOnOptionClickEnabled = value; + } + + public setEnabled(enabled: boolean): void { + if (enabled) { + this.enable(); + } else { + this.disable(); + } + } + + // public setAdditionalToggles(toggles: Toggle[] | undefined): void { + // for (const currentToggle of this.additionalToggles) { + // currentToggle.domNode.remove(); + // } + // this.additionalToggles = []; + // this.additionalTogglesDisposables.value = new DisposableStore(); + + // for (const toggle of toggles ?? []) { + // this.additionalTogglesDisposables.value.add(toggle); + // this.controls.appendChild(toggle.domNode); + + // this.additionalTogglesDisposables.value.add(toggle.onChange(viaKeyboard => { + // this._onDidOptionChange.fire(viaKeyboard); + // if (!viaKeyboard && this.fixFocusOnOptionClickEnabled) { + // this.inputBox.focus(); + // } + // })); + + // this.additionalToggles.push(toggle); + // } + + // if (this.additionalToggles.length > 0) { + // this.controls.style.display = ''; + // } + + // this.updateInputBoxPadding(); + // } + + private updateInputBoxPadding(controlsHidden = false) { + if (controlsHidden) { + this.inputBox.paddingRight = 0; + } else { + this.inputBox.paddingRight = + ((/* this.caseSensitive?.width() ?? */ 0) + (/* this.wholeWords?.width() ?? */ 0) + (/* this.regex?.width() ?? */ 0)) + /* + this.additionalToggles.reduce((r, t) => r + t.width(), 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; + } + } + + public onSearchSubmit(): void { + // this.inputBox.addToHistory(); + } + + public select(): void { + this.inputBox.select(); + } + + public focus(): void { + this.inputBox.focus(); + } + + // public getCaseSensitive(): boolean { + // return this.caseSensitive?.checked ?? false; + // } + + // public setCaseSensitive(value: boolean): void { + // if (this.caseSensitive) { + // this.caseSensitive.checked = value; + // } + // } + + // public getWholeWords(): boolean { + // return this.wholeWords?.checked ?? false; + // } + + // public setWholeWords(value: boolean): void { + // if (this.wholeWords) { + // this.wholeWords.checked = value; + // } + // } + + // public getRegex(): boolean { + // return this.regex?.checked ?? false; + // } + + // public setRegex(value: boolean): void { + // if (this.regex) { + // this.regex.checked = value; + // this.validate(); + // } + // } + + // public focusOnCaseSensitive(): void { + // this.caseSensitive?.focus(); + // } + + // public focusOnRegex(): void { + // this.regex?.focus(); + // } + + private _lastHighlightFindOptions: number = 0; + public highlightFindOptions(): void { + this.domNode.classList.remove('highlight-' + (this._lastHighlightFindOptions)); + this._lastHighlightFindOptions = 1 - this._lastHighlightFindOptions; + this.domNode.classList.add('highlight-' + (this._lastHighlightFindOptions)); + } + + 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(); + } +} diff --git a/src/vs/editor/contrib/find/browser/findWidget.css b/src/vs/editor/contrib/find/browser/findWidget.css index 00fdaf8c0cdac..5d3ef3277d799 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.css +++ b/src/vs/editor/contrib/find/browser/findWidget.css @@ -126,30 +126,6 @@ line-height: 23px; } -/* Editable numerical input for match location */ -.monaco-editor .find-widget .matchesCount .editable-match-location { - width: 20px; - min-width: 20px; - background-color: var(--vscode-input-background); - color: var(--vscode-input-foreground); - border: 1px solid var(--vscode-input-border, transparent); - bottom: 2px; - margin-right: 0.5em; - text-align: center; -} - - -/* -Hide spin-box stepper controls (up and down arrows) to keep -the UI consistent with the rest of the widget. -(For now, Chromium support ONLY) - */ -.monaco-editor .find-widget .matchesCount .editable-match-location::-webkit-inner-spin-button, -.monaco-editor .find-widget .matchesCount .editable-match-location::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; -} - .monaco-editor .find-widget .button { width: 16px; height: 16px; diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index 7b35bebcb1589..02b0707245b58 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -11,6 +11,7 @@ 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 { MatchLocationInput } from '../../../../base/browser/ui/findinput/matchLocationInput.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'; @@ -137,6 +138,8 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private _findInput!: FindInput; private _replaceInput!: ReplaceInput; + private _matchLocationInput!: MatchLocationInput; + private _toggleReplaceBtn!: SimpleButton; private _matchesCount!: HTMLElement; private _prevBtn!: SimpleButton; @@ -429,13 +432,20 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL label = NLS_NO_RESULTS; } - const matchesLocationInput = this.createMatchesLocationInput(); - matchesLocationInput.value = `${this._state.matchesPosition}`; + this._matchLocationInput = this.getMatchLocationInput(); + - this._matchesCount.appendChild(matchesLocationInput); + this._matchesCount.appendChild(this._matchLocationInput.domNode); this._matchesCount.appendChild(document.createTextNode(' of ')); this._matchesCount.appendChild(document.createTextNode(`${this._state.matchesCount}`)); + // const matchesLocationInput = this.getMatchLocationInput(); + // matchesLocationInput.value = `${this._state.matchesPosition}`; + + // this._matchesCount.appendChild(matchesLocationInput); + // this._matchesCount.appendChild(document.createTextNode(' of ')); + // this._matchesCount.appendChild(document.createTextNode(`${this._state.matchesCount}`)); + alertFn(this._getAriaLabel(label, this._state.currentMatch, this._state.searchString)); MAX_MATCHES_COUNT_WIDTH = Math.max(MAX_MATCHES_COUNT_WIDTH, this._matchesCount.clientWidth); } @@ -443,35 +453,153 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL // ----- actions - private createMatchesLocationInput(): HTMLInputElement { - const inputEl = document.createElement('input'); + // private getMatchLocationInput(): HTMLInputElement { + // let inputEl = document.querySelector('.editable-match-location') as HTMLInputElement; - // Set input type and upper/lower bounds - inputEl.type = 'number'; - inputEl.max = `${this._state.matchesCount}`; - inputEl.min = '1'; + // if (!inputEl) { + // inputEl = document.createElement('input'); + // // Set input type and upper/lower bounds + // inputEl.type = 'number'; + // inputEl.max = `${this._state.matchesCount}`; + // inputEl.min = '1'; - inputEl.classList.add(...['monaco-inputbox', 'editable-match-location']); + // // Keep styling consistent with the surrounding UI. + // inputEl.classList.add(...['monaco-inputbox', 'editable-match-location']); - inputEl.onfocus = () => { - inputEl.classList.add(...['synthetic-focus']); - }; + // inputEl.addEventListener('focus', () => { + // inputEl.classList.add(...['synthetic-focus']); + // }); - inputEl.onblur = () => { - inputEl.classList.remove(...['synthetic-focus']); - }; + // inputEl.addEventListener('blur', () => { + // inputEl.classList.remove(...['synthetic-focus']); + // }); - inputEl.oninput = (event) => { + // inputEl.addEventListener('input', (event: Event) => { + // console.log('findWidget.ts ---> getMatchLocationInput() ---> input INPUT event', event); + // const currentValue = validateInput((event?.target as HTMLInputElement).value); + // }); - }; + // inputEl.addEventListener('change', (event: Event) => { + // console.log('findWidget.ts ---> getMatchLocationInput() ---> input CHANGE event', event); + // const currentValue = validateInput((event?.target as HTMLInputElement).value); - inputEl.onchange = (event) => { + // }); - }; - return inputEl; + // // inputEl.onfocus = () => { + // // inputEl.classList.add(...['synthetic-focus']); + // // }; + + // // inputEl.onblur = () => { + // // inputEl.classList.remove(...['synthetic-focus']); + // // }; + + // // inputEl.oninput = (event: InputEvent) => { + // // const currentValue = validateInput(event?.target?); + // // }; + + // // inputEl.onchange = (event) => { + + // // }; + + // const validateInput = (inputVal: any) => { + // // return Math.min(input) + // } + // } + + // return inputEl; + // } + + private getMatchLocationInput(): MatchLocationInput { + // let inputEl = document.querySelector('.editable-match-location') as HTMLInputElement; + + // if (!inputEl) { + // inputEl = document.createElement('input'); + // // Set input type and upper/lower bounds + // inputEl.type = 'number'; + // inputEl.max = `${this._state.matchesCount}`; + // inputEl.min = '1'; + + // // Keep styling consistent with the surrounding UI. + // inputEl.classList.add(...['monaco-inputbox', 'editable-match-location']); + + // inputEl.addEventListener('focus', () => { + // inputEl.classList.add(...['synthetic-focus']); + // }); + + // inputEl.addEventListener('blur', () => { + // inputEl.classList.remove(...['synthetic-focus']); + // }); + + // inputEl.addEventListener('input', (event: Event) => { + // console.log('findWidget.ts ---> getMatchLocationInput() ---> input INPUT event', event); + // const currentValue = validateInput((event?.target as HTMLInputElement).value); + // }); + + // inputEl.addEventListener('change', (event: Event) => { + // console.log('findWidget.ts ---> getMatchLocationInput() ---> input CHANGE event', event); + // const currentValue = validateInput((event?.target as HTMLInputElement).value); + + // }); + + // // inputEl.onfocus = () => { + // // inputEl.classList.add(...['synthetic-focus']); + // // }; + + // // inputEl.onblur = () => { + // // inputEl.classList.remove(...['synthetic-focus']); + // // }; + + // // inputEl.oninput = (event: InputEvent) => { + // // const currentValue = validateInput(event?.target?); + // // }; + + // // inputEl.onchange = (event) => { + + // // }; + + // const validateInput = (inputVal: any) => { + // // return Math.min(input) + // } + // } + + // Match Location Edit Input + // Created above during this._updateMatchesCount(); + const input = new MatchLocationInput(this._domNode, this._contextViewProvider, { + placeholder: '', + width: 20, + validation: undefined, + label: '', + type: 'number', + min: 0, + max: this._state.matchesCount, + flexibleHeight: undefined, + flexibleWidth: undefined, + flexibleMaxHeight: undefined, + toggleStyles: defaultToggleStyles, + inputBoxStyles: defaultInputBoxStyles, + }); + + this._register(input.onStep((e) => { + if (e.direction === 'up') { + assertIsDefined(this._codeEditor.getAction(FIND_IDS.NextMatchFindAction)).run().then(undefined, onUnexpectedError); + } + else { + assertIsDefined(this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction)).run().then(undefined, onUnexpectedError); + } + })); + + this._register(input.onJump((e) => { + assertIsDefined(this._codeEditor.getAction(FIND_IDS.GoToMatchFindAction)).run().then(undefined, onUnexpectedError); + })); + + input.domNode.classList.add(...['monaco-inputbox', 'editable-match-location']); + input.setValue(`${this._state.matchesPosition}`); + + return input; } + private _getAriaLabel(label: string, currentMatch: Range | null, searchString: string): string { if (label === NLS_NO_RESULTS) { return searchString === '' @@ -1047,8 +1175,80 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._matchesCount = document.createElement('div'); this._matchesCount.className = 'matchesCount'; + this._updateMatchesCount(); + + // this._register(new ContextScopedFindInput(null, this._contextViewProvider, { + // width: FIND_INPUT_AREA_WIDTH, + // label: NLS_FIND_INPUT_LABEL, + // placeholder: NLS_FIND_INPUT_PLACEHOLDER, + // appendCaseSensitiveLabel: this._keybindingLabelFor(FIND_IDS.ToggleCaseSensitiveCommand), + // appendWholeWordsLabel: this._keybindingLabelFor(FIND_IDS.ToggleWholeWordCommand), + // appendRegexLabel: this._keybindingLabelFor(FIND_IDS.ToggleRegexCommand), + // validation: (value: string): InputBoxMessage | null => { + // if (value.length === 0 || !this._findInput.getRegex()) { + // return null; + // } + // try { + // // use `g` and `u` which are also used by the TextModel search + // new RegExp(value, 'gu'); + // return null; + // } catch (e) { + // return { content: e.message }; + // } + // }, + // flexibleHeight, + // flexibleWidth, + // flexibleMaxHeight: 118, + // showCommonFindToggles: true, + // showHistoryHint: () => showHistoryKeybindingHint(this._keybindingService), + // inputBoxStyles: defaultInputBoxStyles, + // toggleStyles: defaultToggleStyles + // }, this._contextKeyService)); + // this._findInput.setRegex(!!this._state.isRegex); + // this._findInput.setCaseSensitive(!!this._state.matchCase); + // this._findInput.setWholeWords(!!this._state.wholeWord); + // this._register(this._findInput.onKeyDown((e) => this._onFindInputKeyDown(e))); + // this._register(this._findInput.inputBox.onDidChange(() => { + // if (this._ignoreChangeEvent) { + // return; + // } + // this._state.change({ searchString: this._findInput.getValue() }, true); + // })); + // this._register(this._findInput.onDidOptionChange(() => { + // this._state.change({ + // isRegex: this._findInput.getRegex(), + // wholeWord: this._findInput.getWholeWords(), + // matchCase: this._findInput.getCaseSensitive() + // }, true); + // })); + // this._register(this._findInput.onCaseSensitiveKeyDown((e) => { + // if (e.equals(KeyMod.Shift | KeyCode.Tab)) { + // if (this._isReplaceVisible) { + // this._replaceInput.focus(); + // e.preventDefault(); + // } + // } + // })); + // this._register(this._findInput.onRegexKeyDown((e) => { + // if (e.equals(KeyCode.Tab)) { + // if (this._isReplaceVisible) { + // this._replaceInput.focusOnPreserve(); + // e.preventDefault(); + // } + // } + // })); + // this._register(this._findInput.inputBox.onDidHeightChange((e) => { + // if (this._tryUpdateHeight()) { + // this._showViewZone(); + // } + // })); + // if (platform.isLinux) { + // this._register(this._findInput.onMouseDown((e) => this._onFindInputMouseDown(e))); + // } + + // Create a scoped hover delegate for all find related buttons const hoverDelegate = this._register(createInstantHoverDelegate()); From 622de8f9c8a85c55971051cff01315a5f3b5bb3e Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Fri, 3 Apr 2026 16:21:28 -0400 Subject: [PATCH 03/15] findWidget - Iterating and Misc. Improvements - Better file and class names - Better alignment with findController and findModel APIs - Register a new `MoveToEditableNthMatch ` EditorAction - Add More key event handlers (not finished) - Enforce width of the input box - Don't recreate the input on every update. - Consider adding scroll event listener for mouse-driven step functionality - Cleanup still pending --- ...tchLocationInput.css => nthMatchInput.css} | 17 +- ...matchLocationInput.ts => nthMatchInput.ts} | 355 ++++++++---------- .../contrib/find/browser/findController.ts | 119 +++++- .../editor/contrib/find/browser/findModel.ts | 1 + .../editor/contrib/find/browser/findWidget.ts | 151 ++------ 5 files changed, 313 insertions(+), 330 deletions(-) rename src/vs/base/browser/ui/findinput/{matchLocationInput.css => nthMatchInput.css} (64%) rename src/vs/base/browser/ui/findinput/{matchLocationInput.ts => nthMatchInput.ts} (58%) diff --git a/src/vs/base/browser/ui/findinput/matchLocationInput.css b/src/vs/base/browser/ui/findinput/nthMatchInput.css similarity index 64% rename from src/vs/base/browser/ui/findinput/matchLocationInput.css rename to src/vs/base/browser/ui/findinput/nthMatchInput.css index 11a91624fbab8..137f70905606d 100644 --- a/src/vs/base/browser/ui/findinput/matchLocationInput.css +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.css @@ -5,19 +5,22 @@ /* ---------- Match Location input ---------- */ /* Editable numerical input for match location */ -.monaco-editor .find-widget .matchesCount .editable-match-location /* input */ { - width: 20px; - min-width: 20px; +.monaco-editor .find-widget .matchesCount .editable-nth-match { + display: block !important; + width: 45px; + min-width: 45px; + max-width: 45px; background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border, transparent); bottom: 2px; margin-right: 0.5em; - /* text-align: center; */ } -.monaco-editor .find-widget .matchesCount .editable-match-location input { +.monaco-editor .find-widget .matchesCount .editable-nth-match input { text-align: center; + overflow-x: hidden; + text-overflow: clip; } @@ -26,8 +29,8 @@ Hide spin-box stepper controls (up and down arrows) to keep the UI consistent with the rest of the widget. (For now, Chromium support ONLY) */ -.monaco-editor .find-widget .matchesCount .editable-match-location input[type="number"]::-webkit-inner-spin-button, -.monaco-editor .find-widget .matchesCount .editable-match-location input[type="number"]::-webkit-outer-spin-button { +.monaco-editor .find-widget .matchesCount .editable-nth-match input[type="number"]::-webkit-inner-spin-button, +.monaco-editor .find-widget .matchesCount .editable-nth-match input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } diff --git a/src/vs/base/browser/ui/findinput/matchLocationInput.ts b/src/vs/base/browser/ui/findinput/nthMatchInput.ts similarity index 58% rename from src/vs/base/browser/ui/findinput/matchLocationInput.ts rename to src/vs/base/browser/ui/findinput/nthMatchInput.ts index d92acbe208dd9..f9d0fa1721ce6 100644 --- a/src/vs/base/browser/ui/findinput/matchLocationInput.ts +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.ts @@ -19,13 +19,15 @@ import { import { Widget } from '../widget.js'; import { Emitter, Event } from '../../../common/event.js'; import { KeyCode } from '../../../common/keyCodes.js'; -import './matchLocationInput.css'; +import './nthMatchInput.css'; import * as nls from '../../../../nls.js'; import { DisposableStore, MutableDisposable } from '../../../common/lifecycle.js'; import { createInstantHoverDelegate } from '../hover/hoverDelegateFactory.js'; // TODO: Remove? +import { IRange } from '../../../common/range.js'; +// import { IScrollEvent } from '../../../../editor/common/editorCommon.js'; -export interface IMatchLocationInputOptions { +export interface INthMatchInputOptions { readonly placeholder?: string; readonly width?: number; readonly validation?: IInputValidator; @@ -33,6 +35,7 @@ export interface IMatchLocationInputOptions { readonly type: 'number'; readonly min?: number; readonly max?: number; + readonly lastMatchLocation?: number; readonly flexibleHeight?: boolean; readonly flexibleWidth?: boolean; readonly flexibleMaxHeight?: number; @@ -53,12 +56,16 @@ export interface IStepEvent { } export interface IJumpEvent { - toIndex: number; + // `toMatchLocation` is emitted as a formality and for completion. + // However, the consumer (codeEditor.ts --> findController.ts) doesn't need it. + // findController.ts calls its own `toFindMatchIndex(value)` method + // which handles indexing. + toMatchLocation: number; } const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); -export class MatchLocationInput extends Widget { +export class NthMatchInput extends Widget { // static readonly OPTION_CHANGE: string = 'optionChange'; @@ -66,8 +73,6 @@ export class MatchLocationInput extends Widget { private validation?: IInputValidator; private label: string; private type: string; - private min: number; - private max: number; private readonly showCommonFindToggles: boolean; private fixFocusOnOptionClickEnabled = true; private imeSessionInProgress = false; @@ -80,6 +85,9 @@ export class MatchLocationInput extends Widget { // protected additionalToggles: Toggle[] = []; public readonly domNode: HTMLElement; public readonly inputBox: InputBox; + public lastMatchLocation: number; + public min: number; + public max: number; private readonly _onDidOptionChange = this._register(new Emitter()); public readonly onDidOptionChange: Event = this._onDidOptionChange.event; @@ -109,7 +117,7 @@ export class MatchLocationInput extends Widget { // private _onRegexKeyDown = this._register(new Emitter()); // public readonly onRegexKeyDown: Event = this._onRegexKeyDown.event; - constructor(parent: HTMLElement | null, contextViewProvider: IContextViewProvider | undefined, options: IMatchLocationInputOptions) { + constructor(parent: HTMLElement | null, contextViewProvider: IContextViewProvider | undefined, options: INthMatchInputOptions) { super(); this.placeholder = options.placeholder || ''; this.validation = options.validation; @@ -117,6 +125,7 @@ export class MatchLocationInput extends Widget { this.type = options.type || 'number'; this.min = options.min || 0; this.max = options.max || 0; + this.lastMatchLocation = options.lastMatchLocation || 0; this.showCommonFindToggles = !!options.showCommonFindToggles; // const appendCaseSensitiveLabel = options.appendCaseSensitiveLabel || ''; @@ -147,114 +156,171 @@ export class MatchLocationInput extends Widget { const hoverDelegate = this._register(createInstantHoverDelegate()); - // if (this.showCommonFindToggles) { - // this.regex = this._register(new RegexToggle({ - // appendTitle: appendRegexLabel, - // isChecked: false, - // hoverDelegate, - // ...options.toggleStyles - // })); - // this._register(this.regex.onChange(viaKeyboard => { - // this._onDidOptionChange.fire(viaKeyboard); - // if (!viaKeyboard && this.fixFocusOnOptionClickEnabled) { - // this.inputBox.focus(); - // } - // this.validate(); - // })); - // this._register(this.regex.onKeyDown(e => { - // this._onRegexKeyDown.fire(e); - // })); - - // this.wholeWords = this._register(new WholeWordsToggle({ - // appendTitle: appendWholeWordsLabel, - // isChecked: false, - // hoverDelegate, - // ...options.toggleStyles - // })); - // this._register(this.wholeWords.onChange(viaKeyboard => { - // this._onDidOptionChange.fire(viaKeyboard); - // if (!viaKeyboard && this.fixFocusOnOptionClickEnabled) { - // this.inputBox.focus(); - // } - // this.validate(); - // })); - - // this.caseSensitive = this._register(new CaseSensitiveToggle({ - // appendTitle: appendCaseSensitiveLabel, - // isChecked: false, - // hoverDelegate, - // ...options.toggleStyles - // })); - // this._register(this.caseSensitive.onChange(viaKeyboard => { - // this._onDidOptionChange.fire(viaKeyboard); - // if (!viaKeyboard && this.fixFocusOnOptionClickEnabled) { - // this.inputBox.focus(); - // } - // this.validate(); - // })); - // this._register(this.caseSensitive.onKeyDown(e => { - // this._onCaseSensitiveKeyDown.fire(e); - // })); - - // Arrow-Key support to step the matched location up or down - // const indexes = [this.caseSensitive.domNode, this.wholeWords.domNode, this.regex.domNode]; + + // TODO: Add scroll listener? + // Let the user scroll the input value up or down on scroll? + // this.onscroll(this.domNode, (event: IScrollEvent) => { + + // }); + this.onkeydown(this.domNode, (event: IKeyboardEvent) => { - const valueParsedAsInt = parseInt(this.inputBox.value); + const currentValueAsInt = parseInt(this.inputBox.value); + const isNumericKey = event.keyCode >= KeyCode.Digit0 && event.keyCode <= KeyCode.Digit9; - if (event.equals(KeyCode.UpArrow)) { - const valueAfterChange = (!isNaN(valueParsedAsInt) ? valueParsedAsInt + 1 : 0); - if (valueAfterChange > this.max) { // Out of bounds. Not sure by how much so 'jump' inbounds from the right - this.inputBox.value = `${this.max || 0}`; - this._onJump.fire({ toIndex: this.max }); - } - else { - this.inputBox.value = `${valueAfterChange}`; - this._onStep.fire({ direction: 'up' }); - } + + // A numeric key was pressed + if (isNumericKey) { + // this.inputBox.value = currentValueAsInt > this.max ? `${this.max}` : currentValueAsInt < this.min ? `${this.min}` : `${currentValueAsInt}`; + + + // if (currentValueAsInt > this.max) { + // this.inputBox.value = `${this.max}`; + // // this._onJump.fire({ toMatchLocation: this.max }); + // } + // else if (currentValueAsInt < this.min) { + // this.inputBox.value = `${this.min}`; + // } + // // this.inputBox.focus(); + } + + // Arrow-Key support to step the matched location up or down + else if (event.equals(KeyCode.UpArrow)) { + // const valueAfterChange = (!isNaN(currentValueAsInt) ? currentValueAsInt + 1 : 0); + this._onStep.fire({ direction: 'down' }); + // this.lastMatchLocation = valueAfterChange; + + // const valueAfterChange = (!isNaN(currentValueAsInt) ? currentValueAsInt + 1 : 0); + // if (valueAfterChange > this.max) { // Out of bounds. Not sure by how much so 'jump' inbounds from the right + // this._onJump.fire({ toMatchLocation: this.min }); + // this.lastMatchLocation = this.min; + // } + // else { + // this._onStep.fire({ direction: 'up' }); + // this.lastMatchLocation = valueAfterChange; + // } } else if (event.equals(KeyCode.DownArrow)) { - const valueAfterChange = (!isNaN(valueParsedAsInt) ? valueParsedAsInt - 1 : 0); - if (valueAfterChange < this.min) { // Out of bounds. Not sure by how much so 'jump' inbounds from the left - this.inputBox.value = `${this.min || 0}`; - this._onJump.fire({ toIndex: this.min }); - // this._onStep.fire({ direction: 'up' }); + this._onStep.fire({ direction: 'up' }); + + + // const valueAfterChange = (!isNaN(currentValueAsInt) ? currentValueAsInt /* - 1 */ : 0); + // if (valueAfterChange < this.min) { // Out of bounds. Not sure by how much so 'jump' inbounds from the left + // this._onJump.fire({ toMatchLocation: this.min }); + // this.lastMatchLocation = this.min; + // } + // else { + // this._onStep.fire({ direction: 'down' }); + // this.lastMatchLocation = valueAfterChange; + // } + } + + // Arrow-Key support to step the cursor left or right in the input box + else if (event.equals(KeyCode.LeftArrow)) { + const { start: cursorStart, end: cursorEnd }: IRange = (this.inputBox.getSelection() as IRange); + + if (cursorStart > 0) { + this.inputBox.inputElement.selectionStart = (this.inputBox.inputElement.selectionStart || 1) - 1; } - else { - this.inputBox.value = `${valueAfterChange}`; - this._onStep.fire({ direction: 'down' }); + } + + else if (event.equals(KeyCode.RightArrow)) { + const { start: cursorStart, end: cursorEnd }: IRange = (this.inputBox.getSelection() as IRange); + + if (cursorStart < (this.inputBox.inputElement.selectionEnd || this.inputBox.value.length - 1)) { + this.inputBox.inputElement.selectionStart = (this.inputBox.inputElement.selectionStart || 0) + 1; } - // assertIsDefined(this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction)).run().then(undefined, onUnexpectedError); } - else if (event.equals(KeyCode.Escape)) { + else if (event.equals(KeyCode.Backspace)) { + const charsArr = [...this.inputBox.value]; + charsArr.pop(); + this.inputBox.value = `${parseInt(charsArr?.join(''))}`; + } + + else if (event.equals(KeyCode.Delete)) { + if (!this.inputBox.isSelectionAtEnd()) { + const charsArr = [...this.inputBox.value]; + charsArr.splice(this.inputBox.inputElement.selectionStart || 0, 1); + this.inputBox.value = `${parseInt(charsArr?.join(''))}`; + // this.inputBox.inputElement.selectionStart = + } + } + + else if (event.equals(KeyCode.Enter)) { + // const destination = ( + // !isNaN(currentValueAsInt) ? + // Math.max((Math.min(currentValueAsInt, this.max), this.min)) + // : 0 + // ); + // this._onJump.fire({ toMatchLocation: destination }); + + this._onJump.fire({ toMatchLocation: currentValueAsInt }); + // this.inputBox.focus(); + } + + else if ( + event.equals(KeyCode.Escape) || event.equals(KeyCode.Tab) || + (event.shiftKey && event.keyCode === KeyCode.Tab) + ) { + this.inputBox.blur(); + // document.dispatchEvent(new KeyboardEvent('Tab')); + } + + // Select 1 character to the left + else if (event.shiftKey && event.code === 'ArrowLeft'/* event.equals(KeyCode.LeftArrow) */) { + const selectRange: IRange = { + start: (this.inputBox.inputElement.selectionEnd || 1) - 1, + end: this.inputBox.inputElement.selectionEnd || 1 + }; + this.inputBox.select(selectRange); } + // Select 1 character to the right + else if (event.shiftKey && event.code === 'ArrowRight'/* event.equals(KeyCode.RightArrow) */) { + const selectRange: IRange = { + start: (this.inputBox.inputElement.selectionStart || 1) + 1, + end: this.inputBox.inputElement.selectionEnd || 1 + }; + this.inputBox.select(selectRange); + } + + // Select 1 word/token to the left + else if (event.shiftKey && event.ctrlKey && event.code === 'ArrowLeft' /* event.equals(KeyCode.LeftArrow) */) { + + } - dom.EventHelper.stop(event, true); + // Select 1 word/token to the right + else if (event.shiftKey && event.ctrlKey && event.code === 'ArrowRight' /* event.equals(KeyCode.RightArrow) */) { + + } + + // Select all input text + else if (event.ctrlKey && event.code === 'KeyA' /* event.equals(KeyCode.KeyA) */) { + const selectRange: IRange = { start: 0, end: this.inputBox.value.length - 1 }; + this.inputBox.select(selectRange); + } + + + // Ctrl+Z (undo) + + + // Ctrl+Shift+Z or Ctrl+Y(redo) + + + + + + + + + if (!isNumericKey) { + dom.EventHelper.stop(event, true); + } }); - // } - - // this.controls = document.createElement('div'); - // this.controls.className = 'controls'; - // this.controls.style.display = this.showCommonFindToggles ? '' : 'none'; - // if (this.caseSensitive) { - // this.controls.append(this.caseSensitive.domNode); - // } - // if (this.wholeWords) { - // this.controls.appendChild(this.wholeWords.domNode); - // } - // if (this.regex) { - // this.controls.appendChild(this.regex.domNode); - // } - - // this.setAdditionalToggles(options?.additionalToggles); - - // if (this.controls) { - // this.domNode.appendChild(this.controls); - // } + parent?.appendChild(this.domNode); @@ -288,25 +354,11 @@ export class MatchLocationInput extends Widget { public enable(): void { this.domNode.classList.remove('disabled'); this.inputBox.enable(); - // this.regex?.enable(); - // this.wholeWords?.enable(); - // this.caseSensitive?.enable(); - - // for (const toggle of this.additionalToggles) { - // toggle.enable(); - // } } public disable(): void { this.domNode.classList.add('disabled'); this.inputBox.disable(); - // this.regex?.disable(); - // this.wholeWords?.disable(); - // this.caseSensitive?.disable(); - - // for (const toggle of this.additionalToggles) { - // toggle.disable(); - // } } public setFocusInputOnOptionClick(value: boolean): void { @@ -321,41 +373,11 @@ export class MatchLocationInput extends Widget { } } - // public setAdditionalToggles(toggles: Toggle[] | undefined): void { - // for (const currentToggle of this.additionalToggles) { - // currentToggle.domNode.remove(); - // } - // this.additionalToggles = []; - // this.additionalTogglesDisposables.value = new DisposableStore(); - - // for (const toggle of toggles ?? []) { - // this.additionalTogglesDisposables.value.add(toggle); - // this.controls.appendChild(toggle.domNode); - - // this.additionalTogglesDisposables.value.add(toggle.onChange(viaKeyboard => { - // this._onDidOptionChange.fire(viaKeyboard); - // if (!viaKeyboard && this.fixFocusOnOptionClickEnabled) { - // this.inputBox.focus(); - // } - // })); - - // this.additionalToggles.push(toggle); - // } - - // if (this.additionalToggles.length > 0) { - // this.controls.style.display = ''; - // } - - // this.updateInputBoxPadding(); - // } - private updateInputBoxPadding(controlsHidden = false) { if (controlsHidden) { this.inputBox.paddingRight = 0; } else { - this.inputBox.paddingRight = - ((/* this.caseSensitive?.width() ?? */ 0) + (/* this.wholeWords?.width() ?? */ 0) + (/* this.regex?.width() ?? */ 0)) - /* + this.additionalToggles.reduce((r, t) => r + t.width(), 0) */; + this.inputBox.paddingRight = 0; } } @@ -375,10 +397,6 @@ export class MatchLocationInput extends Widget { } } - public onSearchSubmit(): void { - // this.inputBox.addToHistory(); - } - public select(): void { this.inputBox.select(); } @@ -387,45 +405,6 @@ export class MatchLocationInput extends Widget { this.inputBox.focus(); } - // public getCaseSensitive(): boolean { - // return this.caseSensitive?.checked ?? false; - // } - - // public setCaseSensitive(value: boolean): void { - // if (this.caseSensitive) { - // this.caseSensitive.checked = value; - // } - // } - - // public getWholeWords(): boolean { - // return this.wholeWords?.checked ?? false; - // } - - // public setWholeWords(value: boolean): void { - // if (this.wholeWords) { - // this.wholeWords.checked = value; - // } - // } - - // public getRegex(): boolean { - // return this.regex?.checked ?? false; - // } - - // public setRegex(value: boolean): void { - // if (this.regex) { - // this.regex.checked = value; - // this.validate(); - // } - // } - - // public focusOnCaseSensitive(): void { - // this.caseSensitive?.focus(); - // } - - // public focusOnRegex(): void { - // this.regex?.focus(); - // } - private _lastHighlightFindOptions: number = 0; public highlightFindOptions(): void { this.domNode.classList.remove('highlight-' + (this._lastHighlightFindOptions)); diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index 5033c2565d9b3..9ac108af26f87 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -32,6 +32,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IThemeService, themeColorFromId } from '../../../../platform/theme/common/themeService.js'; import { Selection } from '../../../common/core/selection.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { mainWindow } from '../../../../base/browser/window.js'; const SEARCH_STRING_MAX_LENGTH = 524288; @@ -704,13 +705,24 @@ export class NextMatchFindAction extends MatchFindAction { } protected _run(controller: CommonFindController): boolean { - const result = controller.moveToNextMatch(); - if (result) { - controller.editor.pushUndoStop(); - return true; - } + return controller.moveToNextMatch(); - return false; + // // Commented-out the code below since it's preventing + // // the modulo/wrap-around behavior that occurs when + // // cycling past the upper limit. + // // `PreviousMatchFindAction` exhibits this wrap-around + // // behavior for the lower limit, so for the sake of symmetry, + // // it seems best to allow it here as well. + + + //// TODO: REMOVE? + // const result = controller.moveToNextMatch(); + // if (result) { + // controller.editor.pushUndoStop(); + // return true; + // } + + // return false; } } @@ -860,6 +872,100 @@ export class MoveToMatchFindAction extends EditorAction { } } +export class MoveToEditableNthMatchFindAction extends EditorAction { + + private _highlightDecorations: string[] = []; + private inputElement: HTMLInputElement | null | undefined; + + constructor() { + super({ + id: FIND_IDS.GoToEditableNthMatchFindAction, + label: nls.localize('findMatchAction.goToEditableNthMatch', "Go to Editable Nth Match..."), + alias: 'Go to Editable Nth Match...', + precondition: CONTEXT_FIND_WIDGET_VISIBLE + }); + } + + public run(accessor: ServicesAccessor, editor: ICodeEditor, args: any): void | Promise { + const controller = CommonFindController.get(editor); + this.inputElement = mainWindow.document.querySelector('.editable-nth-match')?.querySelector('input') as HTMLInputElement; + if (!controller || !this.inputElement) { + 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) { + return matchCount + index; + } + + return undefined; + }; + + const index = toFindMatchIndex((this.inputElement.value || this.inputElement.min)); + if (typeof index === 'number') { + // valid + controller.goToMatch(index); + const currentMatch = controller.getState().currentMatch; + if (currentMatch) { + this.addDecorations(editor, currentMatch); + } + 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 abstract class SelectionMatchFindAction extends EditorAction { public async run(accessor: ServicesAccessor | null, editor: ICodeEditor): Promise { const controller = CommonFindController.get(editor); @@ -996,6 +1102,7 @@ registerEditorAction(StartFindWithSelectionAction); registerEditorAction(NextMatchFindAction); registerEditorAction(PreviousMatchFindAction); registerEditorAction(MoveToMatchFindAction); +registerEditorAction(MoveToEditableNthMatchFindAction); 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 bd6fa9baedc9b..7909b97e1632b 100644 --- a/src/vs/editor/contrib/find/browser/findModel.ts +++ b/src/vs/editor/contrib/find/browser/findModel.ts @@ -59,6 +59,7 @@ export const FIND_IDS = { NextMatchFindAction: 'editor.action.nextMatchFindAction', PreviousMatchFindAction: 'editor.action.previousMatchFindAction', GoToMatchFindAction: 'editor.action.goToMatchFindAction', + GoToEditableNthMatchFindAction: 'editor.action.goToEditableNthMatchFindAction', NextSelectionMatchFindAction: 'editor.action.nextSelectionMatchFindAction', PreviousSelectionMatchFindAction: 'editor.action.previousSelectionMatchFindAction', StartFindReplaceAction: 'editor.action.startFindReplaceAction', diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index 02b0707245b58..840a28278029a 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -11,7 +11,7 @@ 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 { MatchLocationInput } from '../../../../base/browser/ui/findinput/matchLocationInput.js'; +import { NthMatchInput } from '../../../../base/browser/ui/findinput/nthMatchInput.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'; @@ -138,7 +138,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private _findInput!: FindInput; private _replaceInput!: ReplaceInput; - private _matchLocationInput!: MatchLocationInput; + private _nthMatchInput!: NthMatchInput; private _toggleReplaceBtn!: SimpleButton; private _matchesCount!: HTMLElement; @@ -432,20 +432,15 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL label = NLS_NO_RESULTS; } - this._matchLocationInput = this.getMatchLocationInput(); + // this._nthMatchInput = this.getNthMatchInput(); + this._nthMatchInput.setValue(`${this._state.matchesPosition}`); + this._nthMatchInput.min = this._state.matchesCount >= 1 ? 1 : 0; + this._nthMatchInput.max = this._state.matchesCount; - - this._matchesCount.appendChild(this._matchLocationInput.domNode); + this._matchesCount.appendChild(this._nthMatchInput.domNode); this._matchesCount.appendChild(document.createTextNode(' of ')); this._matchesCount.appendChild(document.createTextNode(`${this._state.matchesCount}`)); - // const matchesLocationInput = this.getMatchLocationInput(); - // matchesLocationInput.value = `${this._state.matchesPosition}`; - - // this._matchesCount.appendChild(matchesLocationInput); - // this._matchesCount.appendChild(document.createTextNode(' of ')); - // this._matchesCount.appendChild(document.createTextNode(`${this._state.matchesCount}`)); - alertFn(this._getAriaLabel(label, this._state.currentMatch, this._state.searchString)); MAX_MATCHES_COUNT_WIDTH = Math.max(MAX_MATCHES_COUNT_WIDTH, this._matchesCount.clientWidth); } @@ -453,126 +448,14 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL // ----- actions - // private getMatchLocationInput(): HTMLInputElement { - // let inputEl = document.querySelector('.editable-match-location') as HTMLInputElement; - - // if (!inputEl) { - // inputEl = document.createElement('input'); - // // Set input type and upper/lower bounds - // inputEl.type = 'number'; - // inputEl.max = `${this._state.matchesCount}`; - // inputEl.min = '1'; - - // // Keep styling consistent with the surrounding UI. - // inputEl.classList.add(...['monaco-inputbox', 'editable-match-location']); - - // inputEl.addEventListener('focus', () => { - // inputEl.classList.add(...['synthetic-focus']); - // }); - - // inputEl.addEventListener('blur', () => { - // inputEl.classList.remove(...['synthetic-focus']); - // }); - - // inputEl.addEventListener('input', (event: Event) => { - // console.log('findWidget.ts ---> getMatchLocationInput() ---> input INPUT event', event); - // const currentValue = validateInput((event?.target as HTMLInputElement).value); - // }); - - // inputEl.addEventListener('change', (event: Event) => { - // console.log('findWidget.ts ---> getMatchLocationInput() ---> input CHANGE event', event); - // const currentValue = validateInput((event?.target as HTMLInputElement).value); - - // }); - - - // // inputEl.onfocus = () => { - // // inputEl.classList.add(...['synthetic-focus']); - // // }; - - // // inputEl.onblur = () => { - // // inputEl.classList.remove(...['synthetic-focus']); - // // }; - - // // inputEl.oninput = (event: InputEvent) => { - // // const currentValue = validateInput(event?.target?); - // // }; - - // // inputEl.onchange = (event) => { - - // // }; - - // const validateInput = (inputVal: any) => { - // // return Math.min(input) - // } - // } - - // return inputEl; - // } - - private getMatchLocationInput(): MatchLocationInput { - // let inputEl = document.querySelector('.editable-match-location') as HTMLInputElement; - - // if (!inputEl) { - // inputEl = document.createElement('input'); - // // Set input type and upper/lower bounds - // inputEl.type = 'number'; - // inputEl.max = `${this._state.matchesCount}`; - // inputEl.min = '1'; - - // // Keep styling consistent with the surrounding UI. - // inputEl.classList.add(...['monaco-inputbox', 'editable-match-location']); - - // inputEl.addEventListener('focus', () => { - // inputEl.classList.add(...['synthetic-focus']); - // }); - - // inputEl.addEventListener('blur', () => { - // inputEl.classList.remove(...['synthetic-focus']); - // }); - - // inputEl.addEventListener('input', (event: Event) => { - // console.log('findWidget.ts ---> getMatchLocationInput() ---> input INPUT event', event); - // const currentValue = validateInput((event?.target as HTMLInputElement).value); - // }); - - // inputEl.addEventListener('change', (event: Event) => { - // console.log('findWidget.ts ---> getMatchLocationInput() ---> input CHANGE event', event); - // const currentValue = validateInput((event?.target as HTMLInputElement).value); - - // }); - - - // // inputEl.onfocus = () => { - // // inputEl.classList.add(...['synthetic-focus']); - // // }; - - // // inputEl.onblur = () => { - // // inputEl.classList.remove(...['synthetic-focus']); - // // }; - - // // inputEl.oninput = (event: InputEvent) => { - // // const currentValue = validateInput(event?.target?); - // // }; - - // // inputEl.onchange = (event) => { - - // // }; - - // const validateInput = (inputVal: any) => { - // // return Math.min(input) - // } - // } - - // Match Location Edit Input - // Created above during this._updateMatchesCount(); - const input = new MatchLocationInput(this._domNode, this._contextViewProvider, { + private getNthMatchInput(): NthMatchInput { + const input = new NthMatchInput(this._domNode, this._contextViewProvider, { placeholder: '', width: 20, validation: undefined, label: '', type: 'number', - min: 0, + min: this._state.matchesCount >= 1 ? 1 : 0, max: this._state.matchesCount, flexibleHeight: undefined, flexibleWidth: undefined, @@ -588,13 +471,21 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL else { assertIsDefined(this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction)).run().then(undefined, onUnexpectedError); } + input.focus(); })); this._register(input.onJump((e) => { - assertIsDefined(this._codeEditor.getAction(FIND_IDS.GoToMatchFindAction)).run().then(undefined, onUnexpectedError); + assertIsDefined(this._codeEditor.getAction(FIND_IDS.GoToEditableNthMatchFindAction)).run().then(undefined, onUnexpectedError); + // this._nthMatchInput.focus(); + input.focus(); + })); + + this._register(input.onInput((e) => { + const currentValueAsInt = parseInt(input.getValue()); + input.setValue(currentValueAsInt > input.max ? `${input.max}` : currentValueAsInt < input.min ? `${input.min}` : `${currentValueAsInt}`); })); - input.domNode.classList.add(...['monaco-inputbox', 'editable-match-location']); + input.domNode.classList.add(...['monaco-inputbox', 'editable-nth-match']); input.setValue(`${this._state.matchesPosition}`); return input; @@ -1176,6 +1067,8 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._matchesCount = document.createElement('div'); this._matchesCount.className = 'matchesCount'; + this._nthMatchInput = this.getNthMatchInput(); + this._updateMatchesCount(); From ceda74bba83c9ea7cab66986e19576dee530b29f Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Sat, 4 Apr 2026 18:27:49 -0400 Subject: [PATCH 04/15] findWidget - More Iteration and Cleanup - Restore 'No Results' error message when the input search string yields 0 matches. - Fallback to default text input element behavior. - Add up/down arrow key support for step behavior native to numerical input elements. - Remove unnecessary comments and scar tissue. --- .../browser/ui/findinput/nthMatchInput.ts | 223 +----------------- .../contrib/find/browser/findController.ts | 23 +- .../editor/contrib/find/browser/findWidget.ts | 112 ++------- 3 files changed, 35 insertions(+), 323 deletions(-) diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.ts b/src/vs/base/browser/ui/findinput/nthMatchInput.ts index f9d0fa1721ce6..05cc1db9ded67 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.ts +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.ts @@ -6,33 +6,21 @@ import * as dom from '../../dom.js'; import { IKeyboardEvent } from '../../keyboardEvent.js'; import { IMouseEvent } from '../../mouseEvent.js'; -import { IToggleStyles, Toggle } from '../toggle/toggle.js'; +import { IToggleStyles } from '../toggle/toggle.js'; import { IContextViewProvider } from '../contextview/contextview.js'; -// import { CaseSensitiveToggle, RegexToggle, WholeWordsToggle } from './findInputToggles.js'; -import { - // HistoryInputBox, - InputBox, - IInputBoxStyles, - IInputValidator, - IMessage as InputBoxMessage -} from '../inputbox/inputBox.js'; +import { InputBox, IInputBoxStyles, IInputValidator, 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 { DisposableStore, MutableDisposable } from '../../../common/lifecycle.js'; -import { createInstantHoverDelegate } from '../hover/hoverDelegateFactory.js'; // TODO: Remove? -import { IRange } from '../../../common/range.js'; -// import { IScrollEvent } from '../../../../editor/common/editorCommon.js'; - export interface INthMatchInputOptions { readonly placeholder?: string; readonly width?: number; readonly validation?: IInputValidator; readonly label: string; - readonly type: 'number'; + readonly type: 'text'; readonly min?: number; readonly max?: number; readonly lastMatchLocation?: number; @@ -41,12 +29,6 @@ export interface INthMatchInputOptions { readonly flexibleMaxHeight?: number; readonly showCommonFindToggles?: boolean; - // readonly appendCaseSensitiveLabel?: string; - // readonly appendWholeWordsLabel?: string; - // readonly appendRegexLabel?: string; - // readonly history?: string[]; - // readonly additionalToggles?: Toggle[]; - // readonly showHistoryHint?: () => boolean; readonly toggleStyles: IToggleStyles; readonly inputBoxStyles: IInputBoxStyles; } @@ -56,10 +38,6 @@ export interface IStepEvent { } export interface IJumpEvent { - // `toMatchLocation` is emitted as a formality and for completion. - // However, the consumer (codeEditor.ts --> findController.ts) doesn't need it. - // findController.ts calls its own `toFindMatchIndex(value)` method - // which handles indexing. toMatchLocation: number; } @@ -67,22 +45,12 @@ const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); export class NthMatchInput extends Widget { - // static readonly OPTION_CHANGE: string = 'optionChange'; - private placeholder: string; private validation?: IInputValidator; private label: string; private type: string; - private readonly showCommonFindToggles: boolean; - private fixFocusOnOptionClickEnabled = true; private imeSessionInProgress = false; - private readonly additionalTogglesDisposables: MutableDisposable = this._register(new MutableDisposable()); - // protected readonly controls: HTMLDivElement; - // protected readonly regex?: RegexToggle; - // protected readonly wholeWords?: WholeWordsToggle; - // protected readonly caseSensitive?: CaseSensitiveToggle; - // protected additionalToggles: Toggle[] = []; public readonly domNode: HTMLElement; public readonly inputBox: InputBox; public lastMatchLocation: number; @@ -107,31 +75,18 @@ export class NthMatchInput extends Widget { private readonly _onStep = this._register(new Emitter()); public readonly onStep: Event = this._onStep.event; - private readonly _onJump = this._register(new Emitter()); public readonly onJump: Event = this._onJump.event; - // private _onCaseSensitiveKeyDown = this._register(new Emitter()); - // public readonly onCaseSensitiveKeyDown: Event = this._onCaseSensitiveKeyDown.event; - - // private _onRegexKeyDown = this._register(new Emitter()); - // public readonly onRegexKeyDown: Event = this._onRegexKeyDown.event; - constructor(parent: HTMLElement | null, contextViewProvider: IContextViewProvider | undefined, options: INthMatchInputOptions) { super(); this.placeholder = options.placeholder || ''; this.validation = options.validation; this.label = options.label || NLS_DEFAULT_LABEL; - this.type = options.type || 'number'; + this.type = options.type || 'text'; this.min = options.min || 0; this.max = options.max || 0; this.lastMatchLocation = options.lastMatchLocation || 0; - this.showCommonFindToggles = !!options.showCommonFindToggles; - - // const appendCaseSensitiveLabel = options.appendCaseSensitiveLabel || ''; - // const appendWholeWordsLabel = options.appendWholeWordsLabel || ''; - // const appendRegexLabel = options.appendRegexLabel || ''; - // const history = options.history || []; const flexibleHeight = !!options.flexibleHeight; const flexibleWidth = !!options.flexibleWidth; const flexibleMaxHeight = options.flexibleMaxHeight; @@ -142,11 +97,6 @@ export class NthMatchInput extends Widget { this.inputBox = this._register(new InputBox(this.domNode, contextViewProvider, { placeholder: this.placeholder || '', ariaLabel: this.label || '', - validationOptions: { - validation: this.validation - }, - // history, - // showHistoryHint: options.showHistoryHint, flexibleHeight, flexibleWidth, flexibleMaxHeight, @@ -154,174 +104,22 @@ export class NthMatchInput extends Widget { type: this.type })); - const hoverDelegate = this._register(createInstantHoverDelegate()); - - - // TODO: Add scroll listener? - // Let the user scroll the input value up or down on scroll? - // this.onscroll(this.domNode, (event: IScrollEvent) => { - - // }); - this.onkeydown(this.domNode, (event: IKeyboardEvent) => { - const currentValueAsInt = parseInt(this.inputBox.value); - const isNumericKey = event.keyCode >= KeyCode.Digit0 && event.keyCode <= KeyCode.Digit9; - - - // A numeric key was pressed - if (isNumericKey) { - // this.inputBox.value = currentValueAsInt > this.max ? `${this.max}` : currentValueAsInt < this.min ? `${this.min}` : `${currentValueAsInt}`; - - - // if (currentValueAsInt > this.max) { - // this.inputBox.value = `${this.max}`; - // // this._onJump.fire({ toMatchLocation: this.max }); - // } - // else if (currentValueAsInt < this.min) { - // this.inputBox.value = `${this.min}`; - // } - // // this.inputBox.focus(); - } // Arrow-Key support to step the matched location up or down - else if (event.equals(KeyCode.UpArrow)) { - // const valueAfterChange = (!isNaN(currentValueAsInt) ? currentValueAsInt + 1 : 0); + if (event.equals(KeyCode.UpArrow)) { this._onStep.fire({ direction: 'down' }); - // this.lastMatchLocation = valueAfterChange; - - // const valueAfterChange = (!isNaN(currentValueAsInt) ? currentValueAsInt + 1 : 0); - // if (valueAfterChange > this.max) { // Out of bounds. Not sure by how much so 'jump' inbounds from the right - // this._onJump.fire({ toMatchLocation: this.min }); - // this.lastMatchLocation = this.min; - // } - // else { - // this._onStep.fire({ direction: 'up' }); - // this.lastMatchLocation = valueAfterChange; - // } } - else if (event.equals(KeyCode.DownArrow)) { this._onStep.fire({ direction: 'up' }); - - - // const valueAfterChange = (!isNaN(currentValueAsInt) ? currentValueAsInt /* - 1 */ : 0); - // if (valueAfterChange < this.min) { // Out of bounds. Not sure by how much so 'jump' inbounds from the left - // this._onJump.fire({ toMatchLocation: this.min }); - // this.lastMatchLocation = this.min; - // } - // else { - // this._onStep.fire({ direction: 'down' }); - // this.lastMatchLocation = valueAfterChange; - // } - } - - // Arrow-Key support to step the cursor left or right in the input box - else if (event.equals(KeyCode.LeftArrow)) { - const { start: cursorStart, end: cursorEnd }: IRange = (this.inputBox.getSelection() as IRange); - - if (cursorStart > 0) { - this.inputBox.inputElement.selectionStart = (this.inputBox.inputElement.selectionStart || 1) - 1; - } - } - - else if (event.equals(KeyCode.RightArrow)) { - const { start: cursorStart, end: cursorEnd }: IRange = (this.inputBox.getSelection() as IRange); - - if (cursorStart < (this.inputBox.inputElement.selectionEnd || this.inputBox.value.length - 1)) { - this.inputBox.inputElement.selectionStart = (this.inputBox.inputElement.selectionStart || 0) + 1; - } - } - - else if (event.equals(KeyCode.Backspace)) { - const charsArr = [...this.inputBox.value]; - charsArr.pop(); - this.inputBox.value = `${parseInt(charsArr?.join(''))}`; - } - - else if (event.equals(KeyCode.Delete)) { - if (!this.inputBox.isSelectionAtEnd()) { - const charsArr = [...this.inputBox.value]; - charsArr.splice(this.inputBox.inputElement.selectionStart || 0, 1); - this.inputBox.value = `${parseInt(charsArr?.join(''))}`; - // this.inputBox.inputElement.selectionStart = - } } - else if (event.equals(KeyCode.Enter)) { - // const destination = ( - // !isNaN(currentValueAsInt) ? - // Math.max((Math.min(currentValueAsInt, this.max), this.min)) - // : 0 - // ); - // this._onJump.fire({ toMatchLocation: destination }); - this._onJump.fire({ toMatchLocation: currentValueAsInt }); - // this.inputBox.focus(); } - else if ( - event.equals(KeyCode.Escape) || event.equals(KeyCode.Tab) || - (event.shiftKey && event.keyCode === KeyCode.Tab) - ) { - - this.inputBox.blur(); - // document.dispatchEvent(new KeyboardEvent('Tab')); - } - - // Select 1 character to the left - else if (event.shiftKey && event.code === 'ArrowLeft'/* event.equals(KeyCode.LeftArrow) */) { - const selectRange: IRange = { - start: (this.inputBox.inputElement.selectionEnd || 1) - 1, - end: this.inputBox.inputElement.selectionEnd || 1 - }; - this.inputBox.select(selectRange); - } - - // Select 1 character to the right - else if (event.shiftKey && event.code === 'ArrowRight'/* event.equals(KeyCode.RightArrow) */) { - const selectRange: IRange = { - start: (this.inputBox.inputElement.selectionStart || 1) + 1, - end: this.inputBox.inputElement.selectionEnd || 1 - }; - this.inputBox.select(selectRange); - } - - // Select 1 word/token to the left - else if (event.shiftKey && event.ctrlKey && event.code === 'ArrowLeft' /* event.equals(KeyCode.LeftArrow) */) { - - } - - // Select 1 word/token to the right - else if (event.shiftKey && event.ctrlKey && event.code === 'ArrowRight' /* event.equals(KeyCode.RightArrow) */) { - - } - - // Select all input text - else if (event.ctrlKey && event.code === 'KeyA' /* event.equals(KeyCode.KeyA) */) { - const selectRange: IRange = { start: 0, end: this.inputBox.value.length - 1 }; - this.inputBox.select(selectRange); - } - - - // Ctrl+Z (undo) - - - // Ctrl+Shift+Z or Ctrl+Y(redo) - - - - - - - - - if (!isNumericKey) { - dom.EventHelper.stop(event, true); - } }); - parent?.appendChild(this.domNode); this._register(dom.addDisposableListener(this.inputBox.inputElement, 'compositionstart', (e: CompositionEvent) => { @@ -361,10 +159,6 @@ export class NthMatchInput extends Widget { this.inputBox.disable(); } - public setFocusInputOnOptionClick(value: boolean): void { - this.fixFocusOnOptionClickEnabled = value; - } - public setEnabled(enabled: boolean): void { if (enabled) { this.enable(); @@ -405,13 +199,6 @@ export class NthMatchInput extends Widget { this.inputBox.focus(); } - private _lastHighlightFindOptions: number = 0; - public highlightFindOptions(): void { - this.domNode.classList.remove('highlight-' + (this._lastHighlightFindOptions)); - this._lastHighlightFindOptions = 1 - this._lastHighlightFindOptions; - this.domNode.classList.add('highlight-' + (this._lastHighlightFindOptions)); - } - public validate(): void { this.inputBox.validate(); } diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index 9ac108af26f87..2f3a0e3352282 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -705,24 +705,13 @@ export class NextMatchFindAction extends MatchFindAction { } protected _run(controller: CommonFindController): boolean { - return controller.moveToNextMatch(); - - // // Commented-out the code below since it's preventing - // // the modulo/wrap-around behavior that occurs when - // // cycling past the upper limit. - // // `PreviousMatchFindAction` exhibits this wrap-around - // // behavior for the lower limit, so for the sake of symmetry, - // // it seems best to allow it here as well. - - - //// TODO: REMOVE? - // const result = controller.moveToNextMatch(); - // if (result) { - // controller.editor.pushUndoStop(); - // return true; - // } + const result = controller.moveToNextMatch(); + if (result) { + controller.editor.pushUndoStop(); + return true; + } - // return false; + return false; } } diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index 840a28278029a..ab21d29ae1647 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -137,7 +137,6 @@ 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; @@ -413,8 +412,12 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } // remove previous content - // this._matchesCount.firstChild?.remove(); - [...this._matchesCount.childNodes].forEach(x => x.parentNode?.removeChild(x)); + if (this._matchesCount.childNodes.length > 1) { + [...this._matchesCount.childNodes].forEach(x => x.parentNode?.removeChild(x)); + } + else { + this._matchesCount.firstChild?.remove(); + } let label: string; @@ -428,19 +431,20 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL matchesPosition = '?'; } label = strings.format(NLS_MATCHES_LOCATION, matchesPosition, matchesCount); + + this._nthMatchInput.setValue(`${this._state.matchesPosition}`); + this._nthMatchInput.min = this._state.matchesCount >= 1 ? 1 : 0; + this._nthMatchInput.max = this._state.matchesCount; + + this._matchesCount.appendChild(this._nthMatchInput.domNode); + this._matchesCount.appendChild(document.createTextNode(' of ')); + this._matchesCount.appendChild(document.createTextNode(`${this._state.matchesCount}`)); + } else { label = NLS_NO_RESULTS; + this._matchesCount.appendChild(document.createTextNode(label)); } - // this._nthMatchInput = this.getNthMatchInput(); - this._nthMatchInput.setValue(`${this._state.matchesPosition}`); - this._nthMatchInput.min = this._state.matchesCount >= 1 ? 1 : 0; - this._nthMatchInput.max = this._state.matchesCount; - - this._matchesCount.appendChild(this._nthMatchInput.domNode); - this._matchesCount.appendChild(document.createTextNode(' of ')); - this._matchesCount.appendChild(document.createTextNode(`${this._state.matchesCount}`)); - alertFn(this._getAriaLabel(label, this._state.currentMatch, this._state.searchString)); MAX_MATCHES_COUNT_WIDTH = Math.max(MAX_MATCHES_COUNT_WIDTH, this._matchesCount.clientWidth); } @@ -454,7 +458,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL width: 20, validation: undefined, label: '', - type: 'number', + type: 'text', min: this._state.matchesCount >= 1 ? 1 : 0, max: this._state.matchesCount, flexibleHeight: undefined, @@ -476,13 +480,18 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._register(input.onJump((e) => { assertIsDefined(this._codeEditor.getAction(FIND_IDS.GoToEditableNthMatchFindAction)).run().then(undefined, onUnexpectedError); - // this._nthMatchInput.focus(); input.focus(); })); this._register(input.onInput((e) => { const currentValueAsInt = parseInt(input.getValue()); - input.setValue(currentValueAsInt > input.max ? `${input.max}` : currentValueAsInt < input.min ? `${input.min}` : `${currentValueAsInt}`); + // Enforce the numerical input and min/max constraints here. + input.setValue( + isNaN(currentValueAsInt) ? + `${input.min}` : currentValueAsInt > input.max ? + `${input.max}` : currentValueAsInt < input.min ? + `${input.min}` : `${currentValueAsInt}` + ); })); input.domNode.classList.add(...['monaco-inputbox', 'editable-nth-match']); @@ -1066,82 +1075,9 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._matchesCount = document.createElement('div'); this._matchesCount.className = 'matchesCount'; - this._nthMatchInput = this.getNthMatchInput(); - this._updateMatchesCount(); - - // this._register(new ContextScopedFindInput(null, this._contextViewProvider, { - // width: FIND_INPUT_AREA_WIDTH, - // label: NLS_FIND_INPUT_LABEL, - // placeholder: NLS_FIND_INPUT_PLACEHOLDER, - // appendCaseSensitiveLabel: this._keybindingLabelFor(FIND_IDS.ToggleCaseSensitiveCommand), - // appendWholeWordsLabel: this._keybindingLabelFor(FIND_IDS.ToggleWholeWordCommand), - // appendRegexLabel: this._keybindingLabelFor(FIND_IDS.ToggleRegexCommand), - // validation: (value: string): InputBoxMessage | null => { - // if (value.length === 0 || !this._findInput.getRegex()) { - // return null; - // } - // try { - // // use `g` and `u` which are also used by the TextModel search - // new RegExp(value, 'gu'); - // return null; - // } catch (e) { - // return { content: e.message }; - // } - // }, - // flexibleHeight, - // flexibleWidth, - // flexibleMaxHeight: 118, - // showCommonFindToggles: true, - // showHistoryHint: () => showHistoryKeybindingHint(this._keybindingService), - // inputBoxStyles: defaultInputBoxStyles, - // toggleStyles: defaultToggleStyles - // }, this._contextKeyService)); - // this._findInput.setRegex(!!this._state.isRegex); - // this._findInput.setCaseSensitive(!!this._state.matchCase); - // this._findInput.setWholeWords(!!this._state.wholeWord); - // this._register(this._findInput.onKeyDown((e) => this._onFindInputKeyDown(e))); - // this._register(this._findInput.inputBox.onDidChange(() => { - // if (this._ignoreChangeEvent) { - // return; - // } - // this._state.change({ searchString: this._findInput.getValue() }, true); - // })); - // this._register(this._findInput.onDidOptionChange(() => { - // this._state.change({ - // isRegex: this._findInput.getRegex(), - // wholeWord: this._findInput.getWholeWords(), - // matchCase: this._findInput.getCaseSensitive() - // }, true); - // })); - // this._register(this._findInput.onCaseSensitiveKeyDown((e) => { - // if (e.equals(KeyMod.Shift | KeyCode.Tab)) { - // if (this._isReplaceVisible) { - // this._replaceInput.focus(); - // e.preventDefault(); - // } - // } - // })); - // this._register(this._findInput.onRegexKeyDown((e) => { - // if (e.equals(KeyCode.Tab)) { - // if (this._isReplaceVisible) { - // this._replaceInput.focusOnPreserve(); - // e.preventDefault(); - // } - // } - // })); - // this._register(this._findInput.inputBox.onDidHeightChange((e) => { - // if (this._tryUpdateHeight()) { - // this._showViewZone(); - // } - // })); - // if (platform.isLinux) { - // this._register(this._findInput.onMouseDown((e) => this._onFindInputMouseDown(e))); - // } - - // Create a scoped hover delegate for all find related buttons const hoverDelegate = this._register(createInstantHoverDelegate()); From 7da8071e0a989fc46f3d4ee69953921e0f98ec20 Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Mon, 6 Apr 2026 10:30:00 -0400 Subject: [PATCH 05/15] simpleFindWidget - Add Nth Match support to the terminal's finder widget . - UI/API proof-of-concept. - Build, link and troubleshoot a local build of @xterm node_module, modified to support nth match. - Before fixing focus-retention issue of NthMatchInput in terminal simpleFindWidget --- .../browser/ui/findinput/nthMatchInput.css | 24 ++++--- .../browser/ui/findinput/nthMatchInput.ts | 10 +-- .../browser/find/simpleFindWidget.css | 8 ++- .../browser/find/simpleFindWidget.ts | 67 ++++++++++++++++++- .../contrib/terminal/browser/terminal.ts | 8 +++ .../terminal/browser/xterm/xtermTerminal.ts | 5 ++ .../find/browser/terminalFindWidget.ts | 15 +++++ .../webview/browser/webviewFindWidget.ts | 4 ++ 8 files changed, 122 insertions(+), 19 deletions(-) diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.css b/src/vs/base/browser/ui/findinput/nthMatchInput.css index 137f70905606d..279dcda6e6a39 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.css +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.css @@ -2,9 +2,10 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* ---------- Match Location input ---------- */ -/* Editable numerical input for match location */ +/* ---------- Nth Match Editor Input Box ---------- */ + + .monaco-editor .find-widget .matchesCount .editable-nth-match { display: block !important; width: 45px; @@ -24,13 +25,14 @@ } -/* -Hide spin-box stepper controls (up and down arrows) to keep -the UI consistent with the rest of the widget. -(For now, Chromium support ONLY) - */ -.monaco-editor .find-widget .matchesCount .editable-nth-match input[type="number"]::-webkit-inner-spin-button, -.monaco-editor .find-widget .matchesCount .editable-nth-match input[type="number"]::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; +/* ---------- Nth Match Terminal Input Box ---------- */ +.monaco-findInput.monaco-inputbox.editable-nth-match { + width: 45px; + margin-right: 0.5em; +} + +.monaco-findInput.monaco-inputbox.editable-nth-match input { + text-align: center; + overflow-x: hidden; + text-overflow: clip; } diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.ts b/src/vs/base/browser/ui/findinput/nthMatchInput.ts index 05cc1db9ded67..f1ea818c15248 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.ts +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.ts @@ -38,7 +38,7 @@ export interface IStepEvent { } export interface IJumpEvent { - toMatchLocation: number; + targetMatchPos: number; } const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); @@ -46,7 +46,7 @@ const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); export class NthMatchInput extends Widget { private placeholder: string; - private validation?: IInputValidator; + // private validation?: IInputValidator; private label: string; private type: string; private imeSessionInProgress = false; @@ -81,7 +81,7 @@ export class NthMatchInput extends Widget { constructor(parent: HTMLElement | null, contextViewProvider: IContextViewProvider | undefined, options: INthMatchInputOptions) { super(); this.placeholder = options.placeholder || ''; - this.validation = options.validation; + // this.validation = options.validation; this.label = options.label || NLS_DEFAULT_LABEL; this.type = options.type || 'text'; this.min = options.min || 0; @@ -107,7 +107,7 @@ export class NthMatchInput extends Widget { this.onkeydown(this.domNode, (event: IKeyboardEvent) => { const currentValueAsInt = parseInt(this.inputBox.value); - // Arrow-Key support to step the matched location up or down + // Arrow-Key support to step the match position up or down if (event.equals(KeyCode.UpArrow)) { this._onStep.fire({ direction: 'down' }); } @@ -115,7 +115,7 @@ export class NthMatchInput extends Widget { this._onStep.fire({ direction: 'up' }); } else if (event.equals(KeyCode.Enter)) { - this._onJump.fire({ toMatchLocation: currentValueAsInt }); + this._onJump.fire({ targetMatchPos: currentValueAsInt }); } }); diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css index 02512f30b5b0f..32e2169045472 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css @@ -19,7 +19,8 @@ } .monaco-workbench .simple-find-part { - visibility: hidden; /* Use visibility to maintain flex layout while hidden otherwise interferes with transition */ + visibility: hidden; + /* Use visibility to maintain flex layout while hidden otherwise interferes with transition */ z-index: 10; position: relative; top: -45px; @@ -58,6 +59,9 @@ } .monaco-workbench .simple-find-part .matchesCount { + display: flex; + align-items: center; + justify-content: space-between; width: 73px; max-width: 73px; min-width: 73px; @@ -107,7 +111,7 @@ div.simple-find-part-wrapper div.button:hover:not(.disabled) { border-bottom-left-radius: 4px; } -.monaco-workbench .simple-find-part .monaco-sash.vertical:before{ +.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/simpleFindWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts index f990b14783432..540b4998f4d16 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts @@ -26,6 +26,7 @@ import { defaultInputBoxStyles, defaultToggleStyles } from '../../../../../platf 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 { NthMatchInput } from '../../../../../base/browser/ui/findinput/nthMatchInput.js'; const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find"); const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find"); @@ -54,6 +55,7 @@ const MATCHES_COUNT_WIDTH = 73; export abstract class SimpleFindWidget extends Widget implements IVerticalSashLayoutProvider { private readonly _findInput: FindInput; + private readonly _nthMatchInput: NthMatchInput; private readonly _domNode: HTMLElement; private readonly _innerDomNode: HTMLElement; private readonly _focusTracker: dom.IFocusTracker; @@ -105,6 +107,9 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa inputBoxStyles: defaultInputBoxStyles, toggleStyles: defaultToggleStyles }, contextKeyService)); + + this._nthMatchInput = this.getNthMatchInput(contextViewService); + // Find History with update delayer this._updateHistoryDelayer = this._register(new Delayer(500)); @@ -251,6 +256,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa } public abstract find(previous: boolean): void; + public abstract findNth(nthMatchPosition: number): void; public abstract findFirst(): void; protected abstract _onInputChanged(): boolean; protected abstract _onFocusTrackerFocus(): void; @@ -420,11 +426,20 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa matchesPosition = '?'; } label = strings.format(NLS_MATCHES_LOCATION, matchesPosition, matchesCount); + + this._nthMatchInput.setValue(`${matchesPosition}`); + this._nthMatchInput.min = +matchesCount >= 1 ? 1 : 0; + this._nthMatchInput.max = +matchesCount; + + this._matchesCount.appendChild(this._nthMatchInput.domNode); + this._matchesCount.appendChild(document.createTextNode(' of ')); + this._matchesCount.appendChild(document.createTextNode(`${matchesCount}`)); } else { label = NLS_NO_RESULTS; + this._matchesCount.appendChild(document.createTextNode(label)); } status(this._announceSearchResults(label, this.inputValue)); - this._matchesCount.appendChild(document.createTextNode(label)); + // this._matchesCount.appendChild(document.createTextNode(label)); this._foundMatch = !!count && count.resultCount > 0; this.updateButtons(this._foundMatch); } @@ -445,6 +460,56 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa return nls.localize('ariaSearchNoResultWithLineNumNoCurrentMatch', "{0} found for '{1}'", label, searchString); } + + private getNthMatchInput(contextViewService: IContextViewService): NthMatchInput { + const matchPositionAndCountArr = this._matchesCount?.innerText?.split(' of ') ?? []; + + const input = new NthMatchInput(this._domNode, contextViewService, { + placeholder: '', + width: 20, + validation: undefined, + label: '', + type: 'text', + min: parseInt(matchPositionAndCountArr[1]?.trim() || '0') >= 1 ? 1 : 0, + max: parseInt(matchPositionAndCountArr[1]?.trim()) || 0, + flexibleHeight: undefined, + flexibleWidth: undefined, + flexibleMaxHeight: undefined, + toggleStyles: defaultToggleStyles, + inputBoxStyles: defaultInputBoxStyles, + }); + + this._register(input.onStep((e) => { + if (e.direction === 'up') { + this.find(false); + } + else { + this.find(true); + } + input.focus(); + })); + + this._register(input.onJump((e) => { + this.findNth(e.targetMatchPos || input.min); + input.focus(); + })); + + this._register(input.onInput((e) => { + const currentValueAsInt = parseInt(input.getValue()); + // Enforce the numerical input and min/max constraints here. + input.setValue( + isNaN(currentValueAsInt) ? + `${input.min}` : currentValueAsInt > input.max ? + `${input.max}` : currentValueAsInt < input.min ? + `${input.min}` : `${currentValueAsInt}` + ); + })); + + input.domNode.classList.add(...['monaco-inputbox', 'editable-nth-match']); + input.setValue(`${matchPositionAndCountArr[0]}`.trim()); + + 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/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index d54f8af6561a3..9016409449a25 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -580,6 +580,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 */ + nthMatchPosition?: number; } export interface ITerminalInstance extends IBaseTerminalInstance { @@ -1112,6 +1115,11 @@ export interface IXtermTerminal extends IDisposable { */ findPrevious(term: string, searchOptions: ISearchOptions): Promise; + /** + * Find the Nth instance of the term, where N is 1-based. + */ + 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 3b5a8c644df1d..13fa9db3d39da 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -445,6 +445,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/terminalContrib/find/browser/terminalFindWidget.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts index ba91e210b4680..a09447c353efe 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts @@ -103,6 +103,14 @@ export class TerminalFindWidget extends SimpleFindWidget { } } + public override findNth(nthMatchPosition: number): void { + const xterm = this._instance.xterm; + if (!xterm) { + return; + } + this._findNthWithEvent(xterm, this.inputValue, { regex: this._getRegexValue(), wholeWord: this._getWholeWordValue(), caseSensitive: this._getCaseSensitiveValue(), nthMatchPosition: nthMatchPosition }); + } + override reveal(): void { const initialInput = this._instance.hasSelection() && !this._instance.selection!.includes('\n') ? this._instance.selection : undefined; const inputValue = initialInput ?? this.inputValue; @@ -193,4 +201,11 @@ export class TerminalFindWidget extends SimpleFindWidget { return foundMatch; }); } + + private async _findNthWithEvent(xterm: IXtermTerminal, term: string, options: ISearchOptions): Promise { + return xterm.findNth(term, options).then(foundMatch => { + this._register(Event.once(xterm.onDidChangeSelection)(() => xterm.clearActiveSearchDecoration())); + return foundMatch; + }); + } } diff --git a/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts b/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts index a52aedf2ec18c..543aebb26ce23 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts @@ -59,6 +59,10 @@ export class WebviewFindWidget extends SimpleFindWidget { } } + public override findNth(nthMatchPosition: number): void { + + } + public override hide(animated = true) { super.hide(animated); this._delegate.stopFind(true); From f272897ceaf070b7b28af077886b94fdd8d5d8e1 Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Tue, 7 Apr 2026 11:49:43 -0400 Subject: [PATCH 06/15] simpleFindWidget - Nth Match Support: - Fix focus-retention issue - Use the incumbent dom.FocusTracker to handle focus and blur events --- .../browser/find/simpleFindWidget.css | 3 ++ .../browser/find/simpleFindWidget.ts | 51 +++++++++++++++---- .../contrib/terminal/browser/terminal.ts | 3 +- .../terminal/common/terminalContextKey.ts | 4 ++ .../browser/terminal.find.contribution.ts | 5 ++ .../find/browser/terminalFindWidget.ts | 11 ++++ .../find/common/terminal.find.ts | 2 + .../webview/browser/webviewFindWidget.ts | 7 ++- 8 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css index 32e2169045472..74d9301eab105 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css @@ -59,6 +59,9 @@ } .monaco-workbench .simple-find-part .matchesCount { + display: flex; + align-items: center; + justify-content: space-between; display: flex; align-items: center; justify-content: space-between; diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts index 540b4998f4d16..6915db1432709 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts @@ -7,18 +7,18 @@ 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 } from '../../../../../base/common/async.js'; import { KeyCode } from '../../../../../base/common/keyCodes.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 { SimpleButton, findPreviousMatchIcon, findNextMatchIcon, NLS_NO_RESULTS } 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'; @@ -26,10 +26,10 @@ import { defaultInputBoxStyles, defaultToggleStyles } from '../../../../../platf 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 { NthMatchInput } from '../../../../../base/browser/ui/findinput/nthMatchInput.js'; 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', "Enter Nth 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"); @@ -43,6 +43,7 @@ interface IFindOptions { appendWholeWordsActionId?: string; previousMatchActionId?: string; nextMatchActionId?: string; + nthMatchActionId?: string; closeWidgetActionId?: string; matchesLimit?: number; type?: 'Terminal' | 'Webview'; @@ -60,6 +61,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa 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 prevBtn: SimpleButton; private readonly nextBtn: SimpleButton; @@ -196,6 +198,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(); })); @@ -263,6 +269,8 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa 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() { @@ -412,7 +420,22 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa } const count = await this._getResultCount(); - this._matchesCount.innerText = ''; + + // 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 = ''; @@ -425,17 +448,25 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa if (matchesPosition === '0') { matchesPosition = '?'; } - label = strings.format(NLS_MATCHES_LOCATION, matchesPosition, matchesCount); this._nthMatchInput.setValue(`${matchesPosition}`); this._nthMatchInput.min = +matchesCount >= 1 ? 1 : 0; this._nthMatchInput.max = +matchesCount; - this._matchesCount.appendChild(this._nthMatchInput.domNode); - this._matchesCount.appendChild(document.createTextNode(' of ')); - this._matchesCount.appendChild(document.createTextNode(`${matchesCount}`)); + if (([...this._matchesCount.childNodes].length === 0)) { + this._matchesCount.appendChild(this._nthMatchInput.domNode); + this._matchesCount.appendChild(document.createTextNode(' of ')); + this._matchesCount.appendChild(document.createTextNode(`${matchesCount}`)); + } + else { + (this._matchesCount.lastChild as Node).nodeValue = `${matchesCount}`; + } + } 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)); @@ -468,7 +499,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa placeholder: '', width: 20, validation: undefined, - label: '', + label: NLS_NTH_MATCH_INPUT_LABEL, type: 'text', min: parseInt(matchPositionAndCountArr[1]?.trim() || '0') >= 1 ? 1 : 0, max: parseInt(matchPositionAndCountArr[1]?.trim()) || 0, @@ -486,12 +517,10 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa else { this.find(true); } - input.focus(); })); this._register(input.onJump((e) => { this.findNth(e.targetMatchPos || input.min); - input.focus(); })); this._register(input.onInput((e) => { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 9016409449a25..9f3dc28526b28 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1116,7 +1116,8 @@ export interface IXtermTerminal extends IDisposable { findPrevious(term: string, searchOptions: ISearchOptions): Promise; /** - * Find the Nth instance of the term, where N is 1-based. + * 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; diff --git a/src/vs/workbench/contrib/terminal/common/terminalContextKey.ts b/src/vs/workbench/contrib/terminal/common/terminalContextKey.ts index fc20ba7cf34f1..b0c475d6f56d4 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalContextKey.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalContextKey.ts @@ -34,6 +34,7 @@ export const enum TerminalContextKeyStrings { FindVisible = 'terminalFindVisible', FindInputFocused = 'terminalFindInputFocused', FindFocused = 'terminalFindFocused', + NthMatchInput = 'terminalNthMatchInputFocused', TabsSingularSelection = 'terminalTabsSingularSelection', SplitTerminal = 'terminalSplitTerminal', ShellType = 'terminalShellType', @@ -120,6 +121,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/terminal.find.contribution.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminal.find.contribution.ts index 40e7294511387..11fac7b6208bb 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/browser/terminal.find.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminal.find.contribution.ts @@ -232,6 +232,11 @@ registerActiveXtermAction({ const widget = contr?.findWidget; if (widget) { widget.show(); + // widget.find(false); // To make search direciton consistent with the editor's findWidget. + + // Why search backwards by default? Shouldn't the traversal direction be consistent with the editor's findWidget? + // Or is this is an intentional special case for the terminal? + // Since after a command, the output text from the command is above the prompt, so searching backwards is more intuitive. widget.find(true); } } diff --git a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts index a09447c353efe..445a29b29f851 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts @@ -27,6 +27,7 @@ export class TerminalFindWidget extends SimpleFindWidget { private _findInputFocused: IContextKey; private _findWidgetFocused: IContextKey; private _findWidgetVisible: IContextKey; + private _nthMatchInputFocused: IContextKey; private _overrideCopyOnSelectionDisposable: IDisposable | undefined; @@ -52,6 +53,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 @@ -63,6 +65,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) => { @@ -177,6 +180,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()) { 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 543aebb26ce23..4aeb68babdfb6 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts @@ -60,7 +60,8 @@ export class WebviewFindWidget extends SimpleFindWidget { } public override findNth(nthMatchPosition: number): void { - + // TODO: Implement + throw new Error('Method not implemented: webviewFindWidget.ts ---> findNth(nthMatchPosition: number)'); } public override hide(animated = true) { @@ -91,5 +92,9 @@ export class WebviewFindWidget extends SimpleFindWidget { protected _onFindInputFocusTrackerBlur() { } + protected _onNthMatchInputFocusTrackerFocus() { } + + protected _onNthMatchInputFocusTrackerBlur() { } + findFirst() { } } From 119f812bb2b7b4cfef735afd06ce353064218aea Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Tue, 7 Apr 2026 12:28:20 -0400 Subject: [PATCH 07/15] findWidget - Nth Match Support: - Fix the focus-retention issue. - Use the incumbent dom.FocusTracker to handle focus and blur events. --- .../browser/ui/findinput/nthMatchInput.ts | 5 +- .../editor/contrib/find/browser/findModel.ts | 5 +- .../editor/contrib/find/browser/findWidget.ts | 56 ++++++++++++++----- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.ts b/src/vs/base/browser/ui/findinput/nthMatchInput.ts index 05cc1db9ded67..09d2870dd1aa4 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.ts +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.ts @@ -8,7 +8,7 @@ import { IKeyboardEvent } from '../../keyboardEvent.js'; import { IMouseEvent } from '../../mouseEvent.js'; import { IToggleStyles } from '../toggle/toggle.js'; import { IContextViewProvider } from '../contextview/contextview.js'; -import { InputBox, IInputBoxStyles, IInputValidator, IMessage as InputBoxMessage } from '../inputbox/inputBox.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'; @@ -18,7 +18,6 @@ import * as nls from '../../../../nls.js'; export interface INthMatchInputOptions { readonly placeholder?: string; readonly width?: number; - readonly validation?: IInputValidator; readonly label: string; readonly type: 'text'; readonly min?: number; @@ -46,7 +45,6 @@ const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); export class NthMatchInput extends Widget { private placeholder: string; - private validation?: IInputValidator; private label: string; private type: string; private imeSessionInProgress = false; @@ -81,7 +79,6 @@ export class NthMatchInput extends Widget { constructor(parent: HTMLElement | null, contextViewProvider: IContextViewProvider | undefined, options: INthMatchInputOptions) { super(); this.placeholder = options.placeholder || ''; - this.validation = options.validation; this.label = options.label || NLS_DEFAULT_LABEL; this.type = options.type || 'text'; this.min = options.min || 0; diff --git a/src/vs/editor/contrib/find/browser/findModel.ts b/src/vs/editor/contrib/find/browser/findModel.ts index 7909b97e1632b..6366237407061 100644 --- a/src/vs/editor/contrib/find/browser/findModel.ts +++ b/src/vs/editor/contrib/find/browser/findModel.ts @@ -28,8 +28,9 @@ import { IKeybindings } from '../../../../platform/keybinding/common/keybindings 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); +export const CONTEXT_NTH_MATCH_INPUT_FOCUSED = new RawContextKey('nthMatchInputFocused', false); export const ToggleCaseSensitiveKeybinding: IKeybindings = { primary: KeyMod.Alt | KeyCode.KeyC, diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index ab21d29ae1647..9086b4eca5b24 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -26,7 +26,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_REPLACE_INPUT_FOCUSED, FIND_IDS, MATCHES_LIMIT } from './findModel.js'; +import { CONTEXT_FIND_INPUT_FOCUSED, CONTEXT_NTH_MATCH_INPUT_FOCUSED, CONTEXT_REPLACE_INPUT_FOCUSED, FIND_IDS, MATCHES_LIMIT } from './findModel.js'; import { FindReplaceState, FindReplaceStateChangedEvent } from './findState.js'; import * as nls from '../../../../nls.js'; import { AccessibilitySupport } from '../../../../platform/accessibility/common/accessibility.js'; @@ -68,6 +68,7 @@ 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_NTH_MATCH_INPUT_LABEL = nls.localize('label.nthMatchInput', "Nth 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"); @@ -157,6 +158,8 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private readonly _findInputFocused: IContextKey; private readonly _replaceFocusTracker: dom.IFocusTracker; private readonly _replaceInputFocused: IContextKey; + private readonly _nthMatchInputFocusTracker: dom.IFocusTracker; + private readonly _nthMatchInputFocused: IContextKey; private _viewZone?: FindWidgetViewZone; private _viewZoneId?: string; @@ -264,6 +267,16 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._replaceInputFocused.set(false); })); + this._nthMatchInputFocused = CONTEXT_NTH_MATCH_INPUT_FOCUSED.bindTo(contextKeyService); + this._nthMatchInputFocusTracker = this._register(dom.trackFocus(this._nthMatchInput.inputBox.inputElement)); + this._register(this._nthMatchInputFocusTracker.onDidFocus(() => { + this._nthMatchInputFocused.set(true); + this._updateSearchScope(); + })); + this._register(this._nthMatchInputFocusTracker.onDidBlur(() => { + this._nthMatchInputFocused.set(false); + })); + this._codeEditor.addOverlayWidget(this); if (this._codeEditor.getOption(EditorOption.find).addExtraSpaceOnTop) { this._viewZone = new FindWidgetViewZone(0); // Put it before the first line then users can scroll beyond the first line. @@ -411,14 +424,22 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._matchesCount.title = ''; } - // remove previous content - if (this._matchesCount.childNodes.length > 1) { - [...this._matchesCount.childNodes].forEach(x => x.parentNode?.removeChild(x)); - } - else { - 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) { @@ -436,12 +457,20 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._nthMatchInput.min = this._state.matchesCount >= 1 ? 1 : 0; this._nthMatchInput.max = this._state.matchesCount; - this._matchesCount.appendChild(this._nthMatchInput.domNode); - this._matchesCount.appendChild(document.createTextNode(' of ')); - this._matchesCount.appendChild(document.createTextNode(`${this._state.matchesCount}`)); + if (([...this._matchesCount.childNodes].length === 0)) { + this._matchesCount.appendChild(this._nthMatchInput.domNode); + this._matchesCount.appendChild(document.createTextNode(' of ')); + this._matchesCount.appendChild(document.createTextNode(`${matchesCount}`)); + } + else { + (this._matchesCount.lastChild as Node).nodeValue = `${matchesCount}`; + } } 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)); } @@ -454,10 +483,9 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private getNthMatchInput(): NthMatchInput { const input = new NthMatchInput(this._domNode, this._contextViewProvider, { - placeholder: '', + placeholder: 'N', width: 20, - validation: undefined, - label: '', + label: NLS_NTH_MATCH_INPUT_LABEL, type: 'text', min: this._state.matchesCount >= 1 ? 1 : 0, max: this._state.matchesCount, @@ -475,12 +503,10 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL else { assertIsDefined(this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction)).run().then(undefined, onUnexpectedError); } - input.focus(); })); this._register(input.onJump((e) => { assertIsDefined(this._codeEditor.getAction(FIND_IDS.GoToEditableNthMatchFindAction)).run().then(undefined, onUnexpectedError); - input.focus(); })); this._register(input.onInput((e) => { From 9c2c34139d7f9189f2d25f0d696257dd7017feeb Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Wed, 8 Apr 2026 10:36:42 -0400 Subject: [PATCH 08/15] findWidget / terminalFindWidget: Add inline "Find Nth" functionality to access match instances arbitrarily. --- .../browser/ui/findinput/nthMatchInput.css | 36 +++ .../browser/ui/findinput/nthMatchInput.ts | 217 ++++++++++++++++++ .../contrib/find/browser/findController.ts | 96 ++++++++ .../editor/contrib/find/browser/findModel.ts | 6 +- .../editor/contrib/find/browser/findWidget.ts | 72 +++++- 5 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 src/vs/base/browser/ui/findinput/nthMatchInput.css create mode 100644 src/vs/base/browser/ui/findinput/nthMatchInput.ts 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..137f70905606d --- /dev/null +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.css @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* ---------- Match Location input ---------- */ + +/* Editable numerical input for match location */ +.monaco-editor .find-widget .matchesCount .editable-nth-match { + display: block !important; + width: 45px; + min-width: 45px; + max-width: 45px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, transparent); + bottom: 2px; + margin-right: 0.5em; +} + +.monaco-editor .find-widget .matchesCount .editable-nth-match input { + text-align: center; + overflow-x: hidden; + text-overflow: clip; +} + + +/* +Hide spin-box stepper controls (up and down arrows) to keep +the UI consistent with the rest of the widget. +(For now, Chromium support ONLY) + */ +.monaco-editor .find-widget .matchesCount .editable-nth-match input[type="number"]::-webkit-inner-spin-button, +.monaco-editor .find-widget .matchesCount .editable-nth-match input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} 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..05cc1db9ded67 --- /dev/null +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IToggleStyles } from '../toggle/toggle.js'; +import { IContextViewProvider } from '../contextview/contextview.js'; +import { InputBox, IInputBoxStyles, IInputValidator, 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'; + +export interface INthMatchInputOptions { + readonly placeholder?: string; + readonly width?: number; + readonly validation?: IInputValidator; + readonly label: string; + readonly type: 'text'; + readonly min?: number; + readonly max?: number; + readonly lastMatchLocation?: number; + readonly flexibleHeight?: boolean; + readonly flexibleWidth?: boolean; + readonly flexibleMaxHeight?: number; + + readonly showCommonFindToggles?: boolean; + readonly toggleStyles: IToggleStyles; + readonly inputBoxStyles: IInputBoxStyles; +} + +export interface IStepEvent { + direction: 'up' | 'down'; +} + +export interface IJumpEvent { + toMatchLocation: number; +} + +const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); + +export class NthMatchInput extends Widget { + + private placeholder: string; + private validation?: IInputValidator; + private label: string; + private type: string; + private imeSessionInProgress = false; + + public readonly domNode: HTMLElement; + public readonly inputBox: InputBox; + public lastMatchLocation: number; + 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; + + private readonly _onJump = this._register(new Emitter()); + public readonly onJump: Event = this._onJump.event; + + constructor(parent: HTMLElement | null, contextViewProvider: IContextViewProvider | undefined, options: INthMatchInputOptions) { + super(); + this.placeholder = options.placeholder || ''; + this.validation = options.validation; + this.label = options.label || NLS_DEFAULT_LABEL; + this.type = options.type || 'text'; + this.min = options.min || 0; + this.max = options.max || 0; + this.lastMatchLocation = options.lastMatchLocation || 0; + const flexibleHeight = !!options.flexibleHeight; + const flexibleWidth = !!options.flexibleWidth; + const flexibleMaxHeight = options.flexibleMaxHeight; + + this.domNode = document.createElement('div'); + this.domNode.classList.add('monaco-findInput'); + + this.inputBox = this._register(new InputBox(this.domNode, contextViewProvider, { + placeholder: this.placeholder || '', + ariaLabel: this.label || '', + flexibleHeight, + flexibleWidth, + flexibleMaxHeight, + inputBoxStyles: options.inputBoxStyles, + type: this.type + })); + + this.onkeydown(this.domNode, (event: IKeyboardEvent) => { + const currentValueAsInt = parseInt(this.inputBox.value); + + // Arrow-Key support to step the matched location up or down + if (event.equals(KeyCode.UpArrow)) { + this._onStep.fire({ direction: 'down' }); + } + else if (event.equals(KeyCode.DownArrow)) { + this._onStep.fire({ direction: 'up' }); + } + else if (event.equals(KeyCode.Enter)) { + this._onJump.fire({ toMatchLocation: currentValueAsInt }); + } + + }); + + 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); + } + + 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; + } + } + + 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(); + } +} diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index c504148633d2e..6c29a702d234f 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 { mainWindow } from '../../../../base/browser/window.js'; const SEARCH_STRING_MAX_LENGTH = 524288; @@ -932,6 +933,100 @@ export class MoveToMatchFindAction extends EditorAction { } } +export class MoveToEditableNthMatchFindAction extends EditorAction { + + private _highlightDecorations: string[] = []; + private inputElement: HTMLInputElement | null | undefined; + + constructor() { + super({ + id: FIND_IDS.GoToEditableNthMatchFindAction, + label: nls.localize('findMatchAction.goToEditableNthMatch', "Go to Editable Nth Match..."), + alias: 'Go to Editable Nth Match...', + precondition: CONTEXT_FIND_WIDGET_VISIBLE + }); + } + + public run(accessor: ServicesAccessor, editor: ICodeEditor, args: any): void | Promise { + const controller = CommonFindController.get(editor); + this.inputElement = mainWindow.document.querySelector('.editable-nth-match')?.querySelector('input') as HTMLInputElement; + if (!controller || !this.inputElement) { + 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) { + return matchCount + index; + } + + return undefined; + }; + + const index = toFindMatchIndex((this.inputElement.value || this.inputElement.min)); + if (typeof index === 'number') { + // valid + controller.goToMatch(index); + const currentMatch = controller.getState().currentMatch; + if (currentMatch) { + this.addDecorations(editor, currentMatch); + } + 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 abstract class SelectionMatchFindAction extends EditorAction { public async run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { const controller = CommonFindController.get(editor); @@ -1063,6 +1158,7 @@ registerEditorContribution(CommonFindController.ID, FindController, EditorContri registerEditorAction(StartFindWithArgsAction); registerEditorAction(StartFindWithSelectionAction); registerEditorAction(MoveToMatchFindAction); +registerEditorAction(MoveToEditableNthMatchFindAction); 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..601c60e454c8d 100644 --- a/src/vs/editor/contrib/find/browser/findModel.ts +++ b/src/vs/editor/contrib/find/browser/findModel.ts @@ -28,13 +28,14 @@ import { IKeybindings } from '../../../../platform/keybinding/common/keybindings 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 +65,7 @@ export const FIND_IDS = { NextMatchFindAction: 'editor.action.nextMatchFindAction', PreviousMatchFindAction: 'editor.action.previousMatchFindAction', GoToMatchFindAction: 'editor.action.goToMatchFindAction', + GoToEditableNthMatchFindAction: 'editor.action.goToEditableNthMatchFindAction', NextSelectionMatchFindAction: 'editor.action.nextSelectionMatchFindAction', PreviousSelectionMatchFindAction: 'editor.action.previousSelectionMatchFindAction', StartFindReplaceAction: 'editor.action.startFindReplaceAction', diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index 03e698c3e8777..a2e731c547b93 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -11,6 +11,7 @@ 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 { 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'; @@ -132,6 +133,7 @@ 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; @@ -463,7 +465,13 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } // remove previous content - this._matchesCount.firstChild?.remove(); + if (this._matchesCount.childNodes.length > 1) { + [...this._matchesCount.childNodes].forEach(x => x.parentNode?.removeChild(x)); + } + else { + this._matchesCount.firstChild?.remove(); + } + let label: string; if (this._state.matchesCount > 0) { @@ -476,18 +484,75 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL matchesPosition = '?'; } label = strings.format(NLS_MATCHES_LOCATION, matchesPosition, matchesCount); + + this._nthMatchInput.setValue(`${this._state.matchesPosition}`); + this._nthMatchInput.min = this._state.matchesCount >= 1 ? 1 : 0; + this._nthMatchInput.max = this._state.matchesCount; + + this._matchesCount.appendChild(this._nthMatchInput.domNode); + this._matchesCount.appendChild(document.createTextNode(' of ')); + this._matchesCount.appendChild(document.createTextNode(`${this._state.matchesCount}`)); + } else { label = NLS_NO_RESULTS; + 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); } // ----- actions + + private getNthMatchInput(): NthMatchInput { + const input = new NthMatchInput(this._domNode, this._contextViewProvider, { + placeholder: '', + width: 20, + validation: undefined, + label: '', + type: 'text', + min: this._state.matchesCount >= 1 ? 1 : 0, + max: this._state.matchesCount, + flexibleHeight: undefined, + flexibleWidth: undefined, + flexibleMaxHeight: undefined, + toggleStyles: defaultToggleStyles, + inputBoxStyles: defaultInputBoxStyles, + }); + + this._register(input.onStep((e) => { + if (e.direction === 'up') { + assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.NextMatchFindAction)).run().then(undefined, onUnexpectedError); + } + else { + assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction)).run().then(undefined, onUnexpectedError); + } + input.focus(); + })); + + this._register(input.onJump((e) => { + assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.GoToEditableNthMatchFindAction)).run().then(undefined, onUnexpectedError); + input.focus(); + })); + + this._register(input.onInput((e) => { + const currentValueAsInt = parseInt(input.getValue()); + // Enforce the numerical input and min/max constraints here. + input.setValue( + isNaN(currentValueAsInt) ? + `${input.min}` : currentValueAsInt > input.max ? + `${input.max}` : currentValueAsInt < input.min ? + `${input.min}` : `${currentValueAsInt}` + ); + })); + + input.domNode.classList.add(...['monaco-inputbox', 'editable-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) { @@ -1064,6 +1129,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._matchesCount = document.createElement('div'); this._matchesCount.className = 'matchesCount'; + this._nthMatchInput = this.getNthMatchInput(); this._updateMatchesCount(); const hoverLifecycleOptions: IHoverLifecycleOptions = { groupId: 'find-widget' }; From e607e3e3b6b2f09e1ded1c29bd220d0e0e84a896 Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Wed, 8 Apr 2026 11:08:55 -0400 Subject: [PATCH 09/15] 11.2.0 --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9af8f0b3bd732..bcc5ae1f0c75f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-oss-dev", - "version": "1.116.0", + "version": "11.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.116.0", + "version": "11.2.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4c25e394d11e1..cf32d32ee5e48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-oss-dev", - "version": "1.116.0", + "version": "11.2.0", "distro": "a265239e421aed1dad7d0db84c68f905ebe9096e", "author": { "name": "Microsoft Corporation" @@ -266,4 +266,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} From ee61f36975062bc9067906d23e2b9457d5a6e70e Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Thu, 9 Apr 2026 16:53:53 -0400 Subject: [PATCH 10/15] findWidget / terminalFindWidget: Stress Testing - On input/change, update the width of `nthMatchInput` to fit the length of its value - Test the editor and terminal finders at their pre-defined `MATCHES_LIMIT` of `19999` matches. --- .../browser/ui/findinput/nthMatchInput.css | 12 ++++++++- .../browser/ui/findinput/nthMatchInput.ts | 25 +++++++++++++++++-- .../editor/contrib/find/browser/findWidget.ts | 20 +++++++++------ .../features/browserEditorFindFeature.ts | 10 ++++++++ .../browser/find/simpleFindWidget.css | 3 +-- .../browser/find/simpleFindWidget.ts | 18 +++++++++---- .../contrib/terminal/browser/terminal.ts | 2 +- 7 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.css b/src/vs/base/browser/ui/findinput/nthMatchInput.css index 279dcda6e6a39..edd52cace5b27 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.css +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.css @@ -10,12 +10,17 @@ display: block !important; width: 45px; min-width: 45px; - max-width: 45px; + max-width: 60px; background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border, transparent); bottom: 2px; margin-right: 0.5em; + transition: width 300ms ease; +} + +.monaco-editor .find-widget .matchesCount .editable-nth-match.elongated { + width: 60px; } .monaco-editor .find-widget .matchesCount .editable-nth-match input { @@ -29,6 +34,11 @@ .monaco-findInput.monaco-inputbox.editable-nth-match { width: 45px; margin-right: 0.5em; + transition: width 300ms ease; +} + +.monaco-findInput.monaco-inputbox.editable-nth-match.elongated { + width: 60px; } .monaco-findInput.monaco-inputbox.editable-nth-match input { diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.ts b/src/vs/base/browser/ui/findinput/nthMatchInput.ts index 2cf48295442b9..f0e2a495b322f 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.ts +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.ts @@ -14,6 +14,7 @@ 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 '../../../../editor/contrib/find/browser/findModel.js'; export interface INthMatchInputOptions { readonly placeholder?: string; @@ -81,8 +82,8 @@ export class NthMatchInput extends Widget { this.placeholder = options.placeholder || ''; this.label = options.label || NLS_DEFAULT_LABEL; this.type = options.type || 'text'; - this.min = options.min || 0; - this.max = options.max || 0; + this.min = options.min || 1; + this.max = options.max || MATCHES_LIMIT; this.lastMatchLocation = options.lastMatchLocation || 0; const flexibleHeight = !!options.flexibleHeight; const flexibleWidth = !!options.flexibleWidth; @@ -117,6 +118,10 @@ export class NthMatchInput extends Widget { }); + this.onchange(this.domNode, () => { + this.updateInputWrapperWidth(); + }); + parent?.appendChild(this.domNode); this._register(dom.addDisposableListener(this.inputBox.inputElement, 'compositionstart', (e: CompositionEvent) => { @@ -131,6 +136,8 @@ export class NthMatchInput extends Widget { 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)); + + this.updateInputWrapperWidth(); } public get isImeSessionInProgress(): boolean { @@ -144,6 +151,20 @@ export class NthMatchInput extends Widget { public layout(style: { collapsedFindWidget: boolean; narrowFindWidget: boolean; reducedFindWidget: boolean }) { this.inputBox.layout(); this.updateInputBoxPadding(style.collapsedFindWidget); + // this.updateInputWrapperWidth(); + } + + public updateInputWrapperWidth() { + const currentInputValue = this.getValue(); + + // Increase the input width when the character count inside exceeds 4. + // Restore the orignal input width when the character count falls below 3. + if ((currentInputValue.length >= 4)) { + (this.inputBox.element.parentElement as HTMLElement).classList.add(...['elongated']); + } + else if (currentInputValue.length <= 3) { + (this.inputBox.element.parentElement as HTMLElement).classList.remove(...['elongated']); + } } public enable(): void { diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index 1dd874a17303f..875f2ebba69e5 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -288,7 +288,6 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._nthMatchInputFocusTracker = this._register(dom.trackFocus(this._nthMatchInput.inputBox.inputElement)); this._register(this._nthMatchInputFocusTracker.onDidFocus(() => { this._nthMatchInputFocused.set(true); - this._updateSearchScope(); })); this._register(this._nthMatchInputFocusTracker.onDidBlur(() => { this._nthMatchInputFocused.set(false); @@ -505,7 +504,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } label = strings.format(NLS_MATCHES_LOCATION, matchesPosition, matchesCount); - this._nthMatchInput.setValue(`${this._state.matchesPosition}`); + this._nthMatchInput.setValue(`${matchesPosition}`); this._nthMatchInput.min = this._state.matchesCount >= 1 ? 1 : 0; this._nthMatchInput.max = this._state.matchesCount; @@ -534,13 +533,18 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private getNthMatchInput(): NthMatchInput { + + const min = 1; + const max = this._state.matchesCount || MATCHES_LIMIT; + const input = new NthMatchInput(this._domNode, this._contextViewProvider, { - placeholder: 'N', + // placeholder: `Enter a number between ${min} and ${max}`, + placeholder: 'Jump to the target result.', width: 20, label: NLS_NTH_MATCH_INPUT_LABEL, type: 'text', - min: this._state.matchesCount >= 1 ? 1 : 0, - max: this._state.matchesCount, + min: min, + max: max, flexibleHeight: undefined, flexibleWidth: undefined, flexibleMaxHeight: undefined, @@ -555,12 +559,12 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL else { assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction)).run().then(undefined, onUnexpectedError); } - input.focus(); + this._nthMatchInput.updateInputWrapperWidth(); })); this._register(input.onJump((e) => { assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.GoToEditableNthMatchFindAction)).run().then(undefined, onUnexpectedError); - input.focus(); + this._nthMatchInput.updateInputWrapperWidth(); })); this._register(input.onInput((e) => { @@ -572,6 +576,8 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL `${input.max}` : currentValueAsInt < input.min ? `${input.min}` : `${currentValueAsInt}` ); + + this._nthMatchInput.updateInputWrapperWidth(); })); input.domNode.classList.add(...['monaco-inputbox', 'editable-nth-match']); 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..f60d96f38c0fd 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts @@ -192,6 +192,16 @@ class BrowserFindWidget extends SimpleFindWidget { protected _onFindInputFocusTrackerBlur(): void { // No-op } + + public override findNth(nthMatchPosition: number): void { + throw new Error('Method not implemented.'); + } + protected override _onNthMatchInputFocusTrackerFocus(): void { + throw new Error('Method not implemented.'); + } + protected override _onNthMatchInputFocusTrackerBlur(): void { + throw new Error('Method not implemented.'); + } } /** diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css index 8f04ade0c3e1b..a031d7ac51821 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css @@ -64,9 +64,8 @@ display: flex; align-items: center; justify-content: space-between; - width: 73px; - max-width: 73px; min-width: 73px; + max-width: 122px; padding-left: 5px; } diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts index dfb7eaa4993ca..583b4f3c5c222 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts @@ -30,6 +30,7 @@ import type { IHoverService } from '../../../../../platform/hover/browser/hover. 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'; +import { MATCHES_LIMIT } from '../../../../../editor/contrib/find/browser/findModel.js'; const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find"); const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find"); @@ -469,8 +470,8 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa } this._nthMatchInput.setValue(`${matchesPosition}`); - this._nthMatchInput.min = +matchesCount >= 1 ? 1 : 0; - this._nthMatchInput.max = +matchesCount; + this._nthMatchInput.min = 1; + this._nthMatchInput.max = +matchesCount || MATCHES_LIMIT; if (([...this._matchesCount.childNodes].length === 0)) { this._matchesCount.appendChild(this._nthMatchInput.domNode); @@ -543,14 +544,18 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa private getNthMatchInput(contextViewService: IContextViewService): NthMatchInput { const matchPositionAndCountArr = this._matchesCount?.innerText?.split(' of ') ?? []; + const trimmedCountStr = matchPositionAndCountArr[1]?.trim(); + + const min = 1; + const max = parseInt(trimmedCountStr) || this._matchesLimit; const input = new NthMatchInput(this._domNode, contextViewService, { - placeholder: '', + placeholder: 'Jump to the target result.', width: 20, label: NLS_NTH_MATCH_INPUT_LABEL, type: 'text', - min: parseInt(matchPositionAndCountArr[1]?.trim() || '0') >= 1 ? 1 : 0, - max: parseInt(matchPositionAndCountArr[1]?.trim()) || 0, + min: min, + max: max, flexibleHeight: undefined, flexibleWidth: undefined, flexibleMaxHeight: undefined, @@ -565,10 +570,12 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa else { this.find(true); } + input.updateInputWrapperWidth(); })); this._register(input.onJump((e) => { this.findNth(e.targetMatchPos || input.min); + input.updateInputWrapperWidth(); })); this._register(input.onInput((e) => { @@ -580,6 +587,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa `${input.max}` : currentValueAsInt < input.min ? `${input.min}` : `${currentValueAsInt}` ); + input.updateInputWrapperWidth(); })); input.domNode.classList.add(...['monaco-inputbox', 'editable-nth-match']); diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 4e2313024be14..aacf1ddb39765 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1300,7 +1300,7 @@ export interface ITerminalInstance extends IBaseTerminalInstance { } export const enum XtermTerminalConstants { - SearchHighlightLimit = 20000 + SearchHighlightLimit = 19999 } export interface IXtermAttachToElementOptions { From 2ed6713572c0b23d53cc80046bfb16e733979d55 Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Mon, 13 Apr 2026 20:43:34 -0400 Subject: [PATCH 11/15] findWidget / terminalFindWidget: More alignment - `findWidget`: Refer to `nthMatchInput`\'s `domNode` like the surrounding `focusTracker`s do with their respective controls. - `terminalFindWidget`: Investigate coupling of `themeService.onDidthemeColorChange` events to the `find(previous: boolean)` call . The normal `findWidget` has no such coupling. Is this intentional? --- src/vs/editor/contrib/find/browser/findWidget.ts | 5 ++--- .../terminalContrib/find/browser/terminalFindWidget.ts | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index 875f2ebba69e5..1aa4dbe4a8dbe 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -285,7 +285,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } })); this._nthMatchInputFocused = CONTEXT_NTH_MATCH_INPUT_FOCUSED.bindTo(contextKeyService); - this._nthMatchInputFocusTracker = this._register(dom.trackFocus(this._nthMatchInput.inputBox.inputElement)); + this._nthMatchInputFocusTracker = this._register(dom.trackFocus(this._nthMatchInput.domNode)); this._register(this._nthMatchInputFocusTracker.onDidFocus(() => { this._nthMatchInputFocused.set(true); })); @@ -538,8 +538,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL const max = this._state.matchesCount || MATCHES_LIMIT; const input = new NthMatchInput(this._domNode, this._contextViewProvider, { - // placeholder: `Enter a number between ${min} and ${max}`, - placeholder: 'Jump to the target result.', + placeholder: 'Jump to the result at the given position.', width: 20, label: NLS_NTH_MATCH_INPUT_LABEL, type: 'text', diff --git a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts index 8a67a916af014..e726d5f23bb03 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts @@ -96,6 +96,8 @@ export class TerminalFindWidget extends SimpleFindWidget { })); this._register(themeService.onDidColorThemeChange(() => { if (this.isVisible()) { + // Does the match cursor need to jump to the previous instance when the theme changes? + // The normal findWidget doesn't behave this way. this.find(true, true); } })); From 8899bf02fff3996c5b5d9f74e85a40b8a17b3472 Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Wed, 15 Apr 2026 11:35:59 -0400 Subject: [PATCH 12/15] findWidget / terminalFindWidget: Consolidate nthMatchInput styles and normalize placeholder text --- .../browser/ui/findinput/nthMatchInput.css | 26 ++++++------------- .../browser/find/simpleFindWidget.ts | 2 +- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.css b/src/vs/base/browser/ui/findinput/nthMatchInput.css index edd52cace5b27..ebdb78de5faf7 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.css +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.css @@ -3,27 +3,29 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* ---------- Nth Match Editor Input Box ---------- */ +/* ---------- Nth Match Editor & Terminal Input Boxes ---------- */ -.monaco-editor .find-widget .matchesCount .editable-nth-match { +.monaco-editor .find-widget .matchesCount .editable-nth-match, +.monaco-findInput.monaco-inputbox.editable-nth-match { display: block !important; width: 45px; min-width: 45px; max-width: 60px; background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); - border: 1px solid var(--vscode-input-border, transparent); bottom: 2px; margin-right: 0.5em; transition: width 300ms ease; } -.monaco-editor .find-widget .matchesCount .editable-nth-match.elongated { +.monaco-editor .find-widget .matchesCount .editable-nth-match.elongated, +.monaco-findInput.monaco-inputbox.editable-nth-match.elongated { width: 60px; } -.monaco-editor .find-widget .matchesCount .editable-nth-match input { +.monaco-editor .find-widget .matchesCount .editable-nth-match input, +.monaco-findInput.monaco-inputbox.editable-nth-match input { text-align: center; overflow-x: hidden; text-overflow: clip; @@ -32,17 +34,5 @@ /* ---------- Nth Match Terminal Input Box ---------- */ .monaco-findInput.monaco-inputbox.editable-nth-match { - width: 45px; - margin-right: 0.5em; - transition: width 300ms ease; -} - -.monaco-findInput.monaco-inputbox.editable-nth-match.elongated { - width: 60px; -} - -.monaco-findInput.monaco-inputbox.editable-nth-match input { - text-align: center; - overflow-x: hidden; - text-overflow: clip; + bottom: unset; } diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts index 583b4f3c5c222..ce4f5836f909d 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts @@ -550,7 +550,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa const max = parseInt(trimmedCountStr) || this._matchesLimit; const input = new NthMatchInput(this._domNode, contextViewService, { - placeholder: 'Jump to the target result.', + placeholder: 'Jump to the result at the given position.', width: 20, label: NLS_NTH_MATCH_INPUT_LABEL, type: 'text', From 1e582d2a7e59d83a7539347d7f9caa18d867f397 Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Mon, 11 May 2026 12:11:19 -0400 Subject: [PATCH 13/15] findWidget/terminalFindWidget: More enhancements - Consolidate and reuse input sanitization logic. - For ease of use, update the match highlight immediately when the input changes (any keydown event, as opossed to an explicit Enter keydown) - Separate placeholder text from tooltip text. - Mitigate coupling of terminal's themeChange event to find(...) call. --- .../browser/ui/findinput/nthMatchInput.ts | 18 +++++++++++++++ .../editor/contrib/find/browser/findWidget.ts | 19 +++++++-------- .../browser/find/simpleFindWidget.ts | 23 +++++++++---------- .../find/browser/terminalFindWidget.ts | 8 ++++--- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.ts b/src/vs/base/browser/ui/findinput/nthMatchInput.ts index f0e2a495b322f..427a9a4f57810 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.ts +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.ts @@ -18,6 +18,7 @@ import { MATCHES_LIMIT } from '../../../../editor/contrib/find/browser/findModel export interface INthMatchInputOptions { readonly placeholder?: string; + readonly tooltip?: string; readonly width?: number; readonly label: string; readonly type: 'text'; @@ -46,6 +47,7 @@ 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; @@ -80,6 +82,7 @@ export class NthMatchInput extends Widget { 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; @@ -94,6 +97,7 @@ export class NthMatchInput extends Widget { this.inputBox = this._register(new InputBox(this.domNode, contextViewProvider, { placeholder: this.placeholder || '', + tooltip: this.tooltip || '', ariaLabel: this.label || '', flexibleHeight, flexibleWidth, @@ -233,3 +237,17 @@ export class NthMatchInput extends Widget { this.inputBox.hideMessage(); } } + +export function getSanitizedInputValue(input: NthMatchInput): number { + // Enforce the numerical input and min/max constraints here. + if (!input || !input.getValue()) { + return 1; + } + + const currentValueAsInt = parseInt(input.getValue(), 10); + return isNaN(currentValueAsInt) ? + input.min : currentValueAsInt > input.max ? + input.max : currentValueAsInt < input.min ? + input.min : currentValueAsInt; +} + diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index 1aa4dbe4a8dbe..2d496dd64d695 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -11,7 +11,7 @@ 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 { NthMatchInput, getSanitizedInputValue } from '../../../../base/browser/ui/findinput/nthMatchInput.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'; @@ -538,7 +538,8 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL const max = this._state.matchesCount || MATCHES_LIMIT; const input = new NthMatchInput(this._domNode, this._contextViewProvider, { - placeholder: 'Jump to the result at the given position.', + placeholder: 'N', + tooltip: 'Jump to the Nth result.', width: 20, label: NLS_NTH_MATCH_INPUT_LABEL, type: 'text', @@ -567,15 +568,11 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL })); this._register(input.onInput((e) => { - const currentValueAsInt = parseInt(input.getValue()); - // Enforce the numerical input and min/max constraints here. - input.setValue( - isNaN(currentValueAsInt) ? - `${input.min}` : currentValueAsInt > input.max ? - `${input.max}` : currentValueAsInt < input.min ? - `${input.min}` : `${currentValueAsInt}` - ); - + if (!input.getValue()) { + return; + } + this._nthMatchInput.setValue(`${getSanitizedInputValue(this._nthMatchInput)}`); + assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.GoToEditableNthMatchFindAction)).run().then(undefined, onUnexpectedError); this._nthMatchInput.updateInputWrapperWidth(); })); diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts index ce4f5836f909d..132b04d1df3f1 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts @@ -7,7 +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 { NthMatchInput, getSanitizedInputValue } 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'; @@ -61,7 +61,7 @@ const MATCHES_COUNT_WIDTH = 73; export abstract class SimpleFindWidget extends Widget implements IVerticalSashLayoutProvider { private readonly _findInput: FindInput; - private readonly _nthMatchInput: NthMatchInput; + public readonly _nthMatchInput: NthMatchInput; private readonly _domNode: HTMLElement; private readonly _innerDomNode: HTMLElement; private readonly _focusTracker: dom.IFocusTracker; @@ -550,9 +550,10 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa const max = parseInt(trimmedCountStr) || this._matchesLimit; const input = new NthMatchInput(this._domNode, contextViewService, { - placeholder: 'Jump to the result at the given position.', - width: 20, + placeholder: 'N', + tooltip: 'Jump to the Nth result.', label: NLS_NTH_MATCH_INPUT_LABEL, + width: 20, type: 'text', min: min, max: max, @@ -579,14 +580,12 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa })); this._register(input.onInput((e) => { - const currentValueAsInt = parseInt(input.getValue()); - // Enforce the numerical input and min/max constraints here. - input.setValue( - isNaN(currentValueAsInt) ? - `${input.min}` : currentValueAsInt > input.max ? - `${input.max}` : currentValueAsInt < input.min ? - `${input.min}` : `${currentValueAsInt}` - ); + if (!input.getValue()) { + return; + } + const inputValueAsSanitizedInt = getSanitizedInputValue(input); + input.setValue(`${inputValueAsSanitizedInt}`); + this.findNth(inputValueAsSanitizedInt); input.updateInputWrapperWidth(); })); diff --git a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts index e726d5f23bb03..14cdedb404028 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts @@ -23,6 +23,7 @@ import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { createTextInputActions } from '../../../../browser/actions/textInputActions.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { getSanitizedInputValue } from '../../../../../base/browser/ui/findinput/nthMatchInput.js'; const TERMINAL_FIND_WIDGET_INITIAL_WIDTH = 419; @@ -96,9 +97,10 @@ export class TerminalFindWidget extends SimpleFindWidget { })); this._register(themeService.onDidColorThemeChange(() => { if (this.isVisible()) { - // Does the match cursor need to jump to the previous instance when the theme changes? - // The normal findWidget doesn't behave this way. - 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 @xterm's decoration logic. + this.findNth(getSanitizedInputValue(this._nthMatchInput)); } })); this._register(configurationService.onDidChangeConfiguration((e) => { From 173ac8a62662a588ede05b95252c6c706d9d0e33 Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Wed, 13 May 2026 11:29:05 -0400 Subject: [PATCH 14/15] findWidget/terminalFindWidget: Last Match Button - Implement proof-of-concept. - Jump to last highlighted match on button click. - Remove nthMatchInput `onJump` event since all transformative keydown events now trigger an implicit jump. - Start localizing hard-coded strings. - Before changes for Copilot code review --- .../browser/ui/findinput/nthMatchInput.css | 6 +- .../browser/ui/findinput/nthMatchInput.ts | 63 +++++-------- .../contrib/find/browser/findController.ts | 91 +++++++++++++++++++ .../editor/contrib/find/browser/findModel.ts | 1 + .../contrib/find/browser/findWidget.css | 32 ++++--- .../editor/contrib/find/browser/findWidget.ts | 51 +++++++---- .../browser/find/simpleFindWidget.css | 16 +++- .../browser/find/simpleFindWidget.ts | 47 ++++++---- .../find/browser/terminalFindWidget.ts | 5 +- 9 files changed, 219 insertions(+), 93 deletions(-) diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.css b/src/vs/base/browser/ui/findinput/nthMatchInput.css index ebdb78de5faf7..d3361693f86ea 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.css +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.css @@ -8,14 +8,13 @@ .monaco-editor .find-widget .matchesCount .editable-nth-match, .monaco-findInput.monaco-inputbox.editable-nth-match { - display: block !important; width: 45px; min-width: 45px; max-width: 60px; background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); - bottom: 2px; - margin-right: 0.5em; + bottom: 1.5px; + margin-right: 10px; transition: width 300ms ease; } @@ -29,6 +28,7 @@ text-align: center; overflow-x: hidden; text-overflow: clip; + font-size: 12px; } diff --git a/src/vs/base/browser/ui/findinput/nthMatchInput.ts b/src/vs/base/browser/ui/findinput/nthMatchInput.ts index 427a9a4f57810..c7b56cb2f61f7 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.ts +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.ts @@ -24,7 +24,6 @@ export interface INthMatchInputOptions { readonly type: 'text'; readonly min?: number; readonly max?: number; - readonly lastMatchLocation?: number; readonly flexibleHeight?: boolean; readonly flexibleWidth?: boolean; readonly flexibleMaxHeight?: number; @@ -38,10 +37,6 @@ export interface IStepEvent { direction: 'up' | 'down'; } -export interface IJumpEvent { - targetMatchPos: number; -} - const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); export class NthMatchInput extends Widget { @@ -54,7 +49,6 @@ export class NthMatchInput extends Widget { public readonly domNode: HTMLElement; public readonly inputBox: InputBox; - public lastMatchLocation: number; public min: number; public max: number; @@ -76,8 +70,6 @@ export class NthMatchInput extends Widget { private readonly _onStep = this._register(new Emitter()); public readonly onStep: Event = this._onStep.event; - private readonly _onJump = this._register(new Emitter()); - public readonly onJump: Event = this._onJump.event; constructor(parent: HTMLElement | null, contextViewProvider: IContextViewProvider | undefined, options: INthMatchInputOptions) { super(); @@ -87,7 +79,6 @@ export class NthMatchInput extends Widget { this.type = options.type || 'text'; this.min = options.min || 1; this.max = options.max || MATCHES_LIMIT; - this.lastMatchLocation = options.lastMatchLocation || 0; const flexibleHeight = !!options.flexibleHeight; const flexibleWidth = !!options.flexibleWidth; const flexibleMaxHeight = options.flexibleMaxHeight; @@ -107,8 +98,6 @@ export class NthMatchInput extends Widget { })); this.onkeydown(this.domNode, (event: IKeyboardEvent) => { - const currentValueAsInt = parseInt(this.inputBox.value); - // Arrow-Key support to step the match position up or down if (event.equals(KeyCode.UpArrow)) { this._onStep.fire({ direction: 'down' }); @@ -116,10 +105,6 @@ export class NthMatchInput extends Widget { else if (event.equals(KeyCode.DownArrow)) { this._onStep.fire({ direction: 'up' }); } - else if (event.equals(KeyCode.Enter)) { - this._onJump.fire({ targetMatchPos: currentValueAsInt }); - } - }); this.onchange(this.domNode, () => { @@ -140,8 +125,6 @@ export class NthMatchInput extends Widget { 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)); - - this.updateInputWrapperWidth(); } public get isImeSessionInProgress(): boolean { @@ -155,19 +138,23 @@ export class NthMatchInput extends Widget { public layout(style: { collapsedFindWidget: boolean; narrowFindWidget: boolean; reducedFindWidget: boolean }) { this.inputBox.layout(); this.updateInputBoxPadding(style.collapsedFindWidget); - // this.updateInputWrapperWidth(); + this.updateInputWrapperWidth(); } public updateInputWrapperWidth() { - const currentInputValue = this.getValue(); - - // Increase the input width when the character count inside exceeds 4. - // Restore the orignal input width when the character count falls below 3. - if ((currentInputValue.length >= 4)) { - (this.inputBox.element.parentElement as HTMLElement).classList.add(...['elongated']); + const currentInputValue = `${this.getSanitizedCurrentValue()}`; + const containerElem = (this.inputBox.element.parentElement as HTMLElement); + console.log('nthMatchInput.updateInputWrapperWidth() ---> currentInputValue', currentInputValue); + if ((currentInputValue.length >= 5)) { + if (!containerElem.classList.contains('elongated')) { + containerElem.classList.add(...['elongated']); + } } - else if (currentInputValue.length <= 3) { - (this.inputBox.element.parentElement as HTMLElement).classList.remove(...['elongated']); + else if (currentInputValue.length <= 4) { + console.log('nthMatchInput.updateInputWrapperWidth() ---> length <= 3 ---> classList AFTER classList change', containerElem?.classList); + if (containerElem.classList.contains('elongated')) { + containerElem.classList.remove(...['elongated']); + } } } @@ -211,6 +198,7 @@ export class NthMatchInput extends Widget { if (this.inputBox.value !== value) { this.inputBox.value = value; } + this.updateInputWrapperWidth(); } public select(): void { @@ -236,18 +224,17 @@ export class NthMatchInput extends Widget { private clearValidation(): void { this.inputBox.hideMessage(); } -} -export function getSanitizedInputValue(input: NthMatchInput): number { - // Enforce the numerical input and min/max constraints here. - if (!input || !input.getValue()) { - return 1; - } + public getSanitizedCurrentValue(): number { + if (!this || !this.getValue()) { + return this.min; + } - const currentValueAsInt = parseInt(input.getValue(), 10); - return isNaN(currentValueAsInt) ? - input.min : currentValueAsInt > input.max ? - input.max : currentValueAsInt < input.min ? - input.min : currentValueAsInt; + // 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 6c29a702d234f..a89fb2c1ab414 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -1026,6 +1026,96 @@ export class MoveToEditableNthMatchFindAction extends EditorAction { } } +export class MoveToLastMatchFindAction extends EditorAction { + + private _highlightDecorations: string[] = []; + + constructor() { + super({ + id: FIND_IDS.GoToLastMatchFindAction, + label: nls.localize('findMatchAction.goToLastMatchFindAction', "Go to Last Match..."), + alias: 'Go to Last Match...', + precondition: CONTEXT_FIND_WIDGET_VISIBLE + }); + } + + public run(accessor: ServicesAccessor, editor: ICodeEditor, args: any): void | Promise { + const controller = CommonFindController.get(editor); + if (!controller) { + 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; + } + + if (index > 0 && index <= matchesCount) { + return index - 1; // zero based + } else if (index < 0 && index >= -matchesCount) { + return matchesCount + index; + } + + return undefined; + }; + + const index = toFindMatchIndex(`${controller.getState().matchesCount}`); + if (typeof index === 'number') { + // valid + controller.goToMatch(index); + const currentMatch = controller.getState().currentMatch; + if (currentMatch) { + this.addDecorations(editor, currentMatch); + } + 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 abstract class SelectionMatchFindAction extends EditorAction { public async run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { @@ -1159,6 +1249,7 @@ registerEditorAction(StartFindWithArgsAction); registerEditorAction(StartFindWithSelectionAction); registerEditorAction(MoveToMatchFindAction); registerEditorAction(MoveToEditableNthMatchFindAction); +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 601c60e454c8d..029209998d31d 100644 --- a/src/vs/editor/contrib/find/browser/findModel.ts +++ b/src/vs/editor/contrib/find/browser/findModel.ts @@ -66,6 +66,7 @@ export const FIND_IDS = { PreviousMatchFindAction: 'editor.action.previousMatchFindAction', GoToMatchFindAction: 'editor.action.goToMatchFindAction', GoToEditableNthMatchFindAction: 'editor.action.goToEditableNthMatchFindAction', + GoToLastMatchFindAction: 'editor.action.goToLastMatchFindAction', NextSelectionMatchFindAction: 'editor.action.nextSelectionMatchFindAction', PreviousSelectionMatchFindAction: 'editor.action.previousSelectionMatchFindAction', StartFindReplaceAction: 'editor.action.startFindReplaceAction', 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 2d496dd64d695..b3739ea0fc4ce 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -11,7 +11,7 @@ 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, getSanitizedInputValue } from '../../../../base/browser/ui/findinput/nthMatchInput.js'; +import { NthMatchInput } from '../../../../base/browser/ui/findinput/nthMatchInput.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'; @@ -65,9 +65,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_NEXT_MATCH_BTN_LABEL = nls.localize('label.nextMatchButton', "Next 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_JUMP_TO_LAST_MATCH_BTN_LABEL = nls.localize('label.lastMatch', "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"); @@ -138,6 +140,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL private _toggleReplaceBtn!: SimpleButton; private _matchesCount!: HTMLElement; + private _lastMatchBtn!: SimpleButton; private _prevBtn!: SimpleButton; private _nextBtn!: SimpleButton; private _toggleSelectionFind!: Toggle; @@ -507,14 +510,12 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL 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(' of ')); - this._matchesCount.appendChild(document.createTextNode(`${matchesCount}`)); - } - else { - (this._matchesCount.lastChild as Node).nodeValue = `${matchesCount}`; + this._matchesCount.appendChild(this._lastMatchBtn.domNode); } } else { @@ -538,8 +539,8 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL const max = this._state.matchesCount || MATCHES_LIMIT; const input = new NthMatchInput(this._domNode, this._contextViewProvider, { - placeholder: 'N', - tooltip: 'Jump to the Nth result.', + placeholder: NLS_NTH_MATCH_INPUT_PLACEHOLDER, + tooltip: NLS_NTH_MATCH_INPUT_LABEL, width: 20, label: NLS_NTH_MATCH_INPUT_LABEL, type: 'text', @@ -559,21 +560,15 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL else { assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction)).run().then(undefined, onUnexpectedError); } - this._nthMatchInput.updateInputWrapperWidth(); - })); - - this._register(input.onJump((e) => { - assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.GoToEditableNthMatchFindAction)).run().then(undefined, onUnexpectedError); - this._nthMatchInput.updateInputWrapperWidth(); + input.updateInputWrapperWidth(); })); this._register(input.onInput((e) => { if (!input.getValue()) { return; } - this._nthMatchInput.setValue(`${getSanitizedInputValue(this._nthMatchInput)}`); + input.setValue(`${input.getSanitizedCurrentValue()}`); assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.GoToEditableNthMatchFindAction)).run().then(undefined, onUnexpectedError); - this._nthMatchInput.updateInputWrapperWidth(); })); input.domNode.classList.add(...['monaco-inputbox', 'editable-nth-match']); @@ -628,8 +623,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); @@ -671,6 +668,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } this._tryUpdateWidgetWidth(); + this._updateMatchesCount(); this._updateButtons(); this._revealTimeouts.push(setTimeout(() => { @@ -1117,12 +1115,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({ @@ -1130,6 +1130,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)) { @@ -1158,11 +1159,23 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._matchesCount = document.createElement('div'); this._matchesCount.className = 'matchesCount'; - this._nthMatchInput = this.getNthMatchInput(); this._updateMatchesCount(); + this._nthMatchInput = this.getNthMatchInput(); + const hoverLifecycleOptions: IHoverLifecycleOptions = { groupId: 'find-widget' }; + this._lastMatchBtn = this._register(new SimpleButton({ + label: `${NLS_JUMP_TO_LAST_MATCH_BTN_LABEL} ${this._keybindingLabelFor(FIND_IDS.GoToLastMatchFindAction)}`, + hoverLifecycleOptions, + onTrigger: () => { + assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.GoToLastMatchFindAction)).run().then(undefined, onUnexpectedError); + this._nthMatchInput.setValue(`${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/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css index a031d7ac51821..992130739e329 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css @@ -58,15 +58,25 @@ } .monaco-workbench .simple-find-part .matchesCount { - display: flex; - align-items: center; - justify-content: space-between; display: flex; align-items: center; justify-content: space-between; min-width: 73px; max-width: 122px; 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 132b04d1df3f1..1136f0cca33db 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts @@ -7,7 +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, getSanitizedInputValue } from '../../../../../base/browser/ui/findinput/nthMatchInput.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'; @@ -30,11 +30,13 @@ import type { IHoverService } from '../../../../../platform/hover/browser/hover. 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'; -import { MATCHES_LIMIT } from '../../../../../editor/contrib/find/browser/findModel.js'; +import { FIND_IDS, MATCHES_LIMIT } from '../../../../../editor/contrib/find/browser/findModel.js'; 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', "Enter Nth Match"); +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_JUMP_TO_LAST_MATCH_BTN_LABEL = nls.localize('label.lastMatch', "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"); @@ -68,6 +70,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa 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; @@ -164,6 +167,18 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa const hoverLifecycleOptions: IHoverLifecycleOptions = { groupId: 'simple-find-widget' }; + this.lastMatchBtn = this._register(new SimpleButton({ + label: `${NLS_JUMP_TO_LAST_MATCH_BTN_LABEL} ${this._keybindingLabelFor(FIND_IDS.GoToLastMatchFindAction)}`, + hoverLifecycleOptions, + onTrigger: () => { + const countParts = this.lastMatchBtn.domNode.innerText?.split('+') || []; + const trueCount = parseInt(countParts[0]); + this.findNth(!isNaN(trueCount) ? trueCount : this._matchesLimit); + this._nthMatchInput.setValue(`${!isNaN(trueCount) ? trueCount : this._matchesLimit}`); + } + }, 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, @@ -233,6 +248,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 () => { @@ -422,6 +440,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); } @@ -472,14 +491,12 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa this._nthMatchInput.setValue(`${matchesPosition}`); this._nthMatchInput.min = 1; this._nthMatchInput.max = +matchesCount || MATCHES_LIMIT; + this.lastMatchBtn.domNode.innerText = `${matchesCount}`; if (([...this._matchesCount.childNodes].length === 0)) { this._matchesCount.appendChild(this._nthMatchInput.domNode); this._matchesCount.appendChild(document.createTextNode(' of ')); - this._matchesCount.appendChild(document.createTextNode(`${matchesCount}`)); - } - else { - (this._matchesCount.lastChild as Node).nodeValue = `${matchesCount}`; + this._matchesCount.appendChild(this.lastMatchBtn.domNode); } } else { @@ -550,8 +567,8 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa const max = parseInt(trimmedCountStr) || this._matchesLimit; const input = new NthMatchInput(this._domNode, contextViewService, { - placeholder: 'N', - tooltip: 'Jump to the Nth result.', + placeholder: NLS_NTH_MATCH_INPUT_PLACEHOLDER, + tooltip: NLS_NTH_MATCH_INPUT_LABEL, label: NLS_NTH_MATCH_INPUT_LABEL, width: 20, type: 'text', @@ -574,19 +591,13 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa input.updateInputWrapperWidth(); })); - this._register(input.onJump((e) => { - this.findNth(e.targetMatchPos || input.min); - input.updateInputWrapperWidth(); - })); - this._register(input.onInput((e) => { if (!input.getValue()) { return; } - const inputValueAsSanitizedInt = getSanitizedInputValue(input); + const inputValueAsSanitizedInt = input.getSanitizedCurrentValue(); input.setValue(`${inputValueAsSanitizedInt}`); this.findNth(inputValueAsSanitizedInt); - input.updateInputWrapperWidth(); })); input.domNode.classList.add(...['monaco-inputbox', 'editable-nth-match']); @@ -594,6 +605,10 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa return input; } + + private _keybindingLabelFor(actionId: string): string { + return this._keybindingService.appendKeybinding('', actionId); + } } 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/terminalContrib/find/browser/terminalFindWidget.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts index 14cdedb404028..958af45da9698 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts @@ -23,7 +23,6 @@ import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { createTextInputActions } from '../../../../browser/actions/textInputActions.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; -import { getSanitizedInputValue } from '../../../../../base/browser/ui/findinput/nthMatchInput.js'; const TERMINAL_FIND_WIDGET_INITIAL_WIDTH = 419; @@ -99,8 +98,8 @@ export class TerminalFindWidget extends SimpleFindWidget { if (this.isVisible()) { // Update the terminal theming while preserving the current match highlight. // Perform a trivial jump to the current match position, - // which should trigger @xterm's decoration logic. - this.findNth(getSanitizedInputValue(this._nthMatchInput)); + // which should trigger the terminal's decoration logic. + this.findNth(this._nthMatchInput.getSanitizedCurrentValue()); } })); this._register(configurationService.onDidChangeConfiguration((e) => { From 44a944a8f5d3f034b779fd7c8ea9fab0148cf890 Mon Sep 17 00:00:00 2001 From: Robert Dambreville Date: Sun, 17 May 2026 17:30:42 -0400 Subject: [PATCH 15/15] findWidget/terminalFindWidget: Code Review - 1st Iteration - Improve language for the stepping logic in `NthMatchInput`. - Rename `nthMatchPosition` to `n`. - Simplify variable names throughout. - Localize the ` of ` phrase between the `nthMatchInput` and `lastMatchButton` widgets. - Sanitize the `matchesCount` value before consuming it. - Export `MATCHES_LIMIT` from a shared, base layer. - Remove unused, nullable properties from `INthMatchInputOptions`. - Remove redundant css properties from `simpleFindWidget.css` - Use distinct css selectors for editor and terminal `NthMatchInput` widgets. - Pass N as an arg to `MoveToNthMatchFindAction` instead of querying the DOM. - Clear editor match decorations when search fails outright. - Remove "find nth" features from `WebViewFindWidget` and `BrowserEditorFindFeature` , and restore legacy find behavior by extending a `SimpleWebFindWidget` class instead of the enhanced `SimpleFindWidget`. - Roll `MoveToLastMatchFindAction` into `MoveToNthMatchFindAction` since the former is just a special case where N == `matchesCount` | `MATCHES_LIMIT`. - Add tests for Nth Match in the `findController` test suite. - Before implementing "find nth" in `NotebookFindWidget` --- .../base/browser/ui/findinput/findContants.ts | 1 + .../browser/ui/findinput/nthMatchInput.css | 16 +- .../browser/ui/findinput/nthMatchInput.ts | 25 +- .../contrib/find/browser/findController.ts | 123 +---- .../editor/contrib/find/browser/findModel.ts | 6 +- .../editor/contrib/find/browser/findState.ts | 2 +- .../editor/contrib/find/browser/findWidget.ts | 36 +- .../find/test/browser/findController.test.ts | 266 ++++++++- .../features/browserEditorFindFeature.ts | 13 +- .../browser/find/simpleFindWidget.css | 2 +- .../browser/find/simpleFindWidget.ts | 63 +-- .../browser/find/simpleWebFindWidget.css | 128 +++++ .../browser/find/simpleWebFindWidget.ts | 504 ++++++++++++++++++ .../contrib/find/notebookFindWidget.ts | 3 +- .../contrib/terminal/browser/terminal.ts | 2 +- .../find/browser/terminalFindWidget.ts | 11 +- .../webview/browser/webviewFindWidget.ts | 12 +- 17 files changed, 1002 insertions(+), 211 deletions(-) create mode 100644 src/vs/base/browser/ui/findinput/findContants.ts create mode 100644 src/vs/workbench/contrib/codeEditor/browser/find/simpleWebFindWidget.css create mode 100644 src/vs/workbench/contrib/codeEditor/browser/find/simpleWebFindWidget.ts 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 index d3361693f86ea..b321e857d0cf4 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.css +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.css @@ -3,11 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* ---------- Nth Match Editor & Terminal Input Boxes ---------- */ - -.monaco-editor .find-widget .matchesCount .editable-nth-match, -.monaco-findInput.monaco-inputbox.editable-nth-match { + /* ---------- Nth Match Input - FindWidget ---------- */ +.nth-match { width: 45px; min-width: 45px; max-width: 60px; @@ -18,13 +16,11 @@ transition: width 300ms ease; } -.monaco-editor .find-widget .matchesCount .editable-nth-match.elongated, -.monaco-findInput.monaco-inputbox.editable-nth-match.elongated { +.nth-match.elongated { width: 60px; } -.monaco-editor .find-widget .matchesCount .editable-nth-match input, -.monaco-findInput.monaco-inputbox.editable-nth-match input { +.nth-match input { text-align: center; overflow-x: hidden; text-overflow: clip; @@ -32,7 +28,7 @@ } -/* ---------- Nth Match Terminal Input Box ---------- */ -.monaco-findInput.monaco-inputbox.editable-nth-match { +/* ---------- 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 index c7b56cb2f61f7..5e6c165c06e76 100644 --- a/src/vs/base/browser/ui/findinput/nthMatchInput.ts +++ b/src/vs/base/browser/ui/findinput/nthMatchInput.ts @@ -6,7 +6,6 @@ import * as dom from '../../dom.js'; import { IKeyboardEvent } from '../../keyboardEvent.js'; import { IMouseEvent } from '../../mouseEvent.js'; -import { IToggleStyles } from '../toggle/toggle.js'; import { IContextViewProvider } from '../contextview/contextview.js'; import { InputBox, IInputBoxStyles, IMessage as InputBoxMessage } from '../inputbox/inputBox.js'; import { Widget } from '../widget.js'; @@ -14,27 +13,21 @@ 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 '../../../../editor/contrib/find/browser/findModel.js'; +import { MATCHES_LIMIT } from './findContants.js'; export interface INthMatchInputOptions { readonly placeholder?: string; readonly tooltip?: string; - readonly width?: number; readonly label: string; readonly type: 'text'; readonly min?: number; readonly max?: number; - readonly flexibleHeight?: boolean; - readonly flexibleWidth?: boolean; - readonly flexibleMaxHeight?: number; - readonly showCommonFindToggles?: boolean; - readonly toggleStyles: IToggleStyles; readonly inputBoxStyles: IInputBoxStyles; } export interface IStepEvent { - direction: 'up' | 'down'; + to: 'previous' | 'next'; } const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); @@ -79,9 +72,6 @@ export class NthMatchInput extends Widget { this.type = options.type || 'text'; this.min = options.min || 1; this.max = options.max || MATCHES_LIMIT; - const flexibleHeight = !!options.flexibleHeight; - const flexibleWidth = !!options.flexibleWidth; - const flexibleMaxHeight = options.flexibleMaxHeight; this.domNode = document.createElement('div'); this.domNode.classList.add('monaco-findInput'); @@ -90,20 +80,17 @@ export class NthMatchInput extends Widget { placeholder: this.placeholder || '', tooltip: this.tooltip || '', ariaLabel: this.label || '', - flexibleHeight, - flexibleWidth, - flexibleMaxHeight, inputBoxStyles: options.inputBoxStyles, type: this.type })); this.onkeydown(this.domNode, (event: IKeyboardEvent) => { - // Arrow-Key support to step the match position up or down + // Arrow-Key support for stepping to the previous match or to the next one. if (event.equals(KeyCode.UpArrow)) { - this._onStep.fire({ direction: 'down' }); + this._onStep.fire({ to: 'previous' }); } else if (event.equals(KeyCode.DownArrow)) { - this._onStep.fire({ direction: 'up' }); + this._onStep.fire({ to: 'next' }); } }); @@ -144,14 +131,12 @@ export class NthMatchInput extends Widget { public updateInputWrapperWidth() { const currentInputValue = `${this.getSanitizedCurrentValue()}`; const containerElem = (this.inputBox.element.parentElement as HTMLElement); - console.log('nthMatchInput.updateInputWrapperWidth() ---> currentInputValue', currentInputValue); if ((currentInputValue.length >= 5)) { if (!containerElem.classList.contains('elongated')) { containerElem.classList.add(...['elongated']); } } else if (currentInputValue.length <= 4) { - console.log('nthMatchInput.updateInputWrapperWidth() ---> length <= 3 ---> classList AFTER classList change', containerElem?.classList); if (containerElem.classList.contains('elongated')) { containerElem.classList.remove(...['elongated']); } diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index a89fb2c1ab414..487e4dff86e67 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -37,7 +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 { mainWindow } from '../../../../base/browser/window.js'; +import { MATCHES_LIMIT } from '../../../../base/browser/ui/findinput/findContants.js'; const SEARCH_STRING_MAX_LENGTH = 524288; @@ -933,24 +933,22 @@ export class MoveToMatchFindAction extends EditorAction { } } -export class MoveToEditableNthMatchFindAction extends EditorAction { +export class MoveToNthMatchFindAction extends EditorAction { - private _highlightDecorations: string[] = []; - private inputElement: HTMLInputElement | null | undefined; + protected _highlightDecorations: string[] = []; - constructor() { + constructor(id?: string, label?: string, alias?: string) { super({ - id: FIND_IDS.GoToEditableNthMatchFindAction, - label: nls.localize('findMatchAction.goToEditableNthMatch', "Go to Editable Nth Match..."), - alias: 'Go to Editable Nth Match...', + 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); - this.inputElement = mainWindow.document.querySelector('.editable-nth-match')?.querySelector('input') as HTMLInputElement; - if (!controller || !this.inputElement) { + if (!controller || !args?.n) { return; } @@ -974,13 +972,15 @@ export class MoveToEditableNthMatchFindAction extends EditorAction { if (index > 0 && index <= matchCount) { return index - 1; // zero based } else if (index < 0 && index >= -matchCount) { - return matchCount + index; + // Always clamp to the start if + // the index is out-of-bounds. + return 0; } return undefined; }; - const index = toFindMatchIndex((this.inputElement.value || this.inputElement.min)); + const index = toFindMatchIndex((args?.n || '1')); if (typeof index === 'number') { // valid controller.goToMatch(index); @@ -992,6 +992,9 @@ export class MoveToEditableNthMatchFindAction extends EditorAction { this.clearDecorations(editor); } } + else { + this.clearDecorations(editor); + } } private clearDecorations(editor: ICodeEditor): void { @@ -1026,97 +1029,23 @@ export class MoveToEditableNthMatchFindAction extends EditorAction { } } -export class MoveToLastMatchFindAction extends EditorAction { - - private _highlightDecorations: string[] = []; +export class MoveToLastMatchFindAction extends MoveToNthMatchFindAction { + protected override _highlightDecorations: string[] = []; constructor() { - super({ - id: FIND_IDS.GoToLastMatchFindAction, - label: nls.localize('findMatchAction.goToLastMatchFindAction', "Go to Last Match..."), - alias: 'Go to Last Match...', - precondition: CONTEXT_FIND_WIDGET_VISIBLE - }); + super( + FIND_IDS.LastMatchFindAction, + nls.localize('findMatchAction.goToLastMatchFindAction', "Go to Last Match..."), + 'Go to Last Match...' + ); } - public run(accessor: ServicesAccessor, editor: ICodeEditor, args: any): void | Promise { - const controller = CommonFindController.get(editor); - if (!controller) { - 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; - } - - if (index > 0 && index <= matchesCount) { - return index - 1; // zero based - } else if (index < 0 && index >= -matchesCount) { - return matchesCount + index; - } - - return undefined; - }; - - const index = toFindMatchIndex(`${controller.getState().matchesCount}`); - if (typeof index === 'number') { - // valid - controller.goToMatch(index); - const currentMatch = controller.getState().currentMatch; - if (currentMatch) { - this.addDecorations(editor, currentMatch); - } - 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 - } - } - } - ]); - }); + 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); @@ -1248,7 +1177,7 @@ registerEditorContribution(CommonFindController.ID, FindController, EditorContri registerEditorAction(StartFindWithArgsAction); registerEditorAction(StartFindWithSelectionAction); registerEditorAction(MoveToMatchFindAction); -registerEditorAction(MoveToEditableNthMatchFindAction); +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 029209998d31d..0cc22a84e12f7 100644 --- a/src/vs/editor/contrib/find/browser/findModel.ts +++ b/src/vs/editor/contrib/find/browser/findModel.ts @@ -24,6 +24,7 @@ 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(); @@ -65,8 +66,8 @@ export const FIND_IDS = { NextMatchFindAction: 'editor.action.nextMatchFindAction', PreviousMatchFindAction: 'editor.action.previousMatchFindAction', GoToMatchFindAction: 'editor.action.goToMatchFindAction', - GoToEditableNthMatchFindAction: 'editor.action.goToEditableNthMatchFindAction', - GoToLastMatchFindAction: 'editor.action.goToLastMatchFindAction', + NthMatchFindAction: 'editor.action.nthMatchFindAction', + LastMatchFindAction: 'editor.action.lastMatchFindAction', NextSelectionMatchFindAction: 'editor.action.nextSelectionMatchFindAction', PreviousSelectionMatchFindAction: 'editor.action.previousSelectionMatchFindAction', StartFindReplaceAction: 'editor.action.startFindReplaceAction', @@ -81,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.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index b3739ea0fc4ce..3425803c7215b 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -12,6 +12,7 @@ import { IContextViewProvider } from '../../../../base/browser/ui/contextview/co 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'; @@ -26,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_NTH_MATCH_INPUT_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'; @@ -67,7 +68,7 @@ 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.nthMatchInput', "Nth Match"); const NLS_NTH_MATCH_INPUT_PLACEHOLDER = nls.localize('placeholder.nthMatchEdit', "N"); -const NLS_JUMP_TO_LAST_MATCH_BTN_LABEL = nls.localize('label.lastMatch', "Last Highlighted Match"); +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"); @@ -79,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; @@ -514,7 +516,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL if (([...this._matchesCount.childNodes].length === 0)) { this._matchesCount.appendChild(this._nthMatchInput.domNode); - this._matchesCount.appendChild(document.createTextNode(' of ')); + this._matchesCount.appendChild(document.createTextNode(NLS_MATCHES_PREPOSITION)); this._matchesCount.appendChild(this._lastMatchBtn.domNode); } @@ -527,7 +529,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } 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 @@ -537,24 +539,19 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL 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, - tooltip: NLS_NTH_MATCH_INPUT_LABEL, - width: 20, - label: NLS_NTH_MATCH_INPUT_LABEL, type: 'text', min: min, max: max, - flexibleHeight: undefined, - flexibleWidth: undefined, - flexibleMaxHeight: undefined, - toggleStyles: defaultToggleStyles, inputBoxStyles: defaultInputBoxStyles, }); this._register(input.onStep((e) => { - if (e.direction === 'up') { + if (e.to === 'next') { assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.NextMatchFindAction)).run().then(undefined, onUnexpectedError); } else { @@ -567,11 +564,12 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL if (!input.getValue()) { return; } - input.setValue(`${input.getSanitizedCurrentValue()}`); - assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.GoToEditableNthMatchFindAction)).run().then(undefined, onUnexpectedError); + 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', 'editable-nth-match']); + input.domNode.classList.add(...['monaco-inputbox', 'nth-match', 'editor-nth-match']); input.setValue(`${this._state.matchesPosition}`); return input; @@ -1166,11 +1164,11 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL const hoverLifecycleOptions: IHoverLifecycleOptions = { groupId: 'find-widget' }; this._lastMatchBtn = this._register(new SimpleButton({ - label: `${NLS_JUMP_TO_LAST_MATCH_BTN_LABEL} ${this._keybindingLabelFor(FIND_IDS.GoToLastMatchFindAction)}`, + label: NLS_LAST_MATCH_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.LastMatchFindAction), hoverLifecycleOptions, onTrigger: () => { - assertReturnsDefined(this._codeEditor.getAction(FIND_IDS.GoToLastMatchFindAction)).run().then(undefined, onUnexpectedError); - this._nthMatchInput.setValue(`${this._state.matchesCount}`); + 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']); 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 f60d96f38c0fd..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; @@ -193,15 +193,6 @@ class BrowserFindWidget extends SimpleFindWidget { // No-op } - public override findNth(nthMatchPosition: number): void { - throw new Error('Method not implemented.'); - } - protected override _onNthMatchInputFocusTrackerFocus(): void { - throw new Error('Method not implemented.'); - } - protected override _onNthMatchInputFocusTrackerBlur(): void { - throw new Error('Method not implemented.'); - } } /** diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css index 992130739e329..042c4d5520477 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css @@ -62,7 +62,7 @@ align-items: center; justify-content: space-between; min-width: 73px; - max-width: 122px; + max-width: 150px; padding-left: 5px; margin-right: 5px; } diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts index 1136f0cca33db..b2948e1e1c0ec 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts @@ -14,12 +14,13 @@ 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 } from '../../../../../editor/contrib/find/browser/findWidget.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'; @@ -30,13 +31,13 @@ import type { IHoverService } from '../../../../../platform/hover/browser/hover. 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'; -import { FIND_IDS, MATCHES_LIMIT } from '../../../../../editor/contrib/find/browser/findModel.js'; 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_JUMP_TO_LAST_MATCH_BTN_LABEL = nls.localize('label.lastMatch', "Last Highlighted Match"); +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"); @@ -48,9 +49,10 @@ interface IFindOptions { appendCaseSensitiveActionId?: string; appendRegexActionId?: string; appendWholeWordsActionId?: string; + nthMatchActionId?: string; + lastMatchActionId?: string; previousMatchActionId?: string; nextMatchActionId?: string; - nthMatchActionId?: string; closeWidgetActionId?: string; matchesLimit?: number; type?: 'Terminal' | 'Webview'; @@ -129,7 +131,6 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa toggleStyles: defaultToggleStyles }, contextKeyService)); - this._nthMatchInput = this.getNthMatchInput(contextViewService); // Find History with update delayer this._updateHistoryDelayer = this._register(new Delayer(500)); @@ -167,14 +168,17 @@ 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_JUMP_TO_LAST_MATCH_BTN_LABEL} ${this._keybindingLabelFor(FIND_IDS.GoToLastMatchFindAction)}`, + 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]); - this.findNth(!isNaN(trueCount) ? trueCount : this._matchesLimit); - this._nthMatchInput.setValue(`${!isNaN(trueCount) ? trueCount : this._matchesLimit}`); + const n = !isNaN(trueCount) ? trueCount : this._matchesLimit; + this.findNth(n); + this._nthMatchInput.setValue(String(n)); } }, hoverService)); this.lastMatchBtn.domNode.classList.add(...['last-match-btn']); @@ -302,7 +306,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa public abstract find(previous: boolean): void; public abstract findFirst(): void; - public abstract findNth(nthMatchPosition: number): void; + public abstract findNth(n: number): void; protected abstract _onInputChanged(): boolean; protected abstract _onFocusTrackerFocus(): void; protected abstract _onFocusTrackerBlur(): void; @@ -488,14 +492,19 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa 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 = +matchesCount || MATCHES_LIMIT; + 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(' of ')); + this._matchesCount.appendChild(document.createTextNode(NLS_MATCHES_PREPOSITION)); this._matchesCount.appendChild(this.lastMatchBtn.domNode); } @@ -507,7 +516,6 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa 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); } @@ -559,30 +567,29 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa return nls.localize('ariaSearchNoResultWithLineNumNoCurrentMatch', "{0} found for '{1}'", label, searchString); } - private getNthMatchInput(contextViewService: IContextViewService): NthMatchInput { - const matchPositionAndCountArr = this._matchesCount?.innerText?.split(' of ') ?? []; - const trimmedCountStr = matchPositionAndCountArr[1]?.trim(); + 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 = parseInt(trimmedCountStr) || this._matchesLimit; + 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, - tooltip: NLS_NTH_MATCH_INPUT_LABEL, - label: NLS_NTH_MATCH_INPUT_LABEL, - width: 20, type: 'text', min: min, max: max, - flexibleHeight: undefined, - flexibleWidth: undefined, - flexibleMaxHeight: undefined, - toggleStyles: defaultToggleStyles, inputBoxStyles: defaultInputBoxStyles, }); this._register(input.onStep((e) => { - if (e.direction === 'up') { + if (e.to === 'next') { this.find(false); } else { @@ -600,15 +607,11 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa this.findNth(inputValueAsSanitizedInt); })); - input.domNode.classList.add(...['monaco-inputbox', 'editable-nth-match']); - input.setValue(`${matchPositionAndCountArr[0]}`.trim()); + input.domNode.classList.add(...['monaco-inputbox', 'nth-match', 'simple-nth-match']); + input.setValue(!isNaN(truePosition) ? String(truePosition) : String(min)); return input; } - - private _keybindingLabelFor(actionId: string): string { - return this._keybindingService.appendKeybinding('', actionId); - } } 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 2bdf877aa28ee..40c3b79714b0c 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -813,7 +813,7 @@ export interface ISearchOptions { incremental?: boolean; /** The 1-based index of the desired match relative to its peer matches */ - nthMatchPosition?: number; + n?: number; } export interface ITerminalInstance extends IBaseTerminalInstance { diff --git a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts index 958af45da9698..175ec364b0602 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts @@ -142,12 +142,12 @@ export class TerminalFindWidget extends SimpleFindWidget { } } - public override findNth(nthMatchPosition: number): void { + 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(), nthMatchPosition: nthMatchPosition }); + this._findNthWithEvent(xterm, this.inputValue, { regex: this._getRegexValue(), wholeWord: this._getWholeWordValue(), caseSensitive: this._getCaseSensitiveValue(), n }); } override reveal(): void { @@ -252,9 +252,8 @@ export class TerminalFindWidget extends SimpleFindWidget { } private async _findNthWithEvent(xterm: IXtermTerminal, term: string, options: ISearchOptions): Promise { - return xterm.findNth(term, options).then(foundMatch => { - this._register(Event.once(xterm.onDidChangeSelection)(() => xterm.clearActiveSearchDecoration())); - return foundMatch; - }); + const foundMatch = await xterm.findNth(term, options); + this._registerSelectionChangeListener(xterm); + return foundMatch; } } diff --git a/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts b/src/vs/workbench/contrib/webview/browser/webviewFindWidget.ts index da9c6d649104e..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,10 +63,6 @@ export class WebviewFindWidget extends SimpleFindWidget { } } - public override findNth(nthMatchPosition: number): void { - // TODO: Implement - throw new Error('Method not implemented: webviewFindWidget.ts ---> findNth(nthMatchPosition: number)'); - } public override hide(animated = true) { super.hide(animated); @@ -96,9 +92,5 @@ export class WebviewFindWidget extends SimpleFindWidget { protected _onFindInputFocusTrackerBlur() { } - protected _onNthMatchInputFocusTrackerFocus() { } - - protected _onNthMatchInputFocusTrackerBlur() { } - findFirst() { } }