Skip to content

Commit

Permalink
Merge pull request #6159 from aschlaep/search-and-replace
Browse files Browse the repository at this point in the history
Add Find and Replace for notebooks and text files
  • Loading branch information
jasongrout committed Apr 11, 2019
2 parents 730333f + 6de695d commit f99c216
Show file tree
Hide file tree
Showing 18 changed files with 673 additions and 149 deletions.
27 changes: 15 additions & 12 deletions packages/codemirror/src/editor.ts
Expand Up @@ -751,7 +751,19 @@ export class CodeMirrorEditor implements CodeEditor.IEditor {
// Only render selections if the start is not equal to the end.
// In that case, we don't need to render the cursor.
if (!JSONExt.deepEqual(selection.start, selection.end)) {
const { anchor, head } = this._toCodeMirrorSelection(selection);
// Selections only appear to render correctly if the anchor
// is before the head in the document. That is, reverse selections
// do not appear as intended.
let forward: boolean =
selection.start.line < selection.end.line ||
(selection.start.line === selection.end.line &&
selection.start.column <= selection.end.column);
let anchor = this._toCodeMirrorPosition(
forward ? selection.start : selection.end
);
let head = this._toCodeMirrorPosition(
forward ? selection.end : selection.start
);
let markerOptions: CodeMirror.TextMarkerOptions;
if (collaborator) {
markerOptions = this._toTextMarkerOptions({
Expand Down Expand Up @@ -823,18 +835,9 @@ export class CodeMirrorEditor implements CodeEditor.IEditor {
private _toCodeMirrorSelection(
selection: CodeEditor.IRange
): CodeMirror.Selection {
// Selections only appear to render correctly if the anchor
// is before the head in the document. That is, reverse selections
// do not appear as intended.
let forward: boolean =
selection.start.line < selection.end.line ||
(selection.start.line === selection.end.line &&
selection.start.column <= selection.end.column);
let anchor = forward ? selection.start : selection.end;
let head = forward ? selection.end : selection.start;
return {
anchor: this._toCodeMirrorPosition(anchor),
head: this._toCodeMirrorPosition(head)
anchor: this._toCodeMirrorPosition(selection.start),
head: this._toCodeMirrorPosition(selection.end)
};
}

Expand Down
29 changes: 28 additions & 1 deletion packages/csvviewer-extension/src/searchprovider.ts
Expand Up @@ -72,11 +72,31 @@ export class CSVSearchProvider implements ISearchProvider {
*
* @returns A promise that resolves once the action has completed.
*/
highlightPrevious(): Promise<ISearchMatch | undefined> {
async highlightPrevious(): Promise<ISearchMatch | undefined> {
this._target.content.searchService.find(this._query, true);
return undefined;
}

/**
* Replace the currently selected match with the provided text
* Not implemented in the CSV viewer as it is read-only.
*
* @returns A promise that resolves once the action has completed.
*/
async replaceCurrentMatch(newText: string): Promise<boolean> {
return false;
}

/**
* Replace all matches in the notebook with the provided text
* Not implemented in the CSV viewer as it is read-only.
*
* @returns A promise that resolves once the action has completed.
*/
async replaceAllMatches(newText: string): Promise<boolean> {
return false;
}

/**
* Signal indicating that something in the search has changed, so the UI should update
*/
Expand All @@ -94,6 +114,13 @@ export class CSVSearchProvider implements ISearchProvider {
*/
readonly currentMatchIndex: number | null = null;

/**
* Set to true if the widget under search is read-only, false
* if it is editable. Will be used to determine whether to show
* the replace option.
*/
readonly isReadOnly = true;

private _target: IDocumentWidget<CSVViewer>;
private _query: RegExp;
private _changed = new Signal<this, void>(this);
Expand Down
45 changes: 43 additions & 2 deletions packages/documentsearch/src/interfaces.ts
Expand Up @@ -26,9 +26,9 @@ export interface IDisplayState {
useRegex: boolean;

/**
* The text in the entry
* The text in the search entry
*/
inputText: string;
searchText: string;

/**
* The query constructed from the text and the case/regex flags
Expand All @@ -44,6 +44,26 @@ export interface IDisplayState {
* Should the focus forced into the input on the next render?
*/
forceFocus: boolean;

/**
* Whether or not the search input is currently focused
*/
searchInputFocused: boolean;

/**
* Whether or not the replace input is currently focused
*/
replaceInputFocused: boolean;

/**
* The text in the replace entry
*/
replaceText: string;

/**
* Whether or not the replace entry row is visible
*/
replaceEntryShown: boolean;
}

export interface ISearchMatch {
Expand Down Expand Up @@ -128,6 +148,20 @@ export interface ISearchProvider {
*/
highlightPrevious(): Promise<ISearchMatch | undefined>;

/**
* Replace the currently selected match with the provided text
*
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
*/
replaceCurrentMatch(newText: string): Promise<boolean>;

/**
* Replace all matches in the notebook with the provided text
*
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
*/
replaceAllMatches(newText: string): Promise<boolean>;

/**
* The same list of matches provided by the startQuery promise resoluton
*/
Expand All @@ -142,4 +176,11 @@ export interface ISearchProvider {
* The current index of the selected match.
*/
readonly currentMatchIndex: number | null;

/**
* Set to true if the widget under search is read-only, false
* if it is editable. Will be used to determine whether to show
* the replace option.
*/
readonly isReadOnly: boolean;
}
120 changes: 112 additions & 8 deletions packages/documentsearch/src/providers/codemirrorsearchprovider.ts
Expand Up @@ -98,7 +98,7 @@ export class CodeMirrorSearchProvider implements ISearchProvider {
const match = this._matchState[cursorMatch.from.line][
cursorMatch.from.ch
];
this._matchIndex = match.index;
this._currentMatch = match;
}
return matches;
}
Expand All @@ -112,8 +112,18 @@ export class CodeMirrorSearchProvider implements ISearchProvider {
*/
async endQuery(): Promise<void> {
this._matchState = {};
this._matchIndex = null;
this._currentMatch = null;
this._cm.removeOverlay(this._overlay);
const from = this._cm.getCursor('from');
const to = this._cm.getCursor('to');
// Setting a reverse selection to allow search-as-you-type to maintain the
// current selected match. See comment in _findNext for more details.
if (from !== to) {
this._cm.setSelection({
start: this._toEditorPos(to),
end: this._toEditorPos(from)
});
}
CodeMirror.off(this._cm.doc, 'change', this._onDocChanged.bind(this));
}

Expand All @@ -140,7 +150,7 @@ export class CodeMirrorSearchProvider implements ISearchProvider {
return;
}
const match = this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
this._matchIndex = match.index;
this._currentMatch = match;
return match;
}

Expand All @@ -155,10 +165,60 @@ export class CodeMirrorSearchProvider implements ISearchProvider {
return;
}
const match = this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
this._matchIndex = match.index;
this._currentMatch = match;
return match;
}

/**
* Replace the currently selected match with the provided text
*
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
*/
async replaceCurrentMatch(newText: string): Promise<boolean> {
// If the current selection exactly matches the current match,
// replace it. Otherwise, just select the next match after the cursor.
let replaceOccurred = false;
if (this._currentMatchIsSelected()) {
const cursor = this._cm.getSearchCursor(
this._query,
this._cm.getCursor('from'),
!this._query.ignoreCase
);
if (!cursor.findNext()) {
return replaceOccurred;
}
replaceOccurred = true;
cursor.replace(newText);
}
await this.highlightNext();
return replaceOccurred;
}

/**
* Replace all matches in the notebook with the provided text
*
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
*/
async replaceAllMatches(newText: string): Promise<boolean> {
let replaceOccurred = false;
return new Promise((resolve, _) => {
this._cm.operation(() => {
const cursor = this._cm.getSearchCursor(
this._query,
null,
!this._query.ignoreCase
);
while (cursor.findNext()) {
replaceOccurred = true;
cursor.replace(newText);
}
this._matchState = {};
this._currentMatch = null;
resolve(replaceOccurred);
});
});
}

/**
* Report whether or not this provider has the ability to search on the given object
*/
Expand All @@ -177,6 +237,10 @@ export class CodeMirrorSearchProvider implements ISearchProvider {
return this._parseMatchesFromState();
}

get currentMatch(): ISearchMatch | null {
return this._currentMatch;
}

/**
* Signal indicating that something in the search has changed, so the UI should update
*/
Expand All @@ -188,9 +252,19 @@ export class CodeMirrorSearchProvider implements ISearchProvider {
* The current index of the selected match.
*/
get currentMatchIndex(): number {
return this._matchIndex;
if (!this._currentMatch) {
return null;
}
return this._currentMatch.index;
}

/**
* Set to true if the widget under search is read-only, false
* if it is editable. Will be used to determine whether to show
* the replace option.
*/
readonly isReadOnly = false;

clearSelection(): void {
return null;
}
Expand Down Expand Up @@ -328,7 +402,20 @@ export class CodeMirrorSearchProvider implements ISearchProvider {
private _findNext(reverse: boolean): Private.ICodeMirrorMatch {
return this._cm.operation(() => {
const caseSensitive = this._query.ignoreCase;
const cursorToGet = reverse ? 'from' : 'to';

// In order to support search-as-you-type, we needed a way to allow the first
// match to be selected when a search is started, but prevent the selected
// search to move for each new keypress. To do this, when a search is ended,
// the cursor is reversed, putting the head at the 'from' position. When a new
// search is started, the cursor we want is at the 'from' position, so that the same
// match is selected when the next key is entered (if it is still a match).
//
// When toggling through a search normally, the cursor is always set in the forward
// direction, so head is always at the 'to' position. That way, if reverse = false,
// the search proceeds from the 'to' position during normal toggling. If reverse = true,
// the search always proceeds from the 'anchor' position, which is at the 'from'.

const cursorToGet = reverse ? 'anchor' : 'head';
const lastPosition = this._cm.getCursor(cursorToGet);
const position = this._toEditorPos(lastPosition);
let cursor: CodeMirror.SearchCursor = this._cm.getSearchCursor(
Expand All @@ -340,7 +427,7 @@ export class CodeMirrorSearchProvider implements ISearchProvider {
// if we don't want to loop, no more matches found, reset the cursor and exit
if (this.isSubProvider) {
this._cm.setCursorPosition(position);
this._matchIndex = null;
this._currentMatch = null;
return null;
}

Expand Down Expand Up @@ -415,9 +502,26 @@ export class CodeMirrorSearchProvider implements ISearchProvider {
};
}

private _currentMatchIsSelected(): boolean {
if (!this._currentMatch) {
return false;
}
const currentSelection = this._cm.getSelection();
const currentSelectionLength =
currentSelection.end.column - currentSelection.start.column;
const selectionIsOneLine =
currentSelection.start.line === currentSelection.end.line;
return (
this._currentMatch.line === currentSelection.start.line &&
this._currentMatch.column === currentSelection.start.column &&
this._currentMatch.text.length === currentSelectionLength &&
selectionIsOneLine
);
}

private _query: RegExp;
private _cm: CodeMirrorEditor;
private _matchIndex: number;
private _currentMatch: ISearchMatch;
private _matchState: MatchMap = {};
private _changed = new Signal<this, void>(this);
private _overlay: any;
Expand Down

0 comments on commit f99c216

Please sign in to comment.