deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,42 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - SEARCH-IN-WORKSPACE EXTENSION</h2>
<hr />
</div>
## Description
The `@theia/search-in-workspace` extension provides the ability to perform searches over all files in a given workspace using different search techniques.
## Search Widget
The `@theia/search-in-workspace` extension contributes the `Search` widget which is capable of performing different types of searches include the possibility to:
- Perform standard searches
- Perform searches using regular expressions
- Perform searches within an `include` list (search for specific types of files (ex: `*.ts`))
- Perform searches excluding files or directories (using `exclude`)
- Perform searches ignoring hidden or excluded files/folders
- Perform search and replace (to quickly update multiple occurrences of a search term)
## Additional Information
- [API documentation for `@theia/search-in-workspace`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_search-in-workspace.html)
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,57 @@
{
"name": "@theia/search-in-workspace",
"version": "1.68.0",
"description": "Theia - Search in workspace",
"dependencies": {
"@theia/core": "1.68.0",
"@theia/editor": "1.68.0",
"@theia/filesystem": "1.68.0",
"@theia/navigator": "1.68.0",
"@theia/process": "1.68.0",
"@theia/workspace": "1.68.0",
"@vscode/ripgrep": "^1.14.2",
"minimatch": "^10.0.3",
"react-textarea-autosize": "^8.5.5",
"tslib": "^2.6.2"
},
"publishConfig": {
"access": "public"
},
"theiaExtensions": [
{
"frontend": "lib/browser/search-in-workspace-frontend-module",
"backend": "lib/node/search-in-workspace-backend-module"
},
{
"frontendOnly": "lib/browser-only/search-in-workspace-frontend-only-module"
}
],
"keywords": [
"theia-extension"
],
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,30 @@
// *****************************************************************************
// Copyright (C) 2025 Maksim Kachurin 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 { injectable, postConstruct } from '@theia/core/shared/inversify';
import { SearchInWorkspaceService } from '../browser/search-in-workspace-service';
@injectable()
export class BrowserOnlySearchInWorkspaceService extends SearchInWorkspaceService {
@postConstruct()
protected override init(): void {
super.init();
if (this.searchServer && typeof this.searchServer.setClient === 'function') {
this.searchServer.setClient(this);
}
}
}

View File

@@ -0,0 +1,471 @@
// *****************************************************************************
// Copyright (C) 2025 Maksim Kachurin 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 { inject, injectable, named } from '@theia/core/shared/inversify';
import type {
SearchInWorkspaceClient,
SearchInWorkspaceOptions,
SearchInWorkspaceResult,
SearchInWorkspaceServer,
SearchMatch
} from '../common/search-in-workspace-interface';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { URI, ILogger } from '@theia/core';
import { FileService, TextFileOperationError, TextFileOperationResult } from '@theia/filesystem/lib/browser/file-service';
import { normalizeGlob, matchesPattern, createIgnoreMatcher, getIgnorePatterns } from '@theia/filesystem/lib/browser-only/file-search';
import { escapeRegExpCharacters } from '@theia/core/lib/common/strings';
import { BinarySize, type FileStatWithMetadata } from '@theia/filesystem/lib/common/files';
interface SearchController {
regex: RegExp;
searchPaths: URI[];
options: SearchInWorkspaceOptions;
isAborted: () => boolean;
abort: () => void;
};
const minimatchOpts = {
dot: true,
matchBase: true,
nocase: true
};
@injectable()
export class BrowserSearchInWorkspaceServer implements SearchInWorkspaceServer {
@inject(ILogger) @named('search-in-workspace')
protected readonly logger: ILogger;
@inject(FileService)
protected readonly fs: FileService;
private client: SearchInWorkspaceClient | undefined;
private ongoingSearches: Map<number, SearchController> = new Map();
private nextSearchId: number = 1;
/**
* Sets the client for receiving search results
*/
setClient(client: SearchInWorkspaceClient | undefined): void {
this.client = client;
}
/**
* Initiates a search operation and returns a search ID.
* @param what - The search term or pattern
* @param rootUris - Array of root URIs to search in
* @param opts - Search options including filters and limits
* @returns Promise resolving to the search ID
*/
async search(what: string, rootUris: string[], opts: SearchInWorkspaceOptions = {}): Promise<number> {
const searchId = this.nextSearchId++;
const controller = new AbortController();
const { regex, searchPaths, options } = await this.processSearchOptions(
what,
rootUris,
opts,
);
this.ongoingSearches.set(searchId, {
regex,
searchPaths,
options,
isAborted: () => controller.signal.aborted,
abort: () => controller.abort()
});
// Start search asynchronously and return searchId immediately
this.doSearch(searchId).catch((error: Error) => {
const errorStr = `An error happened while searching (${error.message}).`;
this.client?.onDone(searchId, errorStr);
}).finally(() => {
this.ongoingSearches.delete(searchId);
});
return searchId;
}
/**
* Cancels an ongoing search operation.
* @param searchId - The ID of the search to cancel
*/
cancel(searchId: number): Promise<void> {
const controller = this.ongoingSearches.get(searchId);
if (controller) {
this.ongoingSearches.delete(searchId);
controller.abort();
this.client?.onDone(searchId);
}
return Promise.resolve();
}
/**
* Disposes the service by aborting all ongoing searches.
*/
dispose(): void {
this.ongoingSearches.forEach(controller => controller.abort());
this.ongoingSearches.clear();
}
/**
* Internal method to perform the search.
* @param searchId - The ID of the search to perform.
* @returns A promise that resolves when the search is complete.
*/
private async doSearch(searchId: number): Promise<void> {
const ctx = this.ongoingSearches.get(searchId);
if (!ctx) {
return;
}
const { regex, searchPaths, options, isAborted } = ctx;
const maxFileSize = options.maxFileSize ? BinarySize.parseSize(options.maxFileSize) : 20 * BinarySize.MB;
const matcher = createIgnoreMatcher();
let remaining = options.maxResults ?? Number.POSITIVE_INFINITY;
for (const root of searchPaths) {
if (isAborted()) {
break;
}
const pathsStack: URI[] = [root];
let index = 0;
while (index < pathsStack.length && !isAborted() && remaining > 0) {
const current = pathsStack[index++];
const relPath = current.path.toString().replace(/^\/|^\.\//, '');
// Skip excluded paths
if (this.shouldExcludePath(current, options.exclude)) {
continue;
}
// Skip ignored files unless explicitly included
if (!options.includeIgnored && relPath && matcher.ignores(relPath)) {
continue;
}
let stat: FileStatWithMetadata;
try {
stat = await this.fs.resolve(current, { resolveMetadata: true });
} catch {
continue;
}
// Skip files not matching include patterns
if (stat.isFile && !this.shouldIncludePath(current, options.include)) {
continue;
}
// Skip files exceeding size limit
if (stat.isFile && stat.size > maxFileSize) {
continue;
}
// Process directory contents
if (stat.isDirectory) {
if (Array.isArray(stat.children)) {
// Load ignore patterns from files
if (!options.includeIgnored) {
const patterns = await getIgnorePatterns(
current,
uri => this.fs.read(uri).then(content => content.value)
);
matcher.add(patterns);
}
for (const child of stat.children) {
pathsStack.push(child.resource);
}
}
continue;
}
try {
const matches = await this.searchFileByLines(current, regex, isAborted, {
autoGuessEncoding: true,
acceptTextOnly: true
}, remaining);
if (matches.length > 0) {
const result: SearchInWorkspaceResult = {
root: root.path.toString(),
fileUri: current.path.toString(),
matches
};
this.client?.onResult(searchId, result);
remaining -= matches.length;
if (remaining <= 0) {
break;
}
}
} catch (err) {
if (err instanceof TextFileOperationError && err.textFileOperationResult === TextFileOperationResult.FILE_IS_BINARY) {
continue;
}
this.logger.error(`Error reading file ${current.path.toString()}: ${err.message}`);
continue;
}
}
if (remaining <= 0 || isAborted()) {
break;
}
}
this.client?.onDone(searchId);
}
/**
* Searches for matches within a file by processing it line by line.
* @param uri - The file URI to search
* @param re - The regex pattern to match
* @param isAborted - Function to check if search was aborted
* @param opts - File reading options
* @param limit - Maximum number of matches to return
* @returns Array of search matches found in the file
*/
private async searchFileByLines(
uri: URI,
re: RegExp,
isAborted: () => boolean,
opts: { autoGuessEncoding: boolean; acceptTextOnly: boolean },
limit: number
): Promise<SearchMatch[]> {
const { value: stream } = await this.fs.readStream(uri, opts);
let leftover = '';
let lineNo = 0;
const matches: SearchMatch[] = [];
await new Promise<void>((resolve, reject) => {
stream.on('data', chunk => {
if (isAborted()) {
stream.pause();
resolve();
return;
}
const data = leftover + chunk;
const lines = data.split(/\r?\n/);
leftover = lines.pop() ?? '';
for (const line of lines) {
lineNo += 1; // 1-based
if (!line) {
continue;
}
// Reset regex lastIndex for global patterns
if (re.global) {
re.lastIndex = 0;
}
let m: RegExpExecArray | null;
while ((m = re.exec(line))) {
matches.push({
line: lineNo,
character: m.index + 1, // 1-based
length: m[0].length,
lineText: line
});
if (matches.length >= limit) {
resolve();
return;
}
}
}
});
stream.on('error', err => reject(err));
stream.on('end', () => {
if (leftover.length && matches.length < limit) {
lineNo += 1;
const line = leftover;
// Reset regex lastIndex for global patterns
if (re.global) {
re.lastIndex = 0;
}
let m: RegExpExecArray | null;
while ((m = re.exec(line))) {
matches.push({
line: lineNo,
character: m.index + 1,
length: m[0].length,
lineText: line
});
if (matches.length >= limit) {
break;
}
}
}
resolve();
});
});
return matches;
}
/**
* Processes search options and returns clean paths and processed options.
* This method consolidates the path processing logic and matchWholeWord handling for better readability.
*/
private async processSearchOptions(_searchTerm: string, _searchPaths: string[], _options: SearchInWorkspaceOptions): Promise<{
regex: RegExp,
searchPaths: URI[],
options: SearchInWorkspaceOptions,
}> {
const options = { ..._options };
options.maxResults = typeof options.maxResults === 'number' && options.maxResults > 0 ? options.maxResults : Number.POSITIVE_INFINITY;
options.include = (options.include ?? []).map(glob => normalizeGlob(glob));
options.exclude = (options.exclude ?? []).map(glob => normalizeGlob(glob));
// If there are absolute paths in `include` we will remove them and use
// those as paths to search from
const paths = await this.extractSearchPathsFromIncludes(
_searchPaths.map(p => FileUri.fsPath(p)),
options.include
);
// Build regex with consideration of useRegExp/matchCase/matchWholeWord
const useRegExp = !!options.useRegExp;
const matchCase = !!options.matchCase;
const matchWholeWord = !!options.matchWholeWord;
const flags = 'g' + (matchCase ? '' : 'i') + 'u';
let source = useRegExp ? _searchTerm : escapeRegExpCharacters(_searchTerm);
// Unicode word boundaries: letters/numbers/underscore
if (matchWholeWord) {
const wbL = '(?<![\\p{L}\\p{N}_])';
const wbR = '(?![\\p{L}\\p{N}_])';
source = `${wbL}${source}${wbR}`;
}
const regex = new RegExp(source, flags);
const searchPaths = paths.map(p => URI.fromFilePath(p));
return { regex, searchPaths, options };
}
/**
* Checks if a path should be excluded based on exclude patterns.
* @param uri - The URI to check
* @param exclude - Array of exclude patterns
* @returns True if the path should be excluded
*/
protected shouldExcludePath(uri: URI, exclude: string[] | undefined): boolean {
if (!exclude?.length) {
return false;
}
return matchesPattern(uri.path.toString(), exclude, minimatchOpts);
}
/**
* Checks if a path should be included based on include patterns.
* @param uri - The URI to check
* @param include - Array of include patterns
* @returns True if the path should be included
*/
private shouldIncludePath(uri: URI, include: string[] | undefined): boolean {
if (!include?.length) {
return true;
}
return matchesPattern(uri.path.toString(), include, minimatchOpts);
}
/**
* The default search paths are set to be the root paths associated to a workspace
* however the search scope can be further refined with the include paths available in the search options.
* This method will replace the searching paths to the ones specified in the 'include' options but as long
* as the 'include' paths can be successfully validated as existing.
*
* Therefore the returned array of paths can be either the workspace root paths or a set of validated paths
* derived from the include options which can be used to perform the search.
*
* Any pattern that resulted in a valid search path will be removed from the 'include' list as it is
* provided as an equivalent search path instead.
*/
protected async extractSearchPathsFromIncludes(searchPaths: string[], include: string[]): Promise<string[]> {
if (!include) {
return searchPaths;
}
const resolvedPaths = new Set<string>();
const searchPathsUris = searchPaths.map(p => new URI(p));
for (const pattern of include) {
const [base, _] = getGlobBase(pattern);
const baseUri = new URI(base);
for (const rootUri of searchPathsUris) {
if (rootUri.isEqualOrParent(baseUri) && await this.fs.exists(baseUri)) {
resolvedPaths.add(baseUri.path.toString());
}
}
}
return resolvedPaths.size ? Array.from(resolvedPaths) : searchPaths;
}
}
/**
* Get the base + rest of a glob pattern.
*
* @param pattern - The glob pattern to get the base of (like 'workspace2/foo/*.md')
* @returns The base + rest of the glob pattern. (like ['workspace2/foo/', '*.md'])
*/
function getGlobBase(pattern: string): [string, string] {
const isAbsolute = pattern.startsWith('/');
const parts = pattern.replace(/^\//, '').split('/');
const magic = /[*?[\]{}]/;
const staticParts: string[] = [];
for (const part of parts) {
if (magic.test(part)) { break; }
staticParts.push(part);
}
const base = (isAbsolute ? '/' : '') + staticParts.join('/');
return [base, pattern.substring(base.length)];
}

View File

@@ -0,0 +1,35 @@
// *****************************************************************************
// Copyright (C) 2025 Maksim Kachurin 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 { ContainerModule } from '@theia/core/shared/inversify';
import { SearchInWorkspaceServer } from '../common/search-in-workspace-interface';
import { BrowserSearchInWorkspaceServer } from './browser-search-in-workspace-server';
import { SearchInWorkspaceService } from '../browser/search-in-workspace-service';
import { BrowserOnlySearchInWorkspaceService } from './browser-only-search-in-workspace-service';
export default new ContainerModule((bind, _unbind, isBound, rebind) => {
if (isBound(SearchInWorkspaceServer)) {
rebind(SearchInWorkspaceServer).to(BrowserSearchInWorkspaceServer).inSingletonScope();
} else {
bind(SearchInWorkspaceServer).to(BrowserSearchInWorkspaceServer).inSingletonScope();
}
if (isBound(SearchInWorkspaceService)) {
rebind(SearchInWorkspaceService).to(BrowserOnlySearchInWorkspaceService).inSingletonScope();
} else {
bind(SearchInWorkspaceService).to(BrowserOnlySearchInWorkspaceService).inSingletonScope();
}
});

View File

@@ -0,0 +1,139 @@
// *****************************************************************************
// 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 * as React from '@theia/core/shared/react';
import { Key, KeyCode } from '@theia/core/lib/browser';
import debounce = require('@theia/core/shared/lodash.debounce');
interface HistoryState {
history: string[];
index: number;
};
type InputAttributes = React.InputHTMLAttributes<HTMLInputElement>;
export class SearchInWorkspaceInput extends React.Component<InputAttributes, HistoryState> {
static LIMIT = 100;
private input = React.createRef<HTMLInputElement>();
constructor(props: InputAttributes) {
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.input.current?.value ?? '';
}
set value(value: string) {
if (this.input.current) {
this.input.current.value = value;
}
}
/**
* Handle history navigation without overriding the parent's onKeyDown handler, if any.
*/
protected readonly onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (Key.ARROW_UP.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode) {
e.preventDefault();
this.previousValue();
} else if (Key.ARROW_DOWN.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode) {
e.preventDefault();
this.nextValue();
}
this.props.onKeyDown?.(e);
};
/**
* Switch the input'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 input'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 without overriding the parent's onChange handler, if any.
*/
protected readonly onChange = (e: React.ChangeEvent<HTMLInputElement>): 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(-SearchInWorkspaceInput.LIMIT);
this.updateState(history.length - 1, history);
}
override render(): React.ReactNode {
return (
<input
{...this.props}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
spellCheck={false}
ref={this.input}
/>
);
}
}

View File

@@ -0,0 +1,159 @@
// *****************************************************************************
// 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-textarea-autosize';
import debounce = require('@theia/core/shared/lodash.debounce');
interface HistoryState {
history: string[];
index: number;
};
type TextareaAttributes = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'style'>;
export class SearchInWorkspaceTextArea extends React.Component<TextareaAttributes, HistoryState> {
static LIMIT = 100;
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();
}
setTimeout(() => {
this.forceUpdate();
}, 0);
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.forceUpdate();
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 {
/* One row for an empty search input box (fixes bug #15229), seven rows for the normal state (from VS Code) */
const maxRows = this.value.length ? 7 : 1;
return (
<TextareaAutosize
{...this.props}
autoCapitalize="off"
autoCorrect="off"
maxRows={maxRows}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
ref={this.textarea}
rows={1}
spellCheck={false}
/>
);
}
}

