Skip to content

Commit

Permalink
Add support for multiline searches (#12868)
Browse files Browse the repository at this point in the history
Signed-off-by: Christian Radke <[email protected]>
  • Loading branch information
KR155E authored Sep 25, 2023
1 parent d2b7f3b commit f038673
Show file tree
Hide file tree
Showing 7 changed files with 256 additions and 64 deletions.
3 changes: 2 additions & 1 deletion packages/search-in-workspace/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"@theia/process": "1.41.0",
"@theia/workspace": "1.41.0",
"@vscode/ripgrep": "^1.14.2",
"minimatch": "^5.1.0"
"minimatch": "^5.1.0",
"react-autosize-textarea": "^7.0.0"
},
"publishConfig": {
"access": "public"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { Key, KeyCode } from '@theia/core/lib/browser';
import * as React from '@theia/core/shared/react';
import TextareaAutosize from 'react-autosize-textarea';
import debounce = require('@theia/core/shared/lodash.debounce');

interface HistoryState {
history: string[];
index: number;
};
type TextareaAttributes = React.TextareaHTMLAttributes<HTMLTextAreaElement>;

export class SearchInWorkspaceTextArea extends React.Component<TextareaAttributes, HistoryState> {
static LIMIT = 100;

private textarea = React.createRef<HTMLTextAreaElement>();

constructor(props: TextareaAttributes) {
super(props);
this.state = {
history: [],
index: 0,
};
}

updateState(index: number, history?: string[]): void {
this.value = history ? history[index] : this.state.history[index];
this.setState(prevState => {
const newState = {
...prevState,
index,
};
if (history) {
newState.history = history;
}
return newState;
});
}

get value(): string {
return this.textarea.current?.value ?? '';
}

set value(value: string) {
if (this.textarea.current) {
this.textarea.current.value = value;
}
}

/**
* Handle history navigation without overriding the parent's onKeyDown handler, if any.
*/
protected readonly onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
// Navigate history only when cursor is at first or last position of the textarea
if (Key.ARROW_UP.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode && e.currentTarget.selectionStart === 0) {
e.preventDefault();
this.previousValue();
} else if (Key.ARROW_DOWN.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode && e.currentTarget.selectionEnd === e.currentTarget.value.length) {
e.preventDefault();
this.nextValue();
}

// Prevent newline on enter
if (Key.ENTER.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode && !e.nativeEvent.shiftKey) {
e.preventDefault();
}

this.props.onKeyDown?.(e);
};

/**
* Switch the textarea's text to the previous value, if any.
*/
previousValue(): void {
const { history, index } = this.state;
if (!this.value) {
this.value = history[index];
} else if (index > 0 && index < history.length) {
this.updateState(index - 1);
}
}

/**
* Switch the textarea's text to the next value, if any.
*/
nextValue(): void {
const { history, index } = this.state;
if (index === history.length - 1) {
this.value = '';
} else if (!this.value) {
this.value = history[index];
} else if (index >= 0 && index < history.length - 1) {
this.updateState(index + 1);
}
}

/**
* Handle history collection and textarea resizing without overriding the parent's onChange handler, if any.
*/
protected readonly onChange = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
this.addToHistory();
this.props.onChange?.(e);
};

/**
* Add a nonempty current value to the history, if not already present. (Debounced, 1 second delay.)
*/
readonly addToHistory = debounce(this.doAddToHistory, 1000);

private doAddToHistory(): void {
if (!this.value) {
return;
}
const history = this.state.history
.filter(term => term !== this.value)
.concat(this.value)
.slice(-SearchInWorkspaceTextArea.LIMIT);
this.updateState(history.length - 1, history);
}

