diff --git a/addons/addon-search/src/SearchAddon.ts b/addons/addon-search/src/SearchAddon.ts index cb40947430..36f943584d 100644 --- a/addons/addon-search/src/SearchAddon.ts +++ b/addons/addon-search/src/SearchAddon.ts @@ -120,6 +120,41 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp return found; } + /** + * Find the Nth instance of the term, then scroll to and select it. If it + * doesn't exist, do nothing. + * @param term The search term. + * @param searchOptions Search options. + * @returns Whether a result was found. + */ + public findNth(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean { + if (!this._terminal || !this._engine) { + throw new Error('Cannot use addon until it has been loaded'); + } + + // If nthMatchPosition is invalid for any reason, pass along -1 as a substitute. + // That way, `clearDecoration()` logic is guaranteed to run and reflect match failure. + if (!searchOptions || isNaN(parseInt(`${searchOptions.nthMatchPosition}`, 10)) || !searchOptions.nthMatchPosition) { + searchOptions = { nthMatchPosition: -1, ...(searchOptions ?? {}) }; + } + + this._onBeforeSearch.fire(); + + this._state.lastSearchOptions = searchOptions; + + if (this._state.shouldUpdateHighlighting(term, searchOptions)) { + this._highlightAllMatches(term, searchOptions!); + } + + const found = this._findNthAndSelect(term, searchOptions, internalSearchOptions); + this._fireResults(searchOptions); + this._state.cachedSearchTerm = term; + + this._onAfterSearch.fire(); + + return found; + } + private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void { if (!this._terminal || !this._engine || !this._decorationManager) { throw new Error('Cannot use addon until it has been loaded'); @@ -170,6 +205,21 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll); } + private _findNthAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean { + if (!this._terminal || !this._engine) { + return false; + } + + if (!this._state.isValidSearchTerm(term)) { + this._terminal.clearSelection(); + this.clearDecorations(); + return false; + } + + const result = this._resultTracker.searchResults.find((match: ISearchResult, index: number) => index + 1 === searchOptions?.nthMatchPosition); + return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll); + } + /** * Find the previous instance of the term, then scroll to and select it. If it * doesn't exist, do nothing. diff --git a/addons/addon-search/typings/addon-search.d.ts b/addons/addon-search/typings/addon-search.d.ts index 40034da9a5..eb9715e077 100644 --- a/addons/addon-search/typings/addon-search.d.ts +++ b/addons/addon-search/typings/addon-search.d.ts @@ -38,6 +38,12 @@ declare module '@xterm/addon-search' { * them in the overview ruler if it's enabled. */ decorations?: ISearchDecorationOptions; + + /** + * The 1-based index of the desired match relative to its peer matches. + * Set it to a bounded, positive integer for reliable results. + */ + nthMatchPosition?: number; } /** @@ -131,6 +137,15 @@ declare module '@xterm/addon-search' { */ public findNext(term: string, searchOptions?: ISearchOptions): boolean; + /** + * Search arbitrarily for the Nth result that matches the search term and + * options, where searchOptions stores N, the 1-based index of the desired match relative to its peers. + * @param term The search term. + * @param searchOptions The options for the search. + */ + public findNth(term: string, searchOptions?: ISearchOptions): boolean; + + /** * Search backwards for the previous result that matches the search term and * options. diff --git a/demo/client/client.ts b/demo/client/client.ts index aab723b7ea..0649df8949 100644 --- a/demo/client/client.ts +++ b/demo/client/client.ts @@ -92,6 +92,7 @@ let terminalContainer = document.getElementById('terminal-container'); let actionElements: { findNext: HTMLInputElement; findPrevious: HTMLInputElement; + findNth: { nthSearchStrInput: HTMLInputElement, nthPositionIndexInput: HTMLInputElement }; findResults: HTMLElement; }; let paddingElement: HTMLInputElement; @@ -135,7 +136,20 @@ function getSearchOptions(): ISearchOptions { activeMatchBackground: '#ef2929', activeMatchBorder: '#ffffff', activeMatchColorOverviewRuler: '#ef2929' - } : undefined + } : undefined, + // This is a single-use IIFE that validates the arbitrary value in the + // N input and coerces it to a bounded, numeric value. + // This should idealy be performed client-side where the constraints are better understood. + // Just in case, there's a similar, last-minute validation in the server-side implementation. + // See SearchAddon.ts ---> findNth(...) + nthMatchPosition: (() => { + const rawValue = (document.getElementById('find-nth-position-index') as HTMLInputElement)?.value; + if (isNaN(+rawValue)) { + return -1; + } + return parseInt(`${+rawValue}`, 10); + } + )() }; } @@ -228,6 +242,10 @@ if (document.location.pathname === '/test') { actionElements = { findNext: addonSearchWindow.findNextInput, findPrevious: addonSearchWindow.findPreviousInput, + findNth: { + nthSearchStrInput: addonSearchWindow.findNthSearchStrInput, + nthPositionIndexInput: addonSearchWindow.findNthPositionInput + }, findResults: addonSearchWindow.findResultsSpan }; controlBar.activateDefaultTab(); @@ -268,12 +286,39 @@ if (document.location.pathname === '/test') { addDomListener(actionElements.findPrevious, 'input', (e) => { addons.search.instance!.findPrevious(actionElements.findPrevious.value, getSearchOptions()); }); + + addDomListener(actionElements.findNth.nthSearchStrInput, 'keydown', (e) => { + if (e.key === 'Enter') { + addons.search.instance!.findNth(actionElements.findNth.nthSearchStrInput.value, getSearchOptions()); + e.preventDefault(); + } + }); + addDomListener(actionElements.findNth.nthSearchStrInput, 'input', (e) => { + addons.search.instance!.findNth(actionElements.findNth.nthSearchStrInput.value, getSearchOptions()); + }); + addDomListener(actionElements.findNth.nthPositionIndexInput, 'keydown', (e) => { + if (e.key === 'Enter') { + addons.search.instance!.findNth(actionElements.findNth.nthSearchStrInput.value, getSearchOptions()); + e.preventDefault(); + } + }); + addDomListener(actionElements.findNth.nthPositionIndexInput, 'input', (e) => { + addons.search.instance!.findNth(actionElements.findNth.nthSearchStrInput.value, getSearchOptions()); + }); + addDomListener(actionElements.findNext, 'blur', (e) => { addons.search.instance!.clearActiveDecoration(); }); addDomListener(actionElements.findPrevious, 'blur', (e) => { addons.search.instance!.clearActiveDecoration(); }); + + addDomListener(actionElements.findNth.nthSearchStrInput, 'blur', (e) => { + addons.search.instance!.clearActiveDecoration(); + }); + addDomListener(actionElements.findNth.nthSearchStrInput, 'blur', (e) => { + addons.search.instance!.clearActiveDecoration(); + }); } function createTerminal(): Terminal { diff --git a/demo/client/components/window/addonSearchWindow.ts b/demo/client/components/window/addonSearchWindow.ts index 2891fca97e..8fc4b75833 100644 --- a/demo/client/components/window/addonSearchWindow.ts +++ b/demo/client/components/window/addonSearchWindow.ts @@ -12,6 +12,10 @@ export class AddonSearchWindow extends BaseWindow implements IControlWindow { private _findNextInput!: HTMLInputElement; private _findPreviousInput!: HTMLInputElement; + + private _findNthSearchStrInput!: HTMLInputElement; + private _findNthPositionInput!: HTMLInputElement; + private _findResultsSpan!: HTMLElement; private _regexCheckbox!: HTMLInputElement; private _caseSensitiveCheckbox!: HTMLInputElement; @@ -39,6 +43,9 @@ export class AddonSearchWindow extends BaseWindow implements IControlWindow { findPrevLabel.appendChild(this._findPreviousInput); wrapper.appendChild(findPrevLabel); + // Find Nth + this._buildFindNthUI(wrapper); + // Results const resultsDiv = document.createElement('div'); resultsDiv.textContent = 'Results: '; @@ -95,7 +102,50 @@ export class AddonSearchWindow extends BaseWindow implements IControlWindow { return this._findPreviousInput; } + public get findNthSearchStrInput(): HTMLInputElement { + return this._findNthSearchStrInput; + } + + public get findNthPositionInput(): HTMLInputElement { + return this._findNthPositionInput; + } + public get findResultsSpan(): HTMLElement { return this._findResultsSpan; } + + private _buildFindNthUI(wrapper: HTMLElement): HTMLElement { + const findNthLabel = document.createElement('label'); + const findNthSearchStrLabel = document.createElement('label'); + const findNthPositionLabel = document.createElement('label'); + + findNthLabel.textContent = 'Find Nth '; + + const inputsList = document.createElement('ul'); + const listItem1 = document.createElement('li'); + const listItem2 = document.createElement('li'); + + findNthSearchStrLabel.textContent = 'Search Expression '; + this._findNthSearchStrInput = document.createElement('input'); + this._findNthSearchStrInput.id = 'find-nth-search-string'; + findNthSearchStrLabel.appendChild(this._findNthSearchStrInput); + findNthSearchStrLabel.appendChild(document.createElement('br')); + listItem1.appendChild(findNthSearchStrLabel); + + findNthPositionLabel.textContent = 'N (1-based) '; + this._findNthPositionInput = document.createElement('input'); + this._findNthPositionInput.id = 'find-nth-position-index'; + this._findNthPositionInput.value = '1'; + findNthPositionLabel.appendChild(this._findNthPositionInput); + listItem2.appendChild(findNthPositionLabel); + + inputsList.append(listItem1); + inputsList.append(listItem2); + + wrapper.appendChild(document.createElement('br')); + wrapper.appendChild(findNthLabel); + wrapper.appendChild(inputsList); + + return wrapper; + } }