View File

@@ -0,0 +1,93 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox 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 { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service';
@injectable()
export class SearchInWorkspaceContextKeyService {
@inject(ContextKeyService)
protected readonly contextKeyService: ContextKeyService;
protected _searchViewletVisible: ContextKey<boolean>;
get searchViewletVisible(): ContextKey<boolean> {
return this._searchViewletVisible;
}
protected _searchViewletFocus: ContextKey<boolean>;
get searchViewletFocus(): ContextKey<boolean> {
return this._searchViewletFocus;
}
protected searchInputBoxFocus: ContextKey<boolean>;
setSearchInputBoxFocus(searchInputBoxFocus: boolean): void {
this.searchInputBoxFocus.set(searchInputBoxFocus);
this.updateInputBoxFocus();
}
protected replaceInputBoxFocus: ContextKey<boolean>;
setReplaceInputBoxFocus(replaceInputBoxFocus: boolean): void {
this.replaceInputBoxFocus.set(replaceInputBoxFocus);
this.updateInputBoxFocus();
}
protected patternIncludesInputBoxFocus: ContextKey<boolean>;
setPatternIncludesInputBoxFocus(patternIncludesInputBoxFocus: boolean): void {
this.patternIncludesInputBoxFocus.set(patternIncludesInputBoxFocus);
this.updateInputBoxFocus();
}
protected patternExcludesInputBoxFocus: ContextKey<boolean>;
setPatternExcludesInputBoxFocus(patternExcludesInputBoxFocus: boolean): void {
this.patternExcludesInputBoxFocus.set(patternExcludesInputBoxFocus);
this.updateInputBoxFocus();
}
protected inputBoxFocus: ContextKey<boolean>;
protected updateInputBoxFocus(): void {
this.inputBoxFocus.set(
this.searchInputBoxFocus.get() ||
this.replaceInputBoxFocus.get() ||
this.patternIncludesInputBoxFocus.get() ||
this.patternExcludesInputBoxFocus.get()
);
}
protected _replaceActive: ContextKey<boolean>;
get replaceActive(): ContextKey<boolean> {
return this._replaceActive;
}
protected _hasSearchResult: ContextKey<boolean>;
get hasSearchResult(): ContextKey<boolean> {
return this._hasSearchResult;
}
@postConstruct()
protected init(): void {
this._searchViewletVisible = this.contextKeyService.createKey<boolean>('searchViewletVisible', false);
this._searchViewletFocus = this.contextKeyService.createKey<boolean>('searchViewletFocus', false);
this.inputBoxFocus = this.contextKeyService.createKey<boolean>('inputBoxFocus', false);
this.searchInputBoxFocus = this.contextKeyService.createKey<boolean>('searchInputBoxFocus', false);
this.replaceInputBoxFocus = this.contextKeyService.createKey<boolean>('replaceInputBoxFocus', false);
this.patternIncludesInputBoxFocus = this.contextKeyService.createKey<boolean>('patternIncludesInputBoxFocus', false);
this.patternExcludesInputBoxFocus = this.contextKeyService.createKey<boolean>('patternExcludesInputBoxFocus', false);
this._replaceActive = this.contextKeyService.createKey<boolean>('replaceActive', false);
this._hasSearchResult = this.contextKeyService.createKey<boolean>('hasSearchResult', false);
}
}

View File

@@ -0,0 +1,59 @@
// *****************************************************************************
// Copyright (C) 2021 SAP SE or an SAP affiliate company 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 { inject, injectable } from '@theia/core/shared/inversify';
import {
codicon,
ViewContainer,
ViewContainerTitleOptions,
WidgetFactory,
WidgetManager
} from '@theia/core/lib/browser';
import { SearchInWorkspaceWidget } from './search-in-workspace-widget';
import { nls } from '@theia/core/lib/common/nls';
export const SEARCH_VIEW_CONTAINER_ID = 'search-view-container';
export const SEARCH_VIEW_CONTAINER_TITLE_OPTIONS: ViewContainerTitleOptions = {
label: nls.localizeByDefault('Search'),
iconClass: codicon('search'),
closeable: true
};
@injectable()
export class SearchInWorkspaceFactory implements WidgetFactory {
readonly id = SEARCH_VIEW_CONTAINER_ID;
protected searchWidgetOptions: ViewContainer.Factory.WidgetOptions = {
canHide: false,
initiallyCollapsed: false
};
@inject(ViewContainer.Factory)
protected readonly viewContainerFactory: ViewContainer.Factory;
@inject(WidgetManager) protected readonly widgetManager: WidgetManager;
async createWidget(): Promise<ViewContainer> {
const viewContainer = this.viewContainerFactory({
id: SEARCH_VIEW_CONTAINER_ID,
progressLocationId: 'search'
});
viewContainer.setTitleOptions(SEARCH_VIEW_CONTAINER_TITLE_OPTIONS);
const widget = await this.widgetManager.getOrCreateWidget(SearchInWorkspaceWidget.ID);
viewContainer.addWidget(widget, this.searchWidgetOptions);
return viewContainer;
}
}

View File

@@ -0,0 +1,510 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 {
AbstractViewContribution, KeybindingRegistry, LabelProvider, CommonMenus, FrontendApplication,
FrontendApplicationContribution, CommonCommands, StylingParticipant, ColorTheme, CssStyleCollector
} from '@theia/core/lib/browser';
import { SearchInWorkspaceWidget } from './search-in-workspace-widget';
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { CommandRegistry, MenuModelRegistry, SelectionService, Command, isOSX, nls } from '@theia/core';
import { codicon, Widget } from '@theia/core/lib/browser/widgets';
import { FileNavigatorCommands, NavigatorContextMenu } from '@theia/navigator/lib/browser/navigator-contribution';
import { UriCommandHandler, UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler';
import URI from '@theia/core/lib/common/uri';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { SearchInWorkspaceContextKeyService } from './search-in-workspace-context-key-service';
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
import { Range } from '@theia/core/shared/vscode-languageserver-protocol';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { SEARCH_VIEW_CONTAINER_ID } from './search-in-workspace-factory';
import { SearchInWorkspaceFileNode, SearchInWorkspaceResultTreeWidget } from './search-in-workspace-result-tree-widget';
import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
import { isHighContrast } from '@theia/core/lib/common/theme';
export namespace SearchInWorkspaceCommands {
const SEARCH_CATEGORY = 'Search';
export const TOGGLE_SIW_WIDGET = {
id: 'search-in-workspace.toggle'
};
export const OPEN_SIW_WIDGET = Command.toDefaultLocalizedCommand({
id: 'search-in-workspace.open',
category: SEARCH_CATEGORY,
label: 'Find in Files'
});
export const REPLACE_IN_FILES = Command.toDefaultLocalizedCommand({
id: 'search-in-workspace.replace',
category: SEARCH_CATEGORY,
label: 'Replace in Files'
});
export const FIND_IN_FOLDER = Command.toDefaultLocalizedCommand({
id: 'search-in-workspace.in-folder',
category: SEARCH_CATEGORY,
label: 'Find in Folder...'
});
export const FOCUS_NEXT_RESULT = Command.toDefaultLocalizedCommand({
id: 'search.action.focusNextSearchResult',
category: SEARCH_CATEGORY,
label: 'Focus Next Search Result'
});
export const FOCUS_PREV_RESULT = Command.toDefaultLocalizedCommand({
id: 'search.action.focusPreviousSearchResult',
category: SEARCH_CATEGORY,
label: 'Focus Previous Search Result'
});
export const REFRESH_RESULTS = Command.toDefaultLocalizedCommand({
id: 'search-in-workspace.refresh',
category: SEARCH_CATEGORY,
label: 'Refresh',
iconClass: codicon('refresh')
});
export const CANCEL_SEARCH = Command.toDefaultLocalizedCommand({
id: 'search-in-workspace.cancel',
category: SEARCH_CATEGORY,
label: 'Cancel Search',
iconClass: codicon('search-stop')
});
export const COLLAPSE_ALL = Command.toDefaultLocalizedCommand({
id: 'search-in-workspace.collapse-all',
category: SEARCH_CATEGORY,
label: 'Collapse All',
iconClass: codicon('collapse-all')
});
export const EXPAND_ALL = Command.toDefaultLocalizedCommand({
id: 'search-in-workspace.expand-all',
category: SEARCH_CATEGORY,
label: 'Expand All',
iconClass: codicon('expand-all')
});
export const CLEAR_ALL = Command.toDefaultLocalizedCommand({
id: 'search-in-workspace.clear-all',
category: SEARCH_CATEGORY,
label: 'Clear Search Results',
iconClass: codicon('clear-all')
});
export const COPY_ALL = Command.toDefaultLocalizedCommand({
id: 'search.action.copyAll',
category: SEARCH_CATEGORY,
label: 'Copy All',
});
export const COPY_ONE = Command.toDefaultLocalizedCommand({
id: 'search.action.copyMatch',
category: SEARCH_CATEGORY,
label: 'Copy',
});
export const DISMISS_RESULT = Command.toDefaultLocalizedCommand({
id: 'search.action.remove',
category: SEARCH_CATEGORY,
label: 'Dismiss',
});
export const REPLACE_RESULT = Command.toDefaultLocalizedCommand({
id: 'search.action.replace',
});
export const REPLACE_ALL_RESULTS = Command.toDefaultLocalizedCommand({
id: 'search.action.replaceAll'
});
}
@injectable()
export class SearchInWorkspaceFrontendContribution extends AbstractViewContribution<SearchInWorkspaceWidget> implements
FrontendApplicationContribution,
TabBarToolbarContribution,
StylingParticipant {
@inject(SelectionService) protected readonly selectionService: SelectionService;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(FileService) protected readonly fileService: FileService;
@inject(EditorManager) protected readonly editorManager: EditorManager;
@inject(ClipboardService) protected readonly clipboardService: ClipboardService;
@inject(SearchInWorkspaceContextKeyService)
protected readonly contextKeyService: SearchInWorkspaceContextKeyService;
constructor() {
super({
viewContainerId: SEARCH_VIEW_CONTAINER_ID,
widgetId: SearchInWorkspaceWidget.ID,
widgetName: SearchInWorkspaceWidget.LABEL,
defaultWidgetOptions: {
area: 'left',
rank: 200
},
toggleCommandId: SearchInWorkspaceCommands.TOGGLE_SIW_WIDGET.id
});
}
@postConstruct()
protected init(): void {
const updateFocusContextKey = () =>
this.contextKeyService.searchViewletFocus.set(this.shell.activeWidget instanceof SearchInWorkspaceWidget);
updateFocusContextKey();
this.shell.onDidChangeActiveWidget(updateFocusContextKey);
}
async initializeLayout(app: FrontendApplication): Promise<void> {
await this.openView({ activate: false });
}
override async registerCommands(commands: CommandRegistry): Promise<void> {
super.registerCommands(commands);
commands.registerCommand(SearchInWorkspaceCommands.OPEN_SIW_WIDGET, {
isEnabled: () => this.workspaceService.tryGetRoots().length > 0,
execute: async () => {
const widget = await this.openView({ activate: true });
widget.updateSearchTerm(this.getSearchTerm());
}
});
commands.registerCommand(SearchInWorkspaceCommands.REPLACE_IN_FILES, {
isEnabled: () => this.workspaceService.tryGetRoots().length > 0,
execute: async () => {
const widget = await this.openView({ activate: true });
widget.updateSearchTerm(this.getSearchTerm(), true);
}
});
commands.registerCommand(SearchInWorkspaceCommands.FOCUS_NEXT_RESULT, {
isEnabled: () => this.withWidget(undefined, widget => widget.hasResultList()),
execute: async () => {
const widget = await this.openView({ reveal: true });
widget.resultTreeWidget.selectNextResult();
}
});
commands.registerCommand(SearchInWorkspaceCommands.FOCUS_PREV_RESULT, {
isEnabled: () => this.withWidget(undefined, widget => widget.hasResultList()),
execute: async () => {
const widget = await this.openView({ reveal: true });
widget.resultTreeWidget.selectPreviousResult();
}
});
commands.registerCommand(SearchInWorkspaceCommands.FIND_IN_FOLDER, this.newMultiUriAwareCommandHandler({
execute: async uris => {
const resources: string[] = [];
for (const { stat } of await this.fileService.resolveAll(uris.map(resource => ({ resource })))) {
if (stat) {
const uri = stat.resource;
let uriStr = this.labelProvider.getLongName(uri);
if (stat && !stat.isDirectory) {
uriStr = this.labelProvider.getLongName(uri.parent);
}
resources.push(uriStr);
}
}
const widget = await this.openView({ activate: true });
widget.findInFolder(resources);
}
}));
commands.registerCommand(SearchInWorkspaceCommands.CANCEL_SEARCH, {
execute: w => this.withWidget(w, widget => widget.getCancelIndicator() && widget.getCancelIndicator()!.cancel()),
isEnabled: w => this.withWidget(w, widget => widget.getCancelIndicator() !== undefined),
isVisible: w => this.withWidget(w, widget => widget.getCancelIndicator() !== undefined)
});
commands.registerCommand(SearchInWorkspaceCommands.REFRESH_RESULTS, {
execute: w => this.withWidget(w, widget => widget.refresh()),
isEnabled: w => this.withWidget(w, widget => (widget.hasResultList() || widget.hasSearchTerm()) && this.workspaceService.tryGetRoots().length > 0),
isVisible: w => this.withWidget(w, () => true)
});
commands.registerCommand(SearchInWorkspaceCommands.COLLAPSE_ALL, {
execute: w => this.withWidget(w, widget => widget.collapseAll()),
isEnabled: w => this.withWidget(w, widget => widget.hasResultList()),
isVisible: w => this.withWidget(w, widget => !widget.areResultsCollapsed())
});
commands.registerCommand(SearchInWorkspaceCommands.EXPAND_ALL, {
execute: w => this.withWidget(w, widget => widget.expandAll()),
isEnabled: w => this.withWidget(w, widget => widget.hasResultList()),
isVisible: w => this.withWidget(w, widget => widget.areResultsCollapsed())
});
commands.registerCommand(SearchInWorkspaceCommands.CLEAR_ALL, {
execute: w => this.withWidget(w, widget => widget.clear()),
isEnabled: w => this.withWidget(w, widget => widget.hasResultList()),
isVisible: w => this.withWidget(w, () => true)
});
commands.registerCommand(SearchInWorkspaceCommands.DISMISS_RESULT, {
isEnabled: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
return TreeWidgetSelection.isSource(selection, widget.resultTreeWidget) && selection.length > 0;
}),
isVisible: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
return TreeWidgetSelection.isSource(selection, widget.resultTreeWidget) && selection.length > 0;
}),
execute: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
if (TreeWidgetSelection.is(selection)) {
selection.forEach(n => widget.resultTreeWidget.removeNode(n));
}
})
});
commands.registerCommand(SearchInWorkspaceCommands.REPLACE_RESULT, {
isEnabled: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
return TreeWidgetSelection.isSource(selection, widget.resultTreeWidget) && selection.length > 0 && !SearchInWorkspaceFileNode.is(selection[0]);
}),
isVisible: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
return TreeWidgetSelection.isSource(selection, widget.resultTreeWidget) && selection.length > 0 && !SearchInWorkspaceFileNode.is(selection[0]);
}),
execute: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
if (TreeWidgetSelection.is(selection)) {
selection.forEach(n => widget.resultTreeWidget.replace(n));
}
}),
});
commands.registerCommand(SearchInWorkspaceCommands.REPLACE_ALL_RESULTS, {
isEnabled: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
return TreeWidgetSelection.isSource(selection, widget.resultTreeWidget) && selection.length > 0
&& SearchInWorkspaceFileNode.is(selection[0]);
}),
isVisible: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
return TreeWidgetSelection.isSource(selection, widget.resultTreeWidget) && selection.length > 0
&& SearchInWorkspaceFileNode.is(selection[0]);
}),
execute: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
if (TreeWidgetSelection.is(selection)) {
selection.forEach(n => widget.resultTreeWidget.replace(n));
}
}),
});
commands.registerCommand(SearchInWorkspaceCommands.COPY_ONE, {
isEnabled: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
return TreeWidgetSelection.isSource(selection, widget.resultTreeWidget) && selection.length > 0;
}),
isVisible: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
return TreeWidgetSelection.isSource(selection, widget.resultTreeWidget) && selection.length > 0;
}),
execute: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
if (TreeWidgetSelection.is(selection)) {
const string = widget.resultTreeWidget.nodeToString(selection[0], true);
if (string.length !== 0) {
this.clipboardService.writeText(string);
}
}
})
});
commands.registerCommand(SearchInWorkspaceCommands.COPY_ALL, {
isEnabled: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
return TreeWidgetSelection.isSource(selection, widget.resultTreeWidget) && selection.length > 0;
}),
isVisible: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
return TreeWidgetSelection.isSource(selection, widget.resultTreeWidget) && selection.length > 0;
}),
execute: () => this.withWidget(undefined, widget => {
const { selection } = this.selectionService;
if (TreeWidgetSelection.is(selection)) {
const string = widget.resultTreeWidget.treeToString();
if (string.length !== 0) {
this.clipboardService.writeText(string);
}
}
})
});
}
protected withWidget<T>(widget: Widget | undefined = this.tryGetWidget(), fn: (widget: SearchInWorkspaceWidget) => T): T | false {
if (widget instanceof SearchInWorkspaceWidget && widget.id === SearchInWorkspaceWidget.ID) {
return fn(widget);
}
return false;
}
/**
* Get the search term based on current editor selection.
* @returns the selection if available.
*/
protected getSearchTerm(): string {
if (!this.editorManager.currentEditor) {
return '';
}
// Get the current editor selection.
const selection = this.editorManager.currentEditor.editor.selection;
// Compute the selection range.
const selectedRange: Range = Range.create(
selection.start.line,
selection.start.character,
selection.end.line,
selection.end.character
);
// Return the selection text if available, else return empty.
return this.editorManager.currentEditor
? this.editorManager.currentEditor.editor.document.getText(selectedRange)
: '';
}
override registerKeybindings(keybindings: KeybindingRegistry): void {
super.registerKeybindings(keybindings);
keybindings.registerKeybinding({
command: SearchInWorkspaceCommands.OPEN_SIW_WIDGET.id,
keybinding: 'ctrlcmd+shift+f'
});
keybindings.registerKeybinding({
command: SearchInWorkspaceCommands.FIND_IN_FOLDER.id,
keybinding: 'shift+alt+f',
when: 'explorerResourceIsFolder'
});
keybindings.registerKeybinding({
command: SearchInWorkspaceCommands.FOCUS_NEXT_RESULT.id,
keybinding: 'f4',
when: 'hasSearchResult'
});
keybindings.registerKeybinding({
command: SearchInWorkspaceCommands.FOCUS_PREV_RESULT.id,
keybinding: 'shift+f4',
when: 'hasSearchResult'
});
keybindings.registerKeybinding({
command: SearchInWorkspaceCommands.DISMISS_RESULT.id,
keybinding: isOSX ? 'cmd+backspace' : 'del',
when: 'searchViewletFocus && !inputBoxFocus'
});
keybindings.registerKeybinding({
command: SearchInWorkspaceCommands.REPLACE_RESULT.id,
keybinding: 'ctrlcmd+shift+1',
when: 'searchViewletFocus && replaceActive',
});
keybindings.registerKeybinding({
command: SearchInWorkspaceCommands.REPLACE_ALL_RESULTS.id,
keybinding: 'ctrlcmd+shift+1',
when: 'searchViewletFocus && replaceActive',
});
keybindings.registerKeybinding({
command: SearchInWorkspaceCommands.COPY_ONE.id,
keybinding: 'ctrlcmd+c',
when: 'searchViewletFocus && !inputBoxFocus'
});
}
override registerMenus(menus: MenuModelRegistry): void {
super.registerMenus(menus);
menus.registerMenuAction(NavigatorContextMenu.SEARCH, {
commandId: SearchInWorkspaceCommands.FIND_IN_FOLDER.id,
when: 'explorerResourceIsFolder'
});
menus.registerMenuAction(CommonMenus.EDIT_FIND, {
commandId: SearchInWorkspaceCommands.OPEN_SIW_WIDGET.id,
order: '2'
});
menus.registerMenuAction(CommonMenus.EDIT_FIND, {
commandId: SearchInWorkspaceCommands.REPLACE_IN_FILES.id,
order: '3'
});
menus.registerMenuAction(SearchInWorkspaceResultTreeWidget.Menus.INTERNAL, {
commandId: SearchInWorkspaceCommands.REPLACE_RESULT.id,
label: nls.localizeByDefault('Replace'),
order: '1',
when: 'replaceActive',
});
menus.registerMenuAction(SearchInWorkspaceResultTreeWidget.Menus.INTERNAL, {
commandId: SearchInWorkspaceCommands.REPLACE_ALL_RESULTS.id,
label: nls.localizeByDefault('Replace All'),
order: '1',
when: 'replaceActive',
});
menus.registerMenuAction(SearchInWorkspaceResultTreeWidget.Menus.INTERNAL, {
commandId: SearchInWorkspaceCommands.DISMISS_RESULT.id,
order: '1'
});
menus.registerMenuAction(SearchInWorkspaceResultTreeWidget.Menus.COPY, {
commandId: SearchInWorkspaceCommands.COPY_ONE.id,
order: '1',
});
menus.registerMenuAction(SearchInWorkspaceResultTreeWidget.Menus.COPY, {
commandId: CommonCommands.COPY_PATH.id,
order: '2',
});
menus.registerMenuAction(SearchInWorkspaceResultTreeWidget.Menus.COPY, {
commandId: SearchInWorkspaceCommands.COPY_ALL.id,
order: '3',
});
menus.registerMenuAction(SearchInWorkspaceResultTreeWidget.Menus.EXTERNAL, {
commandId: FileNavigatorCommands.REVEAL_IN_NAVIGATOR.id,
order: '1',
});
}
async registerToolbarItems(toolbarRegistry: TabBarToolbarRegistry): Promise<void> {
const widget = await this.widget;
const onDidChange = widget.onDidUpdate;
toolbarRegistry.registerItem({
id: SearchInWorkspaceCommands.CANCEL_SEARCH.id,
command: SearchInWorkspaceCommands.CANCEL_SEARCH.id,
tooltip: SearchInWorkspaceCommands.CANCEL_SEARCH.label,
priority: 0,
onDidChange
});
toolbarRegistry.registerItem({
id: SearchInWorkspaceCommands.REFRESH_RESULTS.id,
command: SearchInWorkspaceCommands.REFRESH_RESULTS.id,
tooltip: SearchInWorkspaceCommands.REFRESH_RESULTS.label,
priority: 1,
onDidChange
});
toolbarRegistry.registerItem({
id: SearchInWorkspaceCommands.CLEAR_ALL.id,
command: SearchInWorkspaceCommands.CLEAR_ALL.id,
tooltip: SearchInWorkspaceCommands.CLEAR_ALL.label,
priority: 2,
onDidChange
});
toolbarRegistry.registerItem({
id: SearchInWorkspaceCommands.COLLAPSE_ALL.id,
command: SearchInWorkspaceCommands.COLLAPSE_ALL.id,
tooltip: SearchInWorkspaceCommands.COLLAPSE_ALL.label,
priority: 3,
onDidChange
});
toolbarRegistry.registerItem({
id: SearchInWorkspaceCommands.EXPAND_ALL.id,
command: SearchInWorkspaceCommands.EXPAND_ALL.id,
tooltip: SearchInWorkspaceCommands.EXPAND_ALL.label,
priority: 3,
onDidChange
});
}
protected newUriAwareCommandHandler(handler: UriCommandHandler<URI>): UriAwareCommandHandler<URI> {
return UriAwareCommandHandler.MonoSelect(this.selectionService, handler);
}
protected newMultiUriAwareCommandHandler(handler: UriCommandHandler<URI[]>): UriAwareCommandHandler<URI[]> {
return UriAwareCommandHandler.MultiSelect(this.selectionService, handler);
}
registerThemeStyle(theme: ColorTheme, collector: CssStyleCollector): void {
const contrastBorder = theme.getColor('contrastBorder');
if (contrastBorder && isHighContrast(theme.type)) {
collector.addRule(`
.t-siw-search-container .searchHeader .search-field-container {
border-color: ${contrastBorder};
}
`);
}
}
}