override render(): React.ReactNode {
const { onResize, ...filteredProps } = this.props;
return (
<TextareaAutosize
{...filteredProps}
autoCapitalize="off"
autoCorrect="off"
maxRows={7} /* from VS Code */
onChange={this.onChange}
onKeyDown={this.onKeyDown}
ref={this.textarea}
rows={1}
spellCheck={false}
>
</TextareaAutosize>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
import { CancellationTokenSource, Emitter, EOL, Event, ProgressService } from '@theia/core';
import {
EditorManager, EditorDecoration, TrackedRangeStickiness, OverviewRulerLane,
EditorWidget, EditorOpenerOptions, FindMatch
EditorWidget, EditorOpenerOptions, FindMatch, Position
} from '@theia/editor/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { FileResourceResolver, FileSystemPreferences } from '@theia/filesystem/lib/browser';
Expand Down Expand Up @@ -303,12 +303,16 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {

const matches: SearchMatch[] = [];
results.forEach(r => {
const lineText: string = widget.editor.document.getLineContent(r.range.start.line);
const numberOfLines = searchTerm.split('\n').length;
const lineTexts = [];
for (let i = 0; i < numberOfLines; i++) {
lineTexts.push(widget.editor.document.getLineContent(r.range.start.line + i));
}
matches.push({
line: r.range.start.line,
character: r.range.start.character,
length: r.range.end.character - r.range.start.character,
lineText
length: searchTerm.length,
lineText: lineTexts.join('\n')
});
});

Expand Down Expand Up @@ -873,19 +877,18 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
// Open the file only if the function is called to replace all matches under a specific node.
const widget: EditorWidget = replaceOne ? await this.doOpen(toReplace[0]) : await this.doGetWidget(toReplace[0]);
const source: string = widget.editor.document.getText();

const replaceOperations = toReplace.map(resultLineNode => ({
text: replacementText,
range: {
start: {
line: resultLineNode.line - 1,
character: resultLineNode.character - 1
},
end: {
line: resultLineNode.line - 1,
character: resultLineNode.character - 1 + resultLineNode.length
}
end: this.findEndCharacterPosition(resultLineNode),
}
}));

// Replace the text.
await widget.editor.replaceText({
source,
Expand Down Expand Up @@ -955,6 +958,23 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
}
}

private findEndCharacterPosition(node: SearchInWorkspaceResultLineNode): Position {
const lineText = typeof node.lineText === 'string' ? node.lineText : node.lineText.text;
const lines = lineText.split('\n');
const line = node.line + lines.length - 2;
let character = node.character - 1 + node.length;
if (lines.length > 1) {
character = node.length - lines[0].length + node.character - lines.length;
if (lines.length > 2) {
for (const lineNum of Array(lines.length - 2).keys()) {
character -= lines[lineNum + 1].length;
}
}
}

return { line, character };
}

protected renderRootFolderNode(node: SearchInWorkspaceRootFolderNode): React.ReactNode {
return <div className='result'>
<div className='result-head'>
Expand Down Expand Up @@ -1017,28 +1037,33 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
wordBreak.lastIndex++;
}

const before = lineText.slice(start, character - 1).trimLeft();

return <div className={`resultLine noWrapInfo noselect ${node.selected ? 'selected' : ''}`} title={lineText.trim()}>
{this.searchInWorkspacePreferences['search.lineNumbers'] && <span className='theia-siw-lineNumber'>{node.line}</span>}
<span>
{before}
</span>
{this.renderMatchLinePart(node)}
<span>
{lineText.slice(node.character + node.length - 1, 250 - before.length + node.length)}
</span>
</div>;
const before = lineText.slice(start, character - 1).trimStart();
const lineCount = lineText.split('\n').length;

return <>
<div className={`resultLine noWrapInfo noselect ${node.selected ? 'selected' : ''}`} title={lineText.trim()}>
{this.searchInWorkspacePreferences['search.lineNumbers'] && <span className='theia-siw-lineNumber'>{node.line}</span>}
<span>
{before}
</span>
{this.renderMatchLinePart(node)}
{lineCount > 1 || <span>
{lineText.slice(node.character + node.length - 1, 250 - before.length + node.length)}
</span>}
</div>
{lineCount > 1 && <div className='match-line-num'>+{lineCount - 1}</div>}
</>;
}

protected renderMatchLinePart(node: SearchInWorkspaceResultLineNode): React.ReactNode {
const replaceTerm = this.isReplacing ? <span className='replace-term'>{this._replaceTerm}</span> : '';
const replaceTermLines = this._replaceTerm.split('\n');
const replaceTerm = this.isReplacing ? <span className='replace-term'>{replaceTermLines[0]}</span> : '';
const className = `match${this.isReplacing ? ' strike-through' : ''}`;
const match = typeof node.lineText === 'string' ?
node.lineText.substring(node.character - 1, node.length + node.character - 1)
: node.lineText.text.substring(node.lineText.character - 1, node.length + node.lineText.character - 1);
const text = typeof node.lineText === 'string' ? node.lineText : node.lineText.text;
const match = text.substring(node.character - 1, node.character + node.length - 1);
const matchLines = match.split('\n');
return <React.Fragment>
<span className={className}>{match}</span>
<span className={className}>{matchLines[0]}</span>
{replaceTerm}
</React.Fragment>;
}
Expand Down Expand Up @@ -1071,10 +1096,7 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
line: node.line - 1,
character: node.character - 1
},
end: {
line: node.line - 1,
character: node.character - 1 + node.length
}
end: this.findEndCharacterPosition(node),
},
mode: preview ? 'reveal' : 'activate',
preview,
Expand All @@ -1100,16 +1122,8 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
content = await resource.readContents();
}

