Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Find and Replace for notebooks and text files #6159

Merged
merged 11 commits into from Apr 11, 2019
27 changes: 15 additions & 12 deletions packages/codemirror/src/editor.ts
Expand Up @@ -742,7 +742,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 =
jasongrout marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -814,18 +826,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;
jasongrout marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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.
jasongrout marked this conversation as resolved.
Show resolved Hide resolved
*/
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;
jasongrout marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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, _) => {
jasongrout marked this conversation as resolved.
Show resolved Hide resolved
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