View File

@@ -0,0 +1,84 @@
// *****************************************************************************
// Copyright (C) 2017-2018 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 '../../src/browser/styles/index.css';
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { SearchInWorkspaceService, SearchInWorkspaceClientImpl } from './search-in-workspace-service';
import { SearchInWorkspaceServer, SIW_WS_PATH } from '../common/search-in-workspace-interface';
import {
WidgetFactory, createTreeContainer, bindViewContribution, FrontendApplicationContribution, LabelProviderContribution,
ApplicationShellLayoutMigration,
StylingParticipant, RemoteConnectionProvider, ServiceConnectionProvider
} from '@theia/core/lib/browser';
import { SearchInWorkspaceWidget } from './search-in-workspace-widget';
import { SearchInWorkspaceResultTreeWidget } from './search-in-workspace-result-tree-widget';
import { SearchInWorkspaceFrontendContribution } from './search-in-workspace-frontend-contribution';
import { SearchInWorkspaceContextKeyService } from './search-in-workspace-context-key-service';
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { bindSearchInWorkspacePreferences } from '../common/search-in-workspace-preferences';
import { SearchInWorkspaceLabelProvider } from './search-in-workspace-label-provider';
import { SearchInWorkspaceFactory } from './search-in-workspace-factory';
import { SearchLayoutVersion3Migration } from './search-layout-migrations';
export default new ContainerModule(bind => {
bind(SearchInWorkspaceContextKeyService).toSelf().inSingletonScope();
bind(SearchInWorkspaceWidget).toSelf();
bind<WidgetFactory>(WidgetFactory).toDynamicValue(ctx => ({
id: SearchInWorkspaceWidget.ID,
createWidget: () => ctx.container.get(SearchInWorkspaceWidget)
}));
bind(SearchInWorkspaceResultTreeWidget).toDynamicValue(ctx => createSearchTreeWidget(ctx.container));
bind(SearchInWorkspaceFactory).toSelf().inSingletonScope();
bind(WidgetFactory).toService(SearchInWorkspaceFactory);
bind(ApplicationShellLayoutMigration).to(SearchLayoutVersion3Migration).inSingletonScope();
bindViewContribution(bind, SearchInWorkspaceFrontendContribution);
bind(FrontendApplicationContribution).toService(SearchInWorkspaceFrontendContribution);
bind(TabBarToolbarContribution).toService(SearchInWorkspaceFrontendContribution);
bind(StylingParticipant).toService(SearchInWorkspaceFrontendContribution);
// The object that gets notified of search results.
bind(SearchInWorkspaceClientImpl).toSelf().inSingletonScope();
bind(SearchInWorkspaceService).toSelf().inSingletonScope();
// The object to call methods on the backend.
bind(SearchInWorkspaceServer).toDynamicValue(ctx => {
const client = ctx.container.get(SearchInWorkspaceClientImpl);
const provider = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
return provider.createProxy<SearchInWorkspaceServer>(SIW_WS_PATH, client);
}).inSingletonScope();
bindSearchInWorkspacePreferences(bind);
bind(SearchInWorkspaceLabelProvider).toSelf().inSingletonScope();
bind(LabelProviderContribution).toService(SearchInWorkspaceLabelProvider);
});
export function createSearchTreeWidget(parent: interfaces.Container): SearchInWorkspaceResultTreeWidget {
const child = createTreeContainer(parent, {
widget: SearchInWorkspaceResultTreeWidget,
props: {
contextMenuPath: SearchInWorkspaceResultTreeWidget.Menus.BASE,
multiSelect: true,
globalSelection: true
}
});
return child.get(SearchInWorkspaceResultTreeWidget);
}