const lines = content.split('\n');
node.children.forEach(l => {
const leftPositionedNodes = node.children.filter(rl => rl.line === l.line && rl.character < l.character);
const diff = (this._replaceTerm.length - this.searchTerm.length) * leftPositionedNodes.length;
const start = lines[l.line - 1].substring(0, l.character - 1 + diff);
const end = lines[l.line - 1].substring(l.character - 1 + diff + l.length);
lines[l.line - 1] = start + this._replaceTerm + end;
});

return fileUri.withScheme(MEMORY_TEXT).withQuery(lines.join('\n'));
const searchTermRegExp = new RegExp(this.searchTerm, 'g');
return fileUri.withScheme(MEMORY_TEXT).withQuery(content.replace(searchTermRegExp, this._replaceTerm));
}

protected decorateEditor(node: SearchInWorkspaceFileNode | undefined, editorWidget: EditorWidget): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { ProgressBarFactory } from '@theia/core/lib/browser/progress-bar-factory
import { EditorManager } from '@theia/editor/lib/browser';
import { SearchInWorkspacePreferences } from './search-in-workspace-preferences';
import { SearchInWorkspaceInput } from './components/search-in-workspace-input';
import { SearchInWorkspaceTextArea } from './components/search-in-workspace-textarea';
import { nls } from '@theia/core/lib/common/nls';

export interface SearchFieldState {
Expand Down Expand Up @@ -65,8 +66,8 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge
protected searchTerm = '';
protected replaceTerm = '';

private searchRef = React.createRef<SearchInWorkspaceInput>();
private replaceRef = React.createRef<SearchInWorkspaceInput>();
private searchRef = React.createRef<SearchInWorkspaceTextArea>();
private replaceRef = React.createRef<SearchInWorkspaceTextArea>();
private includeRef = React.createRef<SearchInWorkspaceInput>();
private excludeRef = React.createRef<SearchInWorkspaceInput>();

Expand Down Expand Up @@ -142,6 +143,7 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge
matchCase: false,
matchWholeWord: false,
useRegExp: false,
multiline: false,
includeIgnored: false,
include: [],
exclude: [],
Expand Down Expand Up @@ -323,6 +325,8 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge

protected override onResize(msg: Widget.ResizeMessage): void {
super.onResize(msg);
this.searchRef.current?.forceUpdate();
this.replaceRef.current?.forceUpdate();
MessageLoop.sendMessage(this.resultTreeWidget, Widget.ResizeMessage.UnknownSize);
}

Expand Down Expand Up @@ -447,7 +451,8 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge
const searchOptions: SearchInWorkspaceOptions = {
...this.searchInWorkspaceOptions,
followSymlinks: this.shouldFollowSymlinks(),
matchCase: this.shouldMatchCase()
matchCase: this.shouldMatchCase(),
multiline: this.searchTerm.includes('\n')
};
this.resultTreeWidget.search(this.searchTerm, searchOptions);
}
Expand All @@ -471,12 +476,10 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge
}

protected renderSearchField(): React.ReactNode {
const input = <SearchInWorkspaceInput
const input = <SearchInWorkspaceTextArea
id='search-input-field'
className='theia-input'
title={SearchInWorkspaceWidget.LABEL}
type='text'
size={1}
placeholder={SearchInWorkspaceWidget.LABEL}
defaultValue={this.searchTerm}
autoComplete='off'
Expand Down Expand Up @@ -516,12 +519,10 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge
const replaceAllButtonContainer = this.renderReplaceAllButtonContainer();
const replace = nls.localizeByDefault('Replace');
return <div className={`replace-field${this.showReplaceField ? '' : ' hidden'}`}>
<SearchInWorkspaceInput
<SearchInWorkspaceTextArea
id='replace-input-field'
className='theia-input'
title={replace}
type='text'
size={1}
placeholder={replace}
defaultValue={this.replaceTerm}
autoComplete='off'
Expand Down
Loading

0 comments on commit f038673

Please sign in to comment.