Merge pull request #3698 from meganrogge/findAll

Show all matches when searching
This commit is contained in:
Daniel Imms
2022-03-21 17:01:18 -07:00
committed by GitHub
7 changed files with 244 additions and 19 deletions
+203 -14
View File
@@ -3,13 +3,19 @@
* @license MIT
*/
import { Terminal, IBufferLine, IDisposable, ITerminalAddon, ISelectionPosition } from 'xterm';
import { Terminal, IDisposable, ITerminalAddon, ISelectionPosition, IDecoration } from 'xterm';
export interface ISearchOptions {
regex?: boolean;
wholeWord?: boolean;
caseSensitive?: boolean;
incremental?: boolean;
decorations?: ISearchDecorationOptions;
}
interface ISearchDecorationOptions {
matchColor: string;
selectedColor: string;
}
export interface ISearchPosition {
@@ -40,7 +46,14 @@ const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs
export class SearchAddon implements ITerminalAddon {
private _terminal: Terminal | undefined;
private _dataChanged: boolean = false;
private _cachedSearchTerm: string | undefined;
private _selectedDecoration: IDecoration | undefined;
private _resultDecorations: Map<number, IDecoration[]> = new Map<number, IDecoration[]>();
private _searchResults: Map<string, ISearchResult> = new Map();
private _onDataDisposable: IDisposable | undefined;
private _lastSearchOptions: ISearchOptions | undefined;
private _highlightTimeout: number | undefined;
/**
* translateBufferLineToStringWithWrap is a fairly expensive call.
* We memoize the calls into an array that has a time based ttl.
@@ -53,9 +66,41 @@ export class SearchAddon implements ITerminalAddon {
public activate(terminal: Terminal): void {
this._terminal = terminal;
this._onDataDisposable = this._terminal.onData(() => {
this._dataChanged = true;
if (this._highlightTimeout) {
window.clearTimeout(this._highlightTimeout);
}
this._highlightTimeout = setTimeout(() => {
if (this._lastSearchOptions?.decorations && this._cachedSearchTerm && this._resultDecorations.size > 0 && this._lastSearchOptions) {
this._highlightAllMatches(this._cachedSearchTerm, this._lastSearchOptions);
}
}, 200);
});
}
public dispose(): void { }
public dispose(): void {
this.clearDecorations();
this._onDataDisposable?.dispose();
}
public clearDecorations(): void {
this._selectedDecoration?.dispose();
this._terminal?.clearSelection();
this._searchResults.clear();
this._disposeDecorations();
this._cachedSearchTerm = undefined;
this._dataChanged = true;
}
private _disposeDecorations(): void {
this._resultDecorations.forEach(decorations => {
for (const d of decorations) {
d.dispose();
}
});
this._resultDecorations.clear();
}
/**
* Find the next instance of the term, then scroll to and select it. If it
@@ -68,9 +113,90 @@ export class SearchAddon implements ITerminalAddon {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
this._lastSearchOptions = searchOptions;
const findNextResult = this._findNextAndSelect(term, searchOptions);
if (searchOptions?.decorations) {
this._highlightAllMatches(term, searchOptions);
}
return findNextResult;
}
private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
if (!term || term.length === 0) {
this._terminal.clearSelection();
this.clearDecorations();
return;
}
searchOptions = searchOptions || {};
if (term === this._cachedSearchTerm && !this._dataChanged) {
return;
}
// new search, clear out the old decorations
this._disposeDecorations();
this._searchResults.clear();
let result = this._find(term, 0, 0, searchOptions);
while (result && !this._searchResults.get(`${result.row}-${result.col}`)) {
this._searchResults.set(`${result.row}-${result.col}`, result);
result = this._find(term, result.row, result.col + 1, searchOptions);
}
this._searchResults.forEach(result => {
const resultDecoration = this._createResultDecoration(result, searchOptions.decorations!);
if (resultDecoration) {
const decorationsForLine = this._resultDecorations.get(resultDecoration.marker.line) || [];
decorationsForLine.push(resultDecoration);
this._resultDecorations.set(resultDecoration.marker.line, decorationsForLine);
}
});
if (this._dataChanged) {
this._dataChanged = false;
}
if (this._searchResults.size > 0) {
this._cachedSearchTerm = term;
}
}
private _find(term: string, startRow?: number, startCol?: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
if (!this._terminal || !term || term.length === 0) {
this._terminal?.clearSelection();
this.clearDecorations();
return undefined;
}
let result: ISearchResult | undefined = undefined;
startCol = startCol || 0;
startRow = startRow ?? 0;
this._initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
// Search startRow
result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
// If the current line is wrapped line, increase index of column to ignore the previous scan
// Otherwise, reset beginning column index to zero with set new unwrapped line index
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
return result;
}
private _findNextAndSelect(term: string, searchOptions?: ISearchOptions): boolean {
if (!this._terminal || !term || term.length === 0) {
this._terminal?.clearSelection();
this.clearDecorations();
return false;
}
@@ -95,7 +221,6 @@ export class SearchAddon implements ITerminalAddon {
// Search startRow
let result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
@@ -130,9 +255,8 @@ export class SearchAddon implements ITerminalAddon {
}
// Set selection and scroll if a result was found
return this._selectResult(result);
return this._selectResult(result, searchOptions?.decorations);
}
/**
* Find the previous instance of the term, then scroll to and select it. If it
* doesn't exist, do nothing.
@@ -144,16 +268,30 @@ export class SearchAddon implements ITerminalAddon {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
this._lastSearchOptions = searchOptions;
const findPreviousResult = this._findAndSelectPrevious(term, searchOptions);
if (searchOptions?.decorations) {
this._highlightAllMatches(term, searchOptions);
}
return findPreviousResult;
}
if (!term || term.length === 0) {
this._terminal.clearSelection();
private _findAndSelectPrevious(term: string, searchOptions?: ISearchOptions): boolean {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
let result: ISearchResult | undefined;
if (!this._terminal || !term || term.length === 0) {
result = undefined;
this._terminal?.clearSelection();
this.clearDecorations();
return false;
}
const isReverseSearch = true;
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows;
let startCol = this._terminal.cols;
let result: ISearchResult | undefined;
const isReverseSearch = true;
const incremental = searchOptions ? searchOptions.incremental : false;
let currentSelection: ISelectionPosition | undefined;
if (this._terminal.hasSelection()) {
@@ -211,7 +349,7 @@ export class SearchAddon implements ITerminalAddon {
if (!result && currentSelection) return true;
// Set selection and scroll if a result was found
return this._selectResult(result);
return this._selectResult(result, searchOptions?.decorations);
}
/**
@@ -446,15 +584,25 @@ export class SearchAddon implements ITerminalAddon {
/**
* Selects and scrolls to a result.
* @param result The result to select.
* @return Whethera result was selected.
* @return Whether a result was selected.
*/
private _selectResult(result: ISearchResult | undefined): boolean {
private _selectResult(result: ISearchResult | undefined, decorations?: ISearchDecorationOptions): boolean {
const terminal = this._terminal!;
this._selectedDecoration?.dispose();
if (!result) {
terminal.clearSelection();
return false;
}
terminal.select(result.col, result.row, result.size);
if (decorations?.selectedColor) {
const marker = terminal.registerMarker(-terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row);
if (marker) {
this._selectedDecoration = terminal.registerDecoration({ marker, overviewRulerOptions: { color: decorations.selectedColor } });
this._selectedDecoration?.onRender((e) => this._applyStyles(e, decorations.selectedColor, result));
this._selectedDecoration?.onDispose(() => marker.dispose());
}
}
// If it is not in the viewport then we scroll else it just gets selected
if (result.row >= (terminal.buffer.active.viewportY + terminal.rows) || result.row < terminal.buffer.active.viewportY) {
let scroll = result.row - terminal.buffer.active.viewportY;
@@ -463,4 +611,45 @@ export class SearchAddon implements ITerminalAddon {
}
return true;
}
/**
* Applies styles to the decoration when it is rendered
* @param element the decoration's element
* @param color the color to apply
* @param result the search result associated with the decoration
* @returns
*/
private _applyStyles(element: HTMLElement, color: string, result: ISearchResult): void {
if (element.clientWidth <= 0) {
return;
}
if (!element.classList.contains('xterm-find-result-decoration')) {
element.classList.add('xterm-find-result-decoration');
element.style.left = `${element.clientWidth * result.col}px`;
element.style.width = `${element.clientWidth * result.term.length}px`;
element.style.backgroundColor = color;
element.style.opacity = '0.6';
}
}
/**
* Creates a decoration for the result and applies styles
* @param result the search result for which to create the decoration
* @param color the color to use for the decoration
* @returns the {@link IDecoration} or undefined if the marker has already been disposed of
*/
private _createResultDecoration(result: ISearchResult, decorations: ISearchDecorationOptions): IDecoration | undefined {
const terminal = this._terminal!;
const marker = terminal.registerMarker(-terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row);
if (!marker || !decorations?.matchColor) {
return undefined;
}
const findResultDecoration = terminal.registerDecoration(
{ marker,
overviewRulerOptions: this._resultDecorations.get(marker.line) && !this._dataChanged ? undefined : { color: decorations.matchColor, position: 'center' }
});
findResultDecoration?.onRender((e) => this._applyStyles(e, decorations.matchColor, result));
findResultDecoration?.onDispose(() => marker.dispose());
return findResultDecoration;
}
}
@@ -32,6 +32,27 @@ declare module 'xterm-addon-search' {
* `findNext`, not `findPrevious`.
*/
incremental?: boolean;
/**
* When set, will highlight all instances of the word on search and show
* them in the overview ruler if it's enabled.
*/
decorations?: ISearchDecorationOptions;
}
/**
* Options for showing decorations when searching.
*/
interface ISearchDecorationOptions {
/**
* The color of a match.
*/
matchColor: string;
/**
* The color for the currently selected match.
*/
selectedColor: string;
}
/**
@@ -64,5 +85,10 @@ declare module 'xterm-addon-search' {
* @param searchOptions The options for the search.
*/
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean;
/**
* Clears the decorations and selection
*/
public clearDecorations(): void;
}
}
+3 -1
View File
@@ -92,6 +92,7 @@ const addons: { [T in AddonType]: IDemoAddon<T>} = {
const terminalContainer = document.getElementById('terminal-container');
const actionElements = {
find: <HTMLInputElement>document.querySelector('#find'),
findNext: <HTMLInputElement>document.querySelector('#find-next'),
findPrevious: <HTMLInputElement>document.querySelector('#find-previous')
};
@@ -107,7 +108,8 @@ function getSearchOptions(e: KeyboardEvent): ISearchOptions {
regex: (document.getElementById('regex') as HTMLInputElement).checked,
wholeWord: (document.getElementById('whole-word') as HTMLInputElement).checked,
caseSensitive: (document.getElementById('case-sensitive') as HTMLInputElement).checked,
incremental: e.key !== `Enter`
incremental: e.key !== `Enter`,
decorations: (document.getElementById('highlight-all-matches') as HTMLInputElement).checked ? { matchColor: '#555753', selectedColor: '#ef2929' } : undefined
};
}
+1
View File
@@ -43,6 +43,7 @@
<label><input type="checkbox" id="regex"/>Use regex</label>
<label><input type="checkbox" id="case-sensitive"/>Case sensitive</label>
<label><input type="checkbox" id="whole-word"/>Whole word</label>
<label><input type="checkbox" id="highlight-all-matches"/>Highlight All Matches</label>
</div>
<h4>SerializeAddon</h4>
<div>
@@ -97,7 +97,7 @@ export class BufferDecorationRenderer extends Disposable {
private _refreshStyle(decoration: IInternalDecoration, element: HTMLElement): void {
const line = decoration.marker.line - this._bufferService.buffers.active.ydisp;
if (line < 0 || line > this._bufferService.rows) {
if (line < 0 || line >= this._bufferService.rows) {
// outside of viewport
element.style.display = 'none';
} else {
@@ -143,12 +143,12 @@ export class OverviewRulerRenderer extends Disposable {
}
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
for (const decoration of this._decorationService.decorations) {
if (decoration.options.overviewRulerOptions!.position !== 'full') {
if (decoration.options.overviewRulerOptions && decoration.options.overviewRulerOptions.position !== 'full') {
this._renderDecoration(decoration, updateAnchor);
}
}
for (const decoration of this._decorationService.decorations) {
if (decoration.options.overviewRulerOptions!.position === 'full') {
if (decoration.options.overviewRulerOptions && decoration.options.overviewRulerOptions.position === 'full') {
this._renderDecoration(decoration, updateAnchor);
}
}
+8 -1
View File
@@ -32,7 +32,10 @@ export class DecorationService extends Disposable implements IDecorationService
if (decoration) {
decoration.onDispose(() => {
if (decoration) {
this._decorations.splice(this._decorations.indexOf(decoration), 1);
const index = this._decorations.indexOf(decoration);
if (index >= 0) {
this._decorations.splice(this._decorations.indexOf(decoration), 1);
}
}
});
this._decorations.push(decoration);
@@ -70,6 +73,10 @@ class Decoration extends Disposable implements IInternalDecoration {
}
}
public override dispose(): void {
if (this._isDisposed) {
return;
}
this._isDisposed = true;
this._onDispose.fire();
super.dispose();
}