View File

@@ -0,0 +1,48 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox 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 { injectable, inject } from '@theia/core/shared/inversify';
import { LabelProviderContribution, LabelProvider, DidChangeLabelEvent } from '@theia/core/lib/browser/label-provider';
import { SearchInWorkspaceRootFolderNode, SearchInWorkspaceFileNode } from './search-in-workspace-result-tree-widget';
import URI from '@theia/core/lib/common/uri';
@injectable()
export class SearchInWorkspaceLabelProvider implements LabelProviderContribution {
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
canHandle(element: object): number {
return SearchInWorkspaceRootFolderNode.is(element) || SearchInWorkspaceFileNode.is(element) ? 100 : 0;
}
getIcon(node: SearchInWorkspaceRootFolderNode | SearchInWorkspaceFileNode): string {
if (SearchInWorkspaceFileNode.is(node)) {
return this.labelProvider.getIcon(new URI(node.fileUri).withScheme('file'));
}
return this.labelProvider.folderIcon;
}
getName(node: SearchInWorkspaceRootFolderNode | SearchInWorkspaceFileNode): string {
const uri = SearchInWorkspaceFileNode.is(node) ? node.fileUri : node.folderUri;
return new URI(uri).displayName;
}
affects(node: SearchInWorkspaceRootFolderNode | SearchInWorkspaceFileNode, event: DidChangeLabelEvent): boolean {
return SearchInWorkspaceFileNode.is(node) && event.affects(new URI(node.fileUri).withScheme('file'));
}
}

View File

@@ -0,0 +1,153 @@
// *****************************************************************************
// Copyright (C) 2017-2018 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 { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import {
SearchInWorkspaceServer,
SearchInWorkspaceClient,
SearchInWorkspaceResult,
SearchInWorkspaceOptions
} from '../common/search-in-workspace-interface';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { ILogger } from '@theia/core';
/**
* Class that will receive the search results from the server. This is separate
* from the SearchInWorkspaceService class only to avoid a cycle in the
* dependency injection.
*/
@injectable()
export class SearchInWorkspaceClientImpl implements SearchInWorkspaceClient {
private service: SearchInWorkspaceClient;
onResult(searchId: number, result: SearchInWorkspaceResult): void {
this.service.onResult(searchId, result);
}
onDone(searchId: number, error?: string): void {
this.service.onDone(searchId, error);
}
setService(service: SearchInWorkspaceClient): void {
this.service = service;
}
}
export type SearchInWorkspaceCallbacks = SearchInWorkspaceClient;
/**
* Service to search text in the workspace files.
*/
@injectable()
export class SearchInWorkspaceService implements SearchInWorkspaceClient {
// All the searches that we have started, that are not done yet (onDone
// with that searchId has not been called).
protected pendingSearches = new Map<number, SearchInWorkspaceCallbacks>();
// Due to the asynchronicity of the node backend, it's possible that we
// start a search, receive an event for that search, and then receive
// the search id for that search.We therefore need to keep those
// events until we get the search id and return it to the caller.
// Otherwise the caller would discard the event because it doesn't know
// the search id yet.
protected pendingOnDones: Map<number, string | undefined> = new Map();
protected lastKnownSearchId: number = -1;
@inject(SearchInWorkspaceServer) protected readonly searchServer: SearchInWorkspaceServer;
@inject(SearchInWorkspaceClientImpl) protected readonly client: SearchInWorkspaceClientImpl;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(ILogger) protected readonly logger: ILogger;
@postConstruct()
protected init(): void {
this.client.setService(this);
}
isEnabled(): boolean {
return this.workspaceService.opened;
}
onResult(searchId: number, result: SearchInWorkspaceResult): void {
const callbacks = this.pendingSearches.get(searchId);
if (callbacks) {
callbacks.onResult(searchId, result);
}
}
onDone(searchId: number, error?: string): void {
const callbacks = this.pendingSearches.get(searchId);
if (callbacks) {
this.pendingSearches.delete(searchId);
callbacks.onDone(searchId, error);
} else {
if (searchId > this.lastKnownSearchId) {
this.logger.debug(`Got an onDone for a searchId we don't know about (${searchId}), stashing it for later with error = `, error);
this.pendingOnDones.set(searchId, error);
} else {
// It's possible to receive an onDone for a search we have cancelled. Just ignore it.
this.logger.debug(`Got an onDone for a searchId we don't know about (${searchId}), but it's probably an old one, error = `, error);
}
}
}
// Start a search of the string "what" in the workspace.
async search(what: string, callbacks: SearchInWorkspaceCallbacks, opts?: SearchInWorkspaceOptions): Promise<number> {
if (!this.workspaceService.opened) {
throw new Error('Search failed: no workspace root.');
}
const roots = await this.workspaceService.roots;
return this.doSearch(what, roots.map(r => r.resource.toString()), callbacks, opts);
}
protected async doSearch(what: string, rootUris: string[], callbacks: SearchInWorkspaceCallbacks, opts?: SearchInWorkspaceOptions): Promise<number> {
const searchId = await this.searchServer.search(what, rootUris, opts);
this.pendingSearches.set(searchId, callbacks);
this.lastKnownSearchId = searchId;
this.logger.debug('Service launched search ' + searchId);
// Check if we received an onDone before search() returned.
if (this.pendingOnDones.has(searchId)) {
this.logger.debug('Ohh, we have a stashed onDone for that searchId');
const error = this.pendingOnDones.get(searchId);
this.pendingOnDones.delete(searchId);
// Call the client's searchId, but first give it a
// chance to record the returned searchId.
setTimeout(() => {
this.onDone(searchId, error);
}, 0);
}
return searchId;
}
async searchWithCallback(what: string, rootUris: string[], callbacks: SearchInWorkspaceClient, opts?: SearchInWorkspaceOptions | undefined): Promise<number> {
return this.doSearch(what, rootUris, callbacks, opts);
}
// Cancel an ongoing search.
cancel(searchId: number): void {
this.pendingSearches.delete(searchId);
this.searchServer.cancel(searchId);
}
}

View File

