Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions addons/addon-search/src/SearchAddon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions addons/addon-search/typings/addon-search.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 46 additions & 1 deletion demo/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
)()
};
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions demo/client/components/window/addonSearchWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: ';
Expand Down Expand Up @@ -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;
}
}