@@ -0,0 +1,732 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { Widget, Message, BaseWidget, Key, StatefulWidget, MessageLoop, KeyCode, codicon } from '@theia/core/lib/browser';
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { SearchInWorkspaceResultTreeWidget } from './search-in-workspace-result-tree-widget';
import { SearchInWorkspaceOptions } from '../common/search-in-workspace-interface';
import * as React from '@theia/core/shared/react';
import { createRoot, Root } from '@theia/core/shared/react-dom/client';
import { Event, Emitter, Disposable } from '@theia/core/lib/common';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { SearchInWorkspaceContextKeyService } from './search-in-workspace-context-key-service';
import { CancellationTokenSource } from '@theia/core';
import { ProgressBarFactory } from '@theia/core/lib/browser/progress-bar-factory';
import { EditorManager } from '@theia/editor/lib/browser';
import { SearchInWorkspacePreferences } from '../common/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';
import { Deferred } from '@theia/core/lib/common/promise-util';
export interface SearchFieldState {
className: string;
enabled: boolean;
title: string;
}
@injectable()
export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidget {
static ID = 'search-in-workspace';
static LABEL = nls.localizeByDefault('Search');
protected matchCaseState: SearchFieldState;
protected wholeWordState: SearchFieldState;
protected regExpState: SearchFieldState;
protected includeIgnoredState: SearchFieldState;
protected showSearchDetails = false;
protected _hasResults = false;
protected get hasResults(): boolean {
return this._hasResults;
}
protected set hasResults(hasResults: boolean) {
this.contextKeyService.hasSearchResult.set(hasResults);
this._hasResults = hasResults;
}
protected resultNumber = 0;
protected searchFieldContainerIsFocused = false;
protected searchInWorkspaceOptions: SearchInWorkspaceOptions;
protected searchTerm = '';
protected replaceTerm = '';
private searchRef = React.createRef<SearchInWorkspaceTextArea>();
private replaceRef = React.createRef<SearchInWorkspaceTextArea>();
private includeRef = React.createRef<SearchInWorkspaceInput>();
private excludeRef = React.createRef<SearchInWorkspaceInput>();
private refsAreSet = new Deferred();
protected _showReplaceField = false;
protected get showReplaceField(): boolean {
return this._showReplaceField;
}
protected set showReplaceField(showReplaceField: boolean) {
this.contextKeyService.replaceActive.set(showReplaceField);
this._showReplaceField = showReplaceField;
}
protected contentNode: HTMLElement;
protected searchFormContainer: HTMLElement;
protected resultContainer: HTMLElement;
protected readonly onDidUpdateEmitter = new Emitter<void>();
readonly onDidUpdate: Event<void> = this.onDidUpdateEmitter.event;
@inject(SearchInWorkspaceResultTreeWidget) readonly resultTreeWidget: SearchInWorkspaceResultTreeWidget;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(SearchInWorkspaceContextKeyService)
protected readonly contextKeyService: SearchInWorkspaceContextKeyService;
@inject(ProgressBarFactory)
protected readonly progressBarFactory: ProgressBarFactory;
@inject(EditorManager) protected readonly editorManager: EditorManager;
@inject(SearchInWorkspacePreferences)
protected readonly searchInWorkspacePreferences: SearchInWorkspacePreferences;
protected searchFormContainerRoot: Root;
@postConstruct()
protected init(): void {
this.id = SearchInWorkspaceWidget.ID;
this.title.label = SearchInWorkspaceWidget.LABEL;
this.title.caption = SearchInWorkspaceWidget.LABEL;
this.title.iconClass = codicon('search');
this.title.closable = true;
this.contentNode = document.createElement('div');
this.contentNode.classList.add('t-siw-search-container');
this.searchFormContainer = document.createElement('div');
this.searchFormContainer.classList.add('searchHeader');
this.contentNode.appendChild(this.searchFormContainer);
this.searchFormContainerRoot = createRoot(this.searchFormContainer);
this.node.tabIndex = 0;
this.node.appendChild(this.contentNode);
this.matchCaseState = {
className: codicon('case-sensitive'),
enabled: false,
title: nls.localizeByDefault('Match Case')
};
this.wholeWordState = {
className: codicon('whole-word'),
enabled: false,
title: nls.localizeByDefault('Match Whole Word')
};
this.regExpState = {
className: codicon('regex'),
enabled: false,
title: nls.localizeByDefault('Use Regular Expression')
};
this.includeIgnoredState = {
className: codicon('eye'),
enabled: false,
title: nls.localize('theia/search-in-workspace/includeIgnoredFiles', 'Include Ignored Files')
};
this.searchInWorkspaceOptions = {
matchCase: false,
matchWholeWord: false,
useRegExp: false,
multiline: false,
includeIgnored: false,
include: [],
exclude: [],
maxResults: 2000
};
this.toDispose.push(this.resultTreeWidget.onChange(r => {
this.hasResults = r.size > 0;
this.resultNumber = 0;
const results = Array.from(r.values());
results.forEach(rootFolder =>
rootFolder.children.forEach(file => this.resultNumber += file.children.length)
);
this.update();
}));
this.toDispose.push(this.resultTreeWidget.onFocusInput(b => {
this.focusInputField();
}));
this.toDispose.push(this.searchInWorkspacePreferences.onPreferenceChanged(e => {
if (e.preferenceName === 'search.smartCase') {
this.performSearch();
}
}));
this.toDispose.push(this.resultTreeWidget);
this.toDispose.push(this.resultTreeWidget.onExpansionChanged(() => {
this.onDidUpdateEmitter.fire();
}));
this.toDispose.push(this.progressBarFactory({ container: this.node, insertMode: 'prepend', locationId: 'search' }));
}
storeState(): object {
return {
matchCaseState: this.matchCaseState,
wholeWordState: this.wholeWordState,
regExpState: this.regExpState,
includeIgnoredState: this.includeIgnoredState,
showSearchDetails: this.showSearchDetails,
searchInWorkspaceOptions: this.searchInWorkspaceOptions,
searchTerm: this.searchTerm,
replaceTerm: this.replaceTerm,
showReplaceField: this.showReplaceField,
searchHistoryState: this.searchRef.current?.state,
replaceHistoryState: this.replaceRef.current?.state,
includeHistoryState: this.includeRef.current?.state,
excludeHistoryState: this.excludeRef.current?.state,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
restoreState(oldState: any): void {
this.matchCaseState = oldState.matchCaseState;
this.wholeWordState = oldState.wholeWordState;
this.regExpState = oldState.regExpState;
this.includeIgnoredState = oldState.includeIgnoredState;
// Override the title of the restored state, as we could have changed languages in between
this.matchCaseState.title = nls.localizeByDefault('Match Case');
this.wholeWordState.title = nls.localizeByDefault('Match Whole Word');
this.regExpState.title = nls.localizeByDefault('Use Regular Expression');
this.includeIgnoredState.title = nls.localize('theia/search-in-workspace/includeIgnoredFiles', 'Include Ignored Files');
this.showSearchDetails = oldState.showSearchDetails;
this.searchInWorkspaceOptions = oldState.searchInWorkspaceOptions;
this.searchTerm = oldState.searchTerm;
this.replaceTerm = oldState.replaceTerm;
this.showReplaceField = oldState.showReplaceField;
this.resultTreeWidget.replaceTerm = this.replaceTerm;
this.resultTreeWidget.showReplaceButtons = this.showReplaceField;
this.searchRef.current?.setState(oldState.searchHistoryState);
this.replaceRef.current?.setState(oldState.replaceHistoryState);
this.includeRef.current?.setState(oldState.includeHistoryState);
this.excludeRef.current?.setState(oldState.excludeHistoryState);
this.refresh();
}
findInFolder(uris: string[]): void {
this.showSearchDetails = true;
const values = Array.from(new Set(uris.map(uri => `${uri}/**`)));
const value = values.join(', ');
this.searchInWorkspaceOptions.include = values;
if (this.includeRef.current) {
this.includeRef.current.value = value;
this.includeRef.current.addToHistory();
}
this.update();
}
/**
* Update the search term and input field.
* @param term the search term.
* @param showReplaceField controls if the replace field should be displayed.
*/
updateSearchTerm(term: string, showReplaceField?: boolean): void {
this.searchTerm = term;
if (this.searchRef.current) {
this.searchRef.current.value = term;
this.searchRef.current.addToHistory();
}
if (showReplaceField) {
this.showReplaceField = true;
}
this.refresh();
}
hasResultList(): boolean {
return this.hasResults;
}
hasSearchTerm(): boolean {
return this.searchTerm !== '';
}
refresh(): void {
this.performSearch();
this.update();
}
getCancelIndicator(): CancellationTokenSource | undefined {
return this.resultTreeWidget.cancelIndicator;
}
collapseAll(): void {
this.resultTreeWidget.collapseAll();
this.update();
}
expandAll(): void {
this.resultTreeWidget.expandAll();
this.update();
}
areResultsCollapsed(): boolean {
return this.resultTreeWidget.areResultsCollapsed();
}
clear(): void {
this.searchTerm = '';
this.replaceTerm = '';
this.searchInWorkspaceOptions.include = [];
this.searchInWorkspaceOptions.exclude = [];
this.includeIgnoredState.enabled = false;
this.matchCaseState.enabled = false;
this.wholeWordState.enabled = false;
this.regExpState.enabled = false;
if (this.searchRef.current) {
this.searchRef.current.value = '';
}
if (this.replaceRef.current) {
this.replaceRef.current.value = '';
}
if (this.includeRef.current) {
this.includeRef.current.value = '';
}
if (this.excludeRef.current) {
this.excludeRef.current.value = '';
}
this.performSearch();
this.update();
}
protected override onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
this.searchFormContainerRoot.render(<React.Fragment>{this.renderSearchHeader()}{this.renderSearchInfo()}</React.Fragment>);
Widget.attach(this.resultTreeWidget, this.contentNode);
this.toDisposeOnDetach.push(Disposable.create(() => {
Widget.detach(this.resultTreeWidget);
}));
}
protected override onUpdateRequest(msg: Message): void {
super.onUpdateRequest(msg);
const searchInfo = this.renderSearchInfo();
if (searchInfo) {
this.searchFormContainerRoot.render(<React.Fragment>{this.renderSearchHeader()}{searchInfo}</React.Fragment>);
this.onDidUpdateEmitter.fire(undefined);
}
}
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);
}
protected override onAfterShow(msg: Message): void {
super.onAfterShow(msg);
this.focusInputField();
this.contextKeyService.searchViewletVisible.set(true);
}
protected override onAfterHide(msg: Message): void {
super.onAfterHide(msg);
this.contextKeyService.searchViewletVisible.set(false);
}
protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
this.focusInputField();
}
protected async focusInputField(): Promise<void> {
// Wait until React rendering is sufficiently progressed before trying to focus the input field.
await this.refsAreSet.promise;
if (this.searchRef.current?.textarea.current) {
this.searchRef.current.textarea.current.focus();
this.searchRef.current.textarea.current.select();
}
}
protected renderSearchHeader(): React.ReactNode {
const searchAndReplaceContainer = this.renderSearchAndReplace();
const searchDetails = this.renderSearchDetails();
return <div ref={() => this.refsAreSet.resolve()}>{searchAndReplaceContainer}{searchDetails}</div>;
}
protected renderSearchAndReplace(): React.ReactNode {
const toggleContainer = this.renderReplaceFieldToggle();
const searchField = this.renderSearchField();
const replaceField = this.renderReplaceField();
return <div className='search-and-replace-container'>
{toggleContainer}
<div className='search-and-replace-fields'>
{searchField}
{replaceField}
</div>
</div>;
}
protected renderReplaceFieldToggle(): React.ReactNode {
const toggle = <span className={codicon(this.showReplaceField ? 'chevron-down' : 'chevron-right')}></span>;
return <div
title={nls.localizeByDefault('Toggle Replace')}
className='replace-toggle'
tabIndex={0}
onClick={e => {
const elArr = document.getElementsByClassName('replace-toggle');
if (elArr && elArr.length > 0) {
(elArr[0] as HTMLElement).focus();
}
this.showReplaceField = !this.showReplaceField;
this.resultTreeWidget.showReplaceButtons = this.showReplaceField;
this.update();
}}>
{toggle}
</div>;
}
protected renderNotification(): React.ReactNode {
if (this.workspaceService.tryGetRoots().length <= 0 && this.editorManager.all.length <= 0) {
return <div className='search-notification show'>
<div>{nls.localize('theia/search-in-workspace/noFolderSpecified', 'You have not opened or specified a folder. Only open files are currently searched.')}</div>
</div>;
}
return <div
className={`search-notification ${this.searchInWorkspaceOptions.maxResults && this.resultNumber >= this.searchInWorkspaceOptions.maxResults ? 'show' : ''}`}>
<div>{nls.localize('theia/search-in-workspace/resultSubset',
'This is only a subset of all results. Use a more specific search term to narrow down the result list.')}</div>
</div>;
}
protected readonly focusSearchFieldContainer = () => this.doFocusSearchFieldContainer();
protected doFocusSearchFieldContainer(): void {
this.searchFieldContainerIsFocused = true;
this.update();
}
protected readonly blurSearchFieldContainer = () => this.doBlurSearchFieldContainer();
protected doBlurSearchFieldContainer(): void {
this.searchFieldContainerIsFocused = false;
this.update();
}
private _searchTimeout: number;
protected readonly search = (e: React.KeyboardEvent) => {
e.persist();
const searchOnType = this.searchInWorkspacePreferences['search.searchOnType'];
if (searchOnType) {
const delay = this.searchInWorkspacePreferences['search.searchOnTypeDebouncePeriod'] || 0;
window.clearTimeout(this._searchTimeout);
this._searchTimeout = window.setTimeout(() => this.doSearch(e), delay);
}
};
protected readonly onKeyDownSearch = (e: React.KeyboardEvent) => {
if (Key.ENTER.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode) {
this.performSearch();
}
};
protected doSearch(e: React.KeyboardEvent): void {
if (e.target) {
const searchValue = (e.target as HTMLInputElement).value;
if (this.searchTerm === searchValue) {
return;
} else {
this.searchTerm = searchValue;
this.performSearch();
}
}
}
protected performSearch(): void {
const searchOptions: SearchInWorkspaceOptions = {
...this.searchInWorkspaceOptions,
followSymlinks: this.shouldFollowSymlinks(),
matchCase: this.shouldMatchCase(),
multiline: this.searchTerm.includes('\n')
};
this.resultTreeWidget.search(this.searchTerm, searchOptions);
}
protected shouldFollowSymlinks(): boolean {
return this.searchInWorkspacePreferences['search.followSymlinks'];
}
/**
* Determine if search should be case sensitive.
*/
protected shouldMatchCase(): boolean {
if (this.matchCaseState.enabled) {
return this.matchCaseState.enabled;
}
// search.smartCase makes siw search case-sensitive if the search term contains uppercase letter(s).
return (
!!this.searchInWorkspacePreferences['search.smartCase']
&& this.searchTerm !== this.searchTerm.toLowerCase()
);
}
protected renderSearchField(): React.ReactNode {
const input = <SearchInWorkspaceTextArea
id='search-input-field'
className='theia-input'
title={SearchInWorkspaceWidget.LABEL}
placeholder={SearchInWorkspaceWidget.LABEL}
defaultValue={this.searchTerm}
autoComplete='off'
onKeyUp={this.search}
onKeyDown={this.onKeyDownSearch}
onFocus={this.handleFocusSearchInputBox}
onBlur={this.handleBlurSearchInputBox}
ref={this.searchRef}
/>;
const notification = this.renderNotification();
const optionContainer = this.renderOptionContainer();
const tooMany = this.searchInWorkspaceOptions.maxResults && this.resultNumber >= this.searchInWorkspaceOptions.maxResults ? 'tooManyResults' : '';
const className = `search-field-container ${tooMany} ${this.searchFieldContainerIsFocused ? 'focused' : ''}`;
return <div className={className}>
<div className='search-field' tabIndex={-1} onFocus={this.focusSearchFieldContainer} onBlur={this.blurSearchFieldContainer}>
{input}
{optionContainer}
</div>
{notification}
</div>;
}
protected handleFocusSearchInputBox = (event: React.FocusEvent<HTMLTextAreaElement>) => {
event.target.placeholder = SearchInWorkspaceWidget.LABEL + nls.localizeByDefault(' ({0} for history)', '⇅');
this.contextKeyService.setSearchInputBoxFocus(true);
};
protected handleBlurSearchInputBox = (event: React.FocusEvent<HTMLTextAreaElement>) => {
event.target.placeholder = SearchInWorkspaceWidget.LABEL;
this.contextKeyService.setSearchInputBoxFocus(false);
};
protected readonly updateReplaceTerm = (e: React.KeyboardEvent) => this.doUpdateReplaceTerm(e);
protected doUpdateReplaceTerm(e: React.KeyboardEvent): void {
if (e.target) {
this.replaceTerm = (e.target as HTMLInputElement).value;
this.resultTreeWidget.replaceTerm = this.replaceTerm;
if (KeyCode.createKeyCode(e.nativeEvent).key?.keyCode === Key.ENTER.keyCode) { this.performSearch(); }
this.update();
}
}
protected renderReplaceField(): React.ReactNode {
const replaceAllButtonContainer = this.renderReplaceAllButtonContainer();
const replace = nls.localizeByDefault('Replace');
return <div className={`replace-field${this.showReplaceField ? '' : ' hidden'}`}>
<SearchInWorkspaceTextArea
id='replace-input-field'
className='theia-input'
title={replace}
placeholder={replace}
defaultValue={this.replaceTerm}
autoComplete='off'
onKeyUp={this.updateReplaceTerm}
onFocus={this.handleFocusReplaceInputBox}
onBlur={this.handleBlurReplaceInputBox}
ref={this.replaceRef}
/>
{replaceAllButtonContainer}
</div>;
}
protected handleFocusReplaceInputBox = (event: React.FocusEvent<HTMLTextAreaElement>) => {
event.target.placeholder = nls.localizeByDefault('Replace') + nls.localizeByDefault(' ({0} for history)', '⇅');
this.contextKeyService.setReplaceInputBoxFocus(true);
};
protected handleBlurReplaceInputBox = (event: React.FocusEvent<HTMLTextAreaElement>) => {
event.target.placeholder = nls.localizeByDefault('Replace');
this.contextKeyService.setReplaceInputBoxFocus(false);
};
protected renderReplaceAllButtonContainer(): React.ReactNode {
// The `Replace All` button is enabled if there is a search term present with results.
const enabled: boolean = this.searchTerm !== '' && this.resultNumber > 0;
return <div className='replace-all-button-container'>
<span
title={nls.localizeByDefault('Replace All')}
className={`${codicon('replace-all', true)} ${enabled ? ' ' : ' disabled'}`}
onClick={() => {
if (enabled) {
this.resultTreeWidget.replace(undefined);
}
}}>
</span>
</div>;
}
protected renderOptionContainer(): React.ReactNode {
const matchCaseOption = this.renderOptionElement(this.matchCaseState);
const wholeWordOption = this.renderOptionElement(this.wholeWordState);
const regexOption = this.renderOptionElement(this.regExpState);
const includeIgnoredOption = this.renderOptionElement(this.includeIgnoredState);
return <div className='option-buttons'>{matchCaseOption}{wholeWordOption}{regexOption}{includeIgnoredOption}</div>;
}
protected renderOptionElement(opt: SearchFieldState): React.ReactNode {
return <span
className={`${opt.className} option action-label ${opt.enabled ? 'enabled' : ''}`}
title={opt.title}
onClick={() => this.handleOptionClick(opt)}></span>;
}
protected handleOptionClick(option: SearchFieldState): void {
option.enabled = !option.enabled;
this.updateSearchOptions();
this.searchFieldContainerIsFocused = true;
this.performSearch();
this.update();
}
protected updateSearchOptions(): void {
this.searchInWorkspaceOptions.matchCase = this.matchCaseState.enabled;
this.searchInWorkspaceOptions.matchWholeWord = this.wholeWordState.enabled;
this.searchInWorkspaceOptions.useRegExp = this.regExpState.enabled;
this.searchInWorkspaceOptions.includeIgnored = this.includeIgnoredState.enabled;
}
protected renderSearchDetails(): React.ReactNode {
const expandButton = this.renderExpandGlobFieldsButton();
const globFieldContainer = this.renderGlobFieldContainer();
return <div className='search-details'>{expandButton}{globFieldContainer}</div>;
}
protected renderGlobFieldContainer(): React.ReactNode {
const includeField = this.renderGlobField('include');
const excludeField = this.renderGlobField('exclude');
return <div className={`glob-field-container${!this.showSearchDetails ? ' hidden' : ''}`}>{includeField}{excludeField}</div>;
}
protected renderExpandGlobFieldsButton(): React.ReactNode {
return <div className='button-container'>
<span
title={nls.localizeByDefault('Toggle Search Details')}
className={codicon('ellipsis')}
onClick={() => {
this.showSearchDetails = !this.showSearchDetails;
this.update();
}}></span>
</div>;
}
protected renderGlobField(kind: 'include' | 'exclude'): React.ReactNode {
const currentValue = this.searchInWorkspaceOptions[kind];
const value = currentValue && currentValue.join(', ') || '';
return <div className='glob-field'>
<div className='label'>{nls.localizeByDefault('files to ' + kind)}</div>
<SearchInWorkspaceInput
className='theia-input'
type='text'
size={1}
defaultValue={value}
autoComplete='off'
id={kind + '-glob-field'}
placeholder={kind === 'include'
? nls.localizeByDefault('e.g. *.ts, src/**/include')
: nls.localizeByDefault('e.g. *.ts, src/**/exclude')
}
onKeyUp={e => {
if (e.target) {
const targetValue = (e.target as HTMLInputElement).value || '';
let shouldSearch = Key.ENTER.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode;
const currentOptions = (this.searchInWorkspaceOptions[kind] || []).slice().map(s => s.trim()).sort();
const candidateOptions = this.splitOnComma(targetValue).map(s => s.trim()).sort();
const sameAs = (left: string[], right: string[]) => {
if (left.length !== right.length) {
return false;
}
for (let i = 0; i < left.length; i++) {
if (left[i] !== right[i]) {
return false;
}
}
return true;
};
if (!sameAs(currentOptions, candidateOptions)) {
this.searchInWorkspaceOptions[kind] = this.splitOnComma(targetValue);
shouldSearch = true;
}
if (shouldSearch) {
this.performSearch();
}
}
}}
onFocus={kind === 'include' ? this.handleFocusIncludesInputBox : this.handleFocusExcludesInputBox}
onBlur={kind === 'include' ? this.handleBlurIncludesInputBox : this.handleBlurExcludesInputBox}
ref={kind === 'include' ? this.includeRef : this.excludeRef}
/>
</div>;
}
protected handleFocusIncludesInputBox = () => this.contextKeyService.setPatternIncludesInputBoxFocus(true);
protected handleBlurIncludesInputBox = () => this.contextKeyService.setPatternIncludesInputBoxFocus(false);
protected handleFocusExcludesInputBox = () => this.contextKeyService.setPatternExcludesInputBoxFocus(true);
protected handleBlurExcludesInputBox = () => this.contextKeyService.setPatternExcludesInputBoxFocus(false);
protected splitOnComma(patterns: string): string[] {
return patterns.length > 0 ? patterns.split(',').map(s => s.trim()) : [];
}
protected renderSearchInfo(): React.ReactNode {
const message = this.getSearchResultMessage() || '';
return <div className='search-info'>{message}</div>;
}
protected getSearchResultMessage(): string | undefined {
if (!this.searchTerm) {
return undefined;
}
if (this.resultNumber === 0) {
const isIncludesPresent = this.searchInWorkspaceOptions.include && this.searchInWorkspaceOptions.include.length > 0;
const isExcludesPresent = this.searchInWorkspaceOptions.exclude && this.searchInWorkspaceOptions.exclude.length > 0;
let message: string;
if (isIncludesPresent && isExcludesPresent) {
message = nls.localizeByDefault("No results found in '{0}' excluding '{1}' - ",
this.searchInWorkspaceOptions.include!.toString(), this.searchInWorkspaceOptions.exclude!.toString());
} else if (isIncludesPresent) {
message = nls.localizeByDefault("No results found in '{0}' - ",
this.searchInWorkspaceOptions.include!.toString());
} else if (isExcludesPresent) {
message = nls.localizeByDefault("No results found excluding '{0}' - ",
this.searchInWorkspaceOptions.exclude!.toString());
} else {
message = nls.localizeByDefault('No results found') + ' - ';
}
// We have to trim here as vscode will always add a trailing " - " string
return message.substring(0, message.length - 2).trim();
} else {
if (this.resultNumber === 1 && this.resultTreeWidget.fileNumber === 1) {
return nls.localizeByDefault('{0} result in {1} file',
this.resultNumber.toString(), this.resultTreeWidget.fileNumber.toString());
} else if (this.resultTreeWidget.fileNumber === 1) {
return nls.localizeByDefault('{0} results in {1} file',
this.resultNumber.toString(), this.resultTreeWidget.fileNumber.toString());
} else if (this.resultTreeWidget.fileNumber > 0) {
return nls.localizeByDefault('{0} results in {1} files',
this.resultNumber.toString(), this.resultTreeWidget.fileNumber.toString());
} else {
// if fileNumber === 0, return undefined so that `onUpdateRequest()` would not re-render component
return undefined;
}
}
}
}

View File

@@ -0,0 +1,53 @@
// *****************************************************************************
// Copyright (C) 2021 SAP SE or an SAP affiliate company 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 { injectable } from '@theia/core/shared/inversify';
import { ApplicationShellLayoutMigration, WidgetDescription, ApplicationShellLayoutMigrationContext } from '@theia/core/lib/browser/shell/shell-layout-restorer';
import { SearchInWorkspaceWidget } from './search-in-workspace-widget';
import { SEARCH_VIEW_CONTAINER_ID, SEARCH_VIEW_CONTAINER_TITLE_OPTIONS } from './search-in-workspace-factory';
@injectable()
export class SearchLayoutVersion3Migration implements ApplicationShellLayoutMigration {
readonly layoutVersion = 6.0;
onWillInflateWidget(desc: WidgetDescription, { parent }: ApplicationShellLayoutMigrationContext): WidgetDescription | undefined {
if (desc.constructionOptions.factoryId === SearchInWorkspaceWidget.ID && !parent) {
return {
constructionOptions: {
factoryId: SEARCH_VIEW_CONTAINER_ID
},
innerWidgetState: {
parts: [
{
widget: {
constructionOptions: {
factoryId: SearchInWorkspaceWidget.ID
},
innerWidgetState: desc.innerWidgetState
},
partId: {
factoryId: SearchInWorkspaceWidget.ID
},
collapsed: false,
hidden: false
}
],
title: SEARCH_VIEW_CONTAINER_TITLE_OPTIONS
}
};
}
return undefined;
}
}

View File

@@ -0,0 +1,400 @@
/********************************************************************************
* Copyright (C) 2017-2018 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
********************************************************************************/
#search-in-workspace {
height: 100%;
display: flex;
flex-flow: column nowrap;
}
#search-in-workspace .theia-TreeContainer.empty {
overflow: hidden;
}
.t-siw-search-container {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
flex: 1 1 auto;
}
.t-siw-search-container .theia-ExpansionToggle {
padding-right: 4px;
min-width: 6px;
}
.t-siw-search-container .theia-input {
box-sizing: border-box;
flex: 1;
line-height: var(--theia-content-line-height);
max-height: calc(2 * 3px + 7 * var(--theia-content-line-height));
min-width: 16px;
min-height: 26px;
resize: none;
width: 100%;
}
.t-siw-search-container #search-input-field:focus {
border: none;
outline: none;
}
.t-siw-search-container #search-input-field {
background: none;
border: none;
padding-block: 2px;
}
.t-siw-search-container .searchHeader {
padding: var(--theia-ui-padding)
max(var(--theia-scrollbar-width), var(--theia-ui-padding))
calc(var(--theia-ui-padding) * 2)
0;
}
#theia-main-content-panel .t-siw-search-container .searchHeader {
padding-top: 10px;
}
.t-siw-search-container .searchHeader .controls.button-container {
height: var(--theia-content-line-height);
margin-bottom: 5px;
}
.t-siw-search-container .searchHeader .search-field-container {
background: var(--theia-input-background);
border-style: solid;
border-width: var(--theia-border-width);
border-color: var(--theia-input-background);
border-radius: 2px;
}
.t-siw-search-container .searchHeader .search-field-container.focused {
border-color: var(--theia-focusBorder);
}
.t-siw-search-container .searchHeader .search-field {
display: flex;
align-items: center;
}
.t-siw-search-container .searchHeader .search-field:focus {
border: none;
outline: none;
}
.t-siw-search-container .searchHeader .search-field .option {
opacity: 0.7;
cursor: pointer;
}
.t-siw-search-container .searchHeader .search-field .option.enabled {
color: var(--theia-inputOption-activeForeground);
border: var(--theia-border-width) var(--theia-inputOption-activeBorder) solid;
background-color: var(--theia-inputOption-activeBackground);
opacity: 1;
}
.t-siw-search-container .searchHeader .search-field .option:hover {
opacity: 1;
}
.t-siw-search-container .searchHeader .search-field .option-buttons {
display: flex;
align-items: center;
align-self: flex-start;
background-color: unset;
margin: auto 2px;
}
.t-siw-search-container .searchHeader .search-field-container.tooManyResults {
border-style: solid;
border-width: var(--theia-border-width);
border-color: var(--theia-inputValidation-warningBorder);
}
.t-siw-search-container
.searchHeader
.search-field-container
.search-notification {
height: 0;
display: none;
width: 100%;
position: relative;
}
.t-siw-search-container
.searchHeader
.search-field-container.focused
.search-notification.show {
display: block;
}
.t-siw-search-container .searchHeader .search-notification div {
background-color: var(--theia-inputValidation-warningBackground);
width: calc(100% + 2px);
margin-left: -1px;
color: var(--theia-inputValidation-warningForeground);
z-index: 1000;
position: absolute;
border: 1px solid var(--theia-inputValidation-warningBorder);
box-sizing: border-box;
padding: 3px;
}
.t-siw-search-container .searchHeader .button-container {
text-align: center;
display: flex;
justify-content: center;
}
.t-siw-search-container .searchHeader .search-field .option,
.t-siw-search-container .searchHeader .button-container .btn {
width: 21px;
height: 21px;
margin: 0 1px;
display: inline-block;
box-sizing: border-box;
align-items: center;
user-select: none;
background-repeat: no-repeat;
background-position: center;
border: var(--theia-border-width) solid transparent;
}
.t-siw-search-container .searchHeader .search-field .fa.option {
display: flex;
align-items: center;
justify-content: center;
}
.t-siw-search-container .searchHeader .search-details {
position: relative;
margin-top: var(--theia-ui-padding);
}
.t-siw-search-container .searchHeader .search-details .button-container {
position: absolute;
width: 25px;
top: 0;
right: 0;
cursor: pointer;
}
.t-siw-search-container .searchHeader .glob-field-container.hidden {
display: none;
}
.t-siw-search-container .searchHeader .glob-field-container .glob-field {
margin-bottom: var(--theia-ui-padding);
margin-left: calc(var(--theia-ui-padding) * 3);
display: flex;
flex-direction: column;
}
.t-siw-search-container .searchHeader .glob-field-container .glob-field .label {
margin-bottom: 4px;
user-select: none;
font-size: var(--theia-ui-font-size0);
}
.t-siw-search-container
.searchHeader
.glob-field-container
.glob-field
.theia-input:not(:focus)::placeholder {
color: transparent;
}
.t-siw-search-container .resultContainer {
height: 100%;
}
.t-siw-search-container .result {
overflow: hidden;
width: 100%;
flex: 1;
}
.t-siw-search-container .result .result-head {
display: flex;
}
.t-siw-search-container .result .result-head .fa,
.t-siw-search-container .result .result-head .theia-file-icons-js {
margin: 0 3px;
}
.t-siw-search-container .result .result-head .file-name {
margin-right: 5px;
}
.t-siw-search-container .result .result-head .file-path {
font-size: var(--theia-ui-font-size0);
margin-left: 3px;
}
.t-siw-search-container .resultLine {
flex-grow: 1;
}
.t-siw-search-container .resultLine .match {
white-space: pre;
background: var(--theia-editor-findMatchHighlightBackground);
border: 1px solid var(--theia-editor-findMatchHighlightBorder);
}
.theia-hc .t-siw-search-container .resultLine .match {
border-style: dashed;
}
.t-siw-search-container .resultLine .match.strike-through {
text-decoration: line-through;
background: var(--theia-diffEditor-removedTextBackground);
border-color: var(--theia-diffEditor-removedTextBorder);
}
.t-siw-search-container .resultLine .replace-term {
background: var(--theia-diffEditor-insertedTextBackground);
border: 1px solid var(--theia-diffEditor-insertedTextBorder);
}
.theia-hc .t-siw-search-container .resultLine .replace-term {
border-style: dashed;
}
.t-siw-search-container .match-line-num {
font-size: .9em;
margin-left: 7px;
margin-right: 4px;
opacity: .7;
}
.t-siw-search-container .result-head-info {
align-items: center;
display: inline-flex;
flex-grow: 1;
}
.search-in-workspace-editor-match {
background: var(--theia-editor-findMatchHighlightBackground);
}
.current-search-in-workspace-editor-match {
background: var(--theia-editor-findMatchBackground);
}
.current-match-range-highlight {
background: var(--theia-editor-findRangeHighlightBackground);
}
.result-node-buttons {
display: none;
}
.theia-TreeNode:hover .result-node-buttons {
display: flex;
justify-content: flex-end;
align-items: center;
align-self: center;
}
.theia-TreeNode:hover .result-head .notification-count-container {
display: none;
}
.result-node-buttons > span {
padding: 2px;
margin-left: var(--theia-ui-padding);
border-radius: 5px;
}
.result-node-buttons > span:hover {
background-color: var(--theia-toolbar-hoverBackground);
}
.search-and-replace-container {
display: flex;
}
.replace-toggle {
display: flex;
align-items: center;
width: 14px;
min-width: 14px;
justify-content: center;
margin-left: 2px;
margin-right: 2px;
box-sizing: border-box;
}
.theia-side-panel .replace-toggle .codicon {
padding: 0px;
}
.replace-toggle:hover {
background: rgba(50%, 50%, 50%, 0.2);
}
.search-and-replace-fields {
display: flex;
flex-direction: column;
flex: 1;
}
.replace-field {
display: flex;
margin-top: var(--theia-ui-padding);
gap: var(--theia-ui-padding);
}
.replace-field.hidden {
display: none;
}
.replace-all-button-container {
display: flex;
align-items: center;
justify-content: center;
}
.result-node-buttons .replace-result {
background-image: var(--theia-icon-replace);
}
.result-node-buttons .replace-all-result {
background-image: var(--theia-icon-replace-all);
}
.replace-all-button-container .action-label.disabled {
opacity: var(--theia-mod-disabled-opacity);
background: transparent;
cursor: default;
}
.highlighted-count-container {
background-color: var(--theia-list-activeSelectionBackground);
color: var(--theia-list-activeSelectionForeground);
}
.t-siw-search-container .searchHeader .search-info {
color: var(--theia-descriptionForeground);
margin-left: 18px;
margin-top: 10px;
}
.theia-siw-lineNumber {
opacity: 0.7;
padding-right: 4px;
}

View File

@@ -0,0 +1,6 @@
<!--Copyright (c) Microsoft Corporation. All rights reserved.-->
<!--Copyright (C) 2019 TypeFox and others.-->
<!--Licensed under the MIT License. See License.txt in the project root for license information.-->
<svg fill="#F6F6F6" height="28" viewBox="0 0 28 28" width="28" xmlns="http://www.w3.org/2000/svg">
<path d="m17.1249 2c-4.9127 0-8.89701 3.98533-8.89701 8.899 0 1.807.54686 3.4801 1.47014 4.8853 0 0-5.562 5.5346-7.20564 7.2056-1.644662 1.6701 1.0156 4.1304 2.63997 2.4442 1.62538-1.6832 7.10824-7.1072 7.10824-7.1072 1.4042.9243 3.0793 1.4711 4.8843 1.4711 4.9157 0 8.9-3.9873 8.9-8.899.001-4.91469-3.9843-8.899-8.9-8.899zm0 15.2544c-3.5095 0-6.3565-2.8449-6.3565-6.3554 0-3.51049 2.846-6.35643 6.3565-6.35643 3.5125 0 6.3574 2.84493 6.3574 6.35643 0 3.5105-2.8449 6.3554-6.3574 6.3554z" fill="#F6F6F6" />
</svg>

After

Width:  |  Height:  |  Size: 828 B

View File

@@ -0,0 +1,153 @@
// *****************************************************************************
// Copyright (C) 2017-2018 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 { RpcServer } from '@theia/core';
export interface SearchInWorkspaceOptions {
/**
* Maximum number of results to return. Defaults to unlimited.
*/
maxResults?: number;
/**
* accepts suffixes of K, M or G which correspond to kilobytes,
* megabytes and gigabytes, respectively. If no suffix is provided the input is
* treated as bytes.
*
* defaults to '20M'
*/
maxFileSize?: string;
/**
* Search case sensitively if true.
*/
matchCase?: boolean;
/**
* Search whole words only if true.
*/
matchWholeWord?: boolean;
/**
* Use regular expressions for search if true.
*/
useRegExp?: boolean;
/**
* Use multiline search if true.
*/
multiline?: boolean;
/**
* Include all .gitignored and hidden files.
*/
includeIgnored?: boolean;
/**
* Glob pattern for matching files and directories to include the search.
*/
include?: string[];
/**
* Glob pattern for matching files and directories to exclude the search.
*/
exclude?: string[];
/**
* Whether symlinks should be followed while searching.
*/
followSymlinks?: boolean;
}
export interface SearchInWorkspaceResult {
/**
* The string uri to the root folder that the search was performed.
*/
root: string;
/**
* The string uri to the file containing the result.
*/
fileUri: string;
/**
* matches found in the file
*/
matches: SearchMatch[];
}
export interface SearchMatch {
/**
* The (1-based) line number of the result.
*/
line: number;
/**
* The (1-based) character number in the result line. For UTF-8 files,
* one multi-byte character counts as one character.
*/
character: number;
/**
* The length of the match, in characters. For UTF-8 files, one
* multi-byte character counts as one character.
*/
length: number;
/**
* The text of the line containing the result.
*/
lineText: string | LinePreview;
}
export interface LinePreview {
text: string;
character: number;
}
export namespace SearchInWorkspaceResult {
/**
* Sort search in workspace results according to file, line, character position
* and then length.
*/
export function compare(a: SearchInWorkspaceResult, b: SearchInWorkspaceResult): number {
if (a.fileUri !== b.fileUri) {
return a.fileUri < b.fileUri ? -1 : 1;
}
return 0;
}
}
export const SearchInWorkspaceClient = Symbol('SearchInWorkspaceClient');
export interface SearchInWorkspaceClient {
/**
* Called by the server for every search match.
*/
onResult(searchId: number, result: SearchInWorkspaceResult): void;
/**
* Called when no more search matches will come.
*/
onDone(searchId: number, error?: string): void;
}
export const SIW_WS_PATH = '/services/search-in-workspace';
export const SearchInWorkspaceServer = Symbol('SearchInWorkspaceServer');
export interface SearchInWorkspaceServer extends RpcServer<SearchInWorkspaceClient> {
/**
* Start a search for WHAT in directories ROOTURIS. Return a unique search id.
*/
search(what: string, rootUris: string[], opts?: SearchInWorkspaceOptions): Promise<number>;
/**
* Cancel an ongoing search.
*/
cancel(searchId: number): Promise<void>;
dispose(): void;
}

View File

@@ -0,0 +1,97 @@
// *****************************************************************************
// Copyright (C) 2019 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 { nls } from '@theia/core/lib/common/nls';
import { PreferenceProxy, PreferenceScope, PreferenceService, createPreferenceProxy } from '@theia/core/lib/common/preferences';
import { interfaces } from '@theia/core/shared/inversify';
import { PreferenceContribution, PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema';
export const searchInWorkspacePreferencesSchema: PreferenceSchema = {
scope: PreferenceScope.Folder,
properties: {
'search.lineNumbers': {
description: nls.localizeByDefault('Controls whether to show line numbers for search results.'),
default: false,
type: 'boolean',
},
'search.collapseResults': {
description: nls.localizeByDefault('Controls whether the search results will be collapsed or expanded.'),
default: 'auto',
type: 'string',
enum: ['auto', 'alwaysCollapse', 'alwaysExpand'],
},
'search.quickOpen.includeHistory': {
description: nls.localizeByDefault('Whether to include results from recently opened files in the file results for Quick Open.'),
default: true,
type: 'boolean',
},
'search.searchOnType': {
description: nls.localizeByDefault('Search all files as you type.'),
default: true,
type: 'boolean',
},
'search.searchOnTypeDebouncePeriod': {
// eslint-disable-next-line max-len
markdownDescription: nls.localizeByDefault('When {0} is enabled, controls the timeout in milliseconds between a character being typed and the search starting. Has no effect when {0} is disabled.', '`#search.searchOnType#`'),
default: 300,
type: 'number',
},
'search.searchOnEditorModification': {
description: nls.localize('theia/search-in-workspace/searchOnEditorModification', 'Search the active editor when modified.'),
default: true,
type: 'boolean',
},
'search.smartCase': {
// eslint-disable-next-line max-len
description: nls.localizeByDefault('Search case-insensitively if the pattern is all lowercase, otherwise, search case-sensitively.'),
default: false,
type: 'boolean',
},
'search.followSymlinks': {
description: nls.localizeByDefault('Controls whether to follow symlinks while searching.'),
default: true,
type: 'boolean',
}
}
};
export class SearchInWorkspaceConfiguration {
'search.lineNumbers': boolean;
'search.collapseResults': string;
'search.searchOnType': boolean;
'search.searchOnTypeDebouncePeriod': number;
'search.searchOnEditorModification': boolean;
'search.smartCase': boolean;
'search.followSymlinks': boolean;
}
export const SearchInWorkspacePreferenceContribution = Symbol('SearchInWorkspacePreferenceContribution');
export const SearchInWorkspacePreferences = Symbol('SearchInWorkspacePreferences');
export type SearchInWorkspacePreferences = PreferenceProxy<SearchInWorkspaceConfiguration>;
export function createSearchInWorkspacePreferences(preferences: PreferenceService, schema: PreferenceSchema = searchInWorkspacePreferencesSchema): SearchInWorkspacePreferences {
return createPreferenceProxy(preferences, schema);
}
export function bindSearchInWorkspacePreferences(bind: interfaces.Bind): void {
bind(SearchInWorkspacePreferences).toDynamicValue(ctx => {
const preferences = ctx.container.get<PreferenceService>(PreferenceService);
const contribution = ctx.container.get<PreferenceContribution>(SearchInWorkspacePreferenceContribution);
return createSearchInWorkspacePreferences(preferences, contribution.schema);
}).inSingletonScope();
bind(SearchInWorkspacePreferenceContribution).toConstantValue({ schema: searchInWorkspacePreferencesSchema });
bind(PreferenceContribution).toService(SearchInWorkspacePreferenceContribution);
}

View File

@@ -0,0 +1,490 @@
// *****************************************************************************
// Copyright (C) 2017-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 * as fs from '@theia/core/shared/fs-extra';
import * as path from 'path';
import { ILogger } from '@theia/core';
import { RawProcess, RawProcessFactory, RawProcessOptions } from '@theia/process/lib/node';
import { FileUri } from '@theia/core/lib/common/file-uri';
import URI from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import { SearchInWorkspaceServer, SearchInWorkspaceOptions, SearchInWorkspaceResult, SearchInWorkspaceClient, LinePreview } from '../common/search-in-workspace-interface';
export const RgPath = Symbol('RgPath');
/**
* Typing for ripgrep's arbitrary data object:
*
* https://docs.rs/grep-printer/0.1.0/grep_printer/struct.JSON.html#object-arbitrary-data
*/
export type IRgBytesOrText = { bytes: string } | { text: string };
function bytesOrTextToString(obj: IRgBytesOrText): string {
return 'bytes' in obj ?
Buffer.from(obj.bytes, 'base64').toString() :
obj.text;
}
type IRgMessage = IRgMatch | IRgBegin | IRgEnd;
interface IRgMatch {
type: 'match';
data: {
path: IRgBytesOrText;
lines: IRgBytesOrText;
line_number: number;
absolute_offset: number;
submatches: IRgSubmatch[];
};
}
export interface IRgSubmatch {
match: IRgBytesOrText;
start: number;
end: number;
}
interface IRgBegin {
type: 'begin';
data: {
path: IRgBytesOrText;
lines: string;
};
}
interface IRgEnd {
type: 'end';
data: {
path: IRgBytesOrText;
};
}
@injectable()
export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer {
// List of ongoing searches, maps search id to a the started rg process.
private ongoingSearches: Map<number, RawProcess> = new Map();
// Each incoming search is given a unique id, returned to the client. This is the next id we will assigned.
private nextSearchId: number = 1;
private client: SearchInWorkspaceClient | undefined;
@inject(RgPath)
protected readonly rgPath: string;
constructor(
@inject(ILogger) protected readonly logger: ILogger,
@inject(RawProcessFactory) protected readonly rawProcessFactory: RawProcessFactory,
) { }
setClient(client: SearchInWorkspaceClient | undefined): void {
this.client = client;
}
protected getArgs(options?: SearchInWorkspaceOptions): string[] {
const args = new Set<string>();
args.add('--hidden');
args.add('--json');
if (options?.multiline) {
args.add('--multiline');
}
if (options?.matchCase) {
args.add('--case-sensitive');
} else {
args.add('--ignore-case');
}
if (options?.includeIgnored) {
args.add('--no-ignore');
}
if (options?.maxFileSize) {
args.add('--max-filesize=' + options.maxFileSize.trim());
} else {
args.add('--max-filesize=20M');
}
if (options?.include) {
this.addGlobArgs(args, options.include, false);
}
if (options?.exclude) {
this.addGlobArgs(args, options.exclude, true);
}
if (options?.followSymlinks) {
args.add('--follow');
}
if (options?.useRegExp || options?.matchWholeWord) {
args.add('--regexp');
} else {
args.add('--fixed-strings');
args.add('--');
}
return Array.from(args);
}
/**
* Add glob patterns to ripgrep's arguments
* @param args ripgrep set of arguments
* @param patterns patterns to include as globs
* @param exclude whether to negate the glob pattern or not
*/
protected addGlobArgs(args: Set<string>, patterns: string[], exclude: boolean = false): void {
const sanitizedPatterns = patterns.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0);
for (let pattern of sanitizedPatterns) {
// make sure the pattern always starts with `**/`
if (pattern.startsWith('/')) {
pattern = '**' + pattern;
} else if (!pattern.startsWith('**/')) {
pattern = '**/' + pattern;
}
// add the exclusion prefix
if (exclude) {
pattern = '!' + pattern;
}
args.add(`--glob=${pattern}`);
// add a generic glob cli argument entry to include files inside a given directory
if (!pattern.endsWith('*')) {
// ensure the new pattern ends with `/*`
pattern += pattern.endsWith('/') ? '*' : '/*';
args.add(`--glob=${pattern}`);
}
}
}
/**
* Transforms relative patterns to absolute paths, one for each given search path.
* The resulting paths are not validated in the file system as the pattern keeps glob information.
*
* @returns The resulting list may be larger than the received patterns as a relative pattern may
* resolve to multiple absolute patterns up to the number of search paths.
*/
protected replaceRelativeToAbsolute(roots: string[], patterns: string[] = []): string[] {
const expandedPatterns = new Set<string>();
for (const pattern of patterns) {
if (this.isPatternRelative(pattern)) {
// create new patterns using the absolute form for each root
for (const root of roots) {
expandedPatterns.add(path.resolve(root, pattern));
}
} else {
expandedPatterns.add(pattern);
}
}
return Array.from(expandedPatterns);
}
/**
* Tests if the pattern is relative and should/can be made absolute.
*/
protected isPatternRelative(pattern: string): boolean {
return pattern.replace(/\\/g, '/').startsWith('./');
}
/**
* By default, sets the search directories for the string WHAT to the provided ROOTURIS directories
* and returns the assigned search id.
*
* The include / exclude (options in SearchInWorkspaceOptions) are lists of patterns for files to
* include / exclude during search (glob characters are allowed).
*
* include patterns successfully recognized as absolute paths will override the default search and set
* the search directories to the ones provided as includes.
* Relative paths are allowed, the application will attempt to translate them to valid absolute paths
* based on the applicable search directories.
*/
async search(what: string, rootUris: string[], options: SearchInWorkspaceOptions = {}): Promise<number> {
// Start the rg process. Use --vimgrep to get one result per
// line, --color=always to get color control characters that
// we'll use to parse the lines.
const searchId = this.nextSearchId++;
const rootPaths = rootUris.map(root => FileUri.fsPath(root));
// If there are absolute paths in `include` we will remove them and use
// those as paths to search from.
const searchPaths = await this.extractSearchPathsFromIncludes(rootPaths, options);
options.include = this.replaceRelativeToAbsolute(searchPaths, options.include);
options.exclude = this.replaceRelativeToAbsolute(searchPaths, options.exclude);
const rgArgs = this.getArgs(options);
// If we use matchWholeWord we use regExp internally, so we need
// to escape regexp characters if we actually not set regexp true in UI.
if (options?.matchWholeWord && !options.useRegExp) {
what = what.replace(/[\-\\\{\}\*\+\?\|\^\$\.\[\]\(\)\#]/g, '\\$&');
if (!/\B/.test(what.charAt(0))) {
what = '\\b' + what;
}
if (!/\B/.test(what.charAt(what.length - 1))) {
what = what + '\\b';
}
}
const args = [...rgArgs, what, ...searchPaths];
const processOptions: RawProcessOptions = {
command: this.rgPath,
args
};
// TODO: Use child_process directly instead of rawProcessFactory?
const rgProcess: RawProcess = this.rawProcessFactory(processOptions);
this.ongoingSearches.set(searchId, rgProcess);
rgProcess.onError(error => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let errorCode = (error as any).code;
// Try to provide somewhat clearer error messages, if possible.
if (errorCode === 'ENOENT') {
errorCode = 'could not find the ripgrep (rg) binary';
} else if (errorCode === 'EACCES') {
errorCode = 'could not execute the ripgrep (rg) binary';
}
const errorStr = `An error happened while searching (${errorCode}).`;
this.wrapUpSearch(searchId, errorStr);
});
// Running counter of results.
let numResults = 0;
// Buffer to accumulate incoming output.
let databuf: string = '';
let currentSearchResult: SearchInWorkspaceResult | undefined;
rgProcess.outputStream.on('data', (chunk: Buffer) => {
// We might have already reached the max number of
// results, sent a TERM signal to rg, but we still get
// the data that was already output in the mean time.
// It's not necessary to return early here (the check
// for maxResults below would avoid sending extra
// results), but it avoids doing unnecessary work.
if (options?.maxResults && numResults >= options.maxResults) {
return;
}
databuf += chunk;
while (1) {
// Check if we have a complete line.
const eolIdx = databuf.indexOf('\n');
if (eolIdx < 0) {
break;
}
// Get and remove the line from the data buffer.
const lineBuf = databuf.slice(0, eolIdx);
databuf = databuf.slice(eolIdx + 1);
const obj = JSON.parse(lineBuf) as IRgMessage;
if (obj.type === 'begin') {
const file = bytesOrTextToString(obj.data.path);
if (file) {
currentSearchResult = {
fileUri: FileUri.create(file).toString(),
root: this.getRoot(file, rootUris).toString(),
matches: []
};
} else {
this.logger.error('Begin message without path. ' + JSON.stringify(obj));
}
} else if (obj.type === 'end') {
if (currentSearchResult && this.client) {
this.client.onResult(searchId, currentSearchResult);
}
currentSearchResult = undefined;
} else if (obj.type === 'match') {
if (!currentSearchResult) {
continue;
}
const data = obj.data;
const file = bytesOrTextToString(data.path);
const line = data.line_number;
const lineText = bytesOrTextToString(data.lines);
if (file === undefined || lineText === undefined) {
continue;
}
const lineInBytes = Buffer.from(lineText);
for (const submatch of data.submatches) {
const startOffset = lineInBytes.slice(0, submatch.start).toString().length;
const match = bytesOrTextToString(submatch.match);
let lineInfo: string | LinePreview = lineText.trimRight();
if (lineInfo.length > 300) {
const prefixLength = 25;
const start = Math.max(startOffset - prefixLength, 0);
const length = prefixLength + match.length + 70;
let prefix = '';
if (start >= prefixLength) {
prefix = '...';
}
const character = (start < prefixLength ? start : prefixLength) + prefix.length + 1;
lineInfo = <LinePreview>{
text: prefix + lineInfo.substring(start, start + length),
character
};
}
currentSearchResult.matches.push({
line,
character: startOffset + 1,
length: match.length,
lineText: lineInfo
});
numResults++;
// Did we reach the maximum number of results?
if (options?.maxResults && numResults >= options.maxResults) {
rgProcess.kill();
if (currentSearchResult && this.client) {
this.client.onResult(searchId, currentSearchResult);
}
currentSearchResult = undefined;
this.wrapUpSearch(searchId);
break;
}
}
}
}
});
rgProcess.outputStream.on('end', () => {
// If we reached maxResults, we should have already
// wrapped up the search. Returning early avoids
// logging a warning message in wrapUpSearch.
if (options?.maxResults && numResults >= options.maxResults) {
return;
}
this.wrapUpSearch(searchId);
});
return searchId;
}
/**
* The default search paths are set to be the root paths associated to a workspace
* however the search scope can be further refined with the include paths available in the search options.
* This method will replace the searching paths to the ones specified in the 'include' options but as long
* as the 'include' paths can be successfully validated as existing.
*
* Therefore the returned array of paths can be either the workspace root paths or a set of validated paths
* derived from the include options which can be used to perform the search.
*
* Any pattern that resulted in a valid search path will be removed from the 'include' list as it is
* provided as an equivalent search path instead.
*/
protected async extractSearchPathsFromIncludes(rootPaths: string[], options: SearchInWorkspaceOptions): Promise<string[]> {
if (!options.include) {
return rootPaths;
}
const resolvedPaths = new Set<string>();
const include: string[] = [];
for (const pattern of options.include) {
let keep = true;
for (const root of rootPaths) {
const absolutePath = await this.getAbsolutePathFromPattern(root, pattern);
// undefined means the pattern cannot be converted into an absolute path
if (absolutePath) {
resolvedPaths.add(absolutePath);
keep = false;
}
}
if (keep) {
include.push(pattern);
}
}
options.include = include;
return resolvedPaths.size > 0
? Array.from(resolvedPaths)
: rootPaths;
}
/**
* Transform include/exclude option patterns from relative patterns to absolute patterns.
* E.g. './abc/foo.*' to '${root}/abc/foo.*', the transformation does not validate the
* pattern against the file system as glob suffixes remain.
*
* @returns undefined if the pattern cannot be converted into an absolute path.
*/
protected async getAbsolutePathFromPattern(root: string, pattern: string): Promise<string | undefined> {
pattern = pattern.replace(/\\/g, '/');
// The pattern is not referring to a single file or folder, i.e. not to be converted
if (!path.isAbsolute(pattern) && !pattern.startsWith('./')) {
return undefined;
}
// remove the `/**` suffix if present
if (pattern.endsWith('/**')) {
pattern = pattern.substring(0, pattern.length - 3);
}
// if `pattern` is absolute then `root` will be ignored by `path.resolve()`
const targetPath = path.resolve(root, pattern);
if (await fs.pathExists(targetPath)) {
return targetPath;
}
return undefined;
}
/**
* Returns the root folder uri that a file belongs to.
* In case that a file belongs to more than one root folders, returns the root folder that is closest to the file.
* If the file is not from the current workspace, returns empty string.
* @param filePath string path of the file
* @param rootUris string URIs of the root folders in the current workspace
*/
private getRoot(filePath: string, rootUris: string[]): URI {
const roots = rootUris.filter(root => new URI(root).withScheme('file').isEqualOrParent(FileUri.create(filePath).withScheme('file')));
if (roots.length > 0) {
return FileUri.create(FileUri.fsPath(roots.sort((r1, r2) => r2.length - r1.length)[0]));
}
return new URI();
}
// Cancel an ongoing search. Trying to cancel a search that doesn't exist isn't an
// error, otherwise we'd have to deal with race conditions, where a client cancels a
// search that finishes normally at the same time.
cancel(searchId: number): Promise<void> {
const process = this.ongoingSearches.get(searchId);
if (process) {
process.kill();
this.wrapUpSearch(searchId);
}
return Promise.resolve();
}
// Send onDone to the client and clean up what we know about search searchId.
private wrapUpSearch(searchId: number, error?: string): void {
if (this.ongoingSearches.delete(searchId)) {
if (this.client) {
this.logger.debug('Sending onDone for ' + searchId, error);
this.client.onDone(searchId, error);
} else {
this.logger.debug('Wrapping up search ' + searchId + ' but no client');
}
} else {
this.logger.debug("Trying to wrap up a search we don't know about " + searchId);
}
}
dispose(): void {
}
}

View File

@@ -0,0 +1,35 @@
// *****************************************************************************
// Copyright (C) 2017-2018 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 { ContainerModule } from '@theia/core/shared/inversify';
import { ConnectionHandler, RpcConnectionHandler } from '@theia/core/lib/common';
import { SearchInWorkspaceServer, SearchInWorkspaceClient, SIW_WS_PATH } from '../common/search-in-workspace-interface';
import { RipgrepSearchInWorkspaceServer, RgPath } from './ripgrep-search-in-workspace-server';
import { rgPath } from '@vscode/ripgrep';
import { bindSearchInWorkspacePreferences } from '../common/search-in-workspace-preferences';
export default new ContainerModule(bind => {
bind(SearchInWorkspaceServer).to(RipgrepSearchInWorkspaceServer);
bind(ConnectionHandler).toDynamicValue(ctx =>
new RpcConnectionHandler<SearchInWorkspaceClient>(SIW_WS_PATH, client => {
const server = ctx.container.get<SearchInWorkspaceServer>(SearchInWorkspaceServer);
server.setClient(client);
client.onDidCloseConnection(() => server.dispose());
return server;
}));
bind(RgPath).toConstantValue(rgPath);
bindSearchInWorkspacePreferences(bind);
});

View File

@@ -0,0 +1,31 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../core"
},
{
"path": "../editor"
},
{
"path": "../filesystem"
},
{
"path": "../navigator"
},
{
"path": "../process"
},
{
"path": "../workspace"
}
]
}