deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/file-search/.eslintrc.js
Normal file
10
packages/file-search/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: [
|
||||
'../../configs/build.eslintrc.json'
|
||||
],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: 'tsconfig.json'
|
||||
}
|
||||
};
|
||||
31
packages/file-search/README.md
Normal file
31
packages/file-search/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
<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 - FILE-SEARCH EXTENSION</h2>
|
||||
|
||||
<hr />
|
||||
|
||||
</div>
|
||||
|
||||
## Description
|
||||
|
||||
The `@theia/file-search` extension adds the command and ability to quickly open any file in a given workspace.
|
||||
|
||||
## Additional Information
|
||||
|
||||
- [API documentation for `@theia/file-search`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_file-search.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>
|
||||
55
packages/file-search/package.json
Normal file
55
packages/file-search/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@theia/file-search",
|
||||
"version": "1.68.0",
|
||||
"description": "Theia - File Search Extension",
|
||||
"dependencies": {
|
||||
"@theia/core": "1.68.0",
|
||||
"@theia/editor": "1.68.0",
|
||||
"@theia/filesystem": "1.68.0",
|
||||
"@theia/process": "1.68.0",
|
||||
"@theia/workspace": "1.68.0",
|
||||
"@vscode/ripgrep": "^1.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"theiaExtensions": [
|
||||
{
|
||||
"frontend": "lib/browser/file-search-frontend-module",
|
||||
"frontendOnly": "lib/browser-only/file-search-frontend-only-module",
|
||||
"backend": "lib/node/file-search-backend-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"
|
||||
},
|
||||
"nyc": {
|
||||
"extends": "../../configs/nyc.json"
|
||||
},
|
||||
"gitHead": "21358137e41342742707f660b8e222f940a27652"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// *****************************************************************************
|
||||
// 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, interfaces } from '@theia/core/shared/inversify';
|
||||
import { CommandContribution, MenuContribution } from '@theia/core/lib/common';
|
||||
import { KeybindingContribution } from '@theia/core/lib/browser';
|
||||
import { FileSearchService } from '../common/file-search-service';
|
||||
import { FileSearchServiceImpl } from './file-search-service-impl';
|
||||
import { QuickAccessContribution } from '@theia/core/lib/browser/quick-input/quick-access';
|
||||
import { QuickFileOpenFrontendContribution } from '../browser/quick-file-open-contribution';
|
||||
import { QuickFileOpenService } from '../browser/quick-file-open';
|
||||
import { QuickFileSelectService } from '../browser/quick-file-select-service';
|
||||
|
||||
export default new ContainerModule((bind: interfaces.Bind) => {
|
||||
bind(FileSearchService).to(FileSearchServiceImpl).inSingletonScope();
|
||||
|
||||
bind(QuickFileOpenFrontendContribution).toSelf().inSingletonScope();
|
||||
|
||||
[CommandContribution, KeybindingContribution, MenuContribution, QuickAccessContribution].forEach(serviceIdentifier =>
|
||||
bind(serviceIdentifier).toService(QuickFileOpenFrontendContribution)
|
||||
);
|
||||
|
||||
bind(QuickFileSelectService).toSelf().inSingletonScope();
|
||||
bind(QuickFileOpenService).toSelf().inSingletonScope();
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
// *****************************************************************************
|
||||
// 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, inject, named } from '@theia/core/shared/inversify';
|
||||
import { FileSearchService, WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import * as fuzzy from '@theia/core/shared/fuzzy';
|
||||
import { CancellationTokenSource, CancellationToken, ILogger, URI } from '@theia/core';
|
||||
import { matchesPattern, createIgnoreMatcher, getIgnorePatterns } from '@theia/filesystem/lib/browser-only/file-search';
|
||||
|
||||
@injectable()
|
||||
export class FileSearchServiceImpl implements FileSearchService {
|
||||
@inject(ILogger)
|
||||
@named('file-search')
|
||||
protected logger: ILogger;
|
||||
|
||||
@inject(FileService)
|
||||
protected readonly fs: FileService;
|
||||
|
||||
/**
|
||||
* Searches for files matching the given pattern.
|
||||
* @param searchPattern - The pattern to search for
|
||||
* @param options - Search options including root URIs and filters
|
||||
* @param clientToken - Optional cancellation token
|
||||
* @returns Promise resolving to array of matching file URIs
|
||||
*/
|
||||
async find(searchPattern: string, options: FileSearchService.Options, clientToken?: CancellationToken): Promise<string[]> {
|
||||
const cancellationSource = new CancellationTokenSource();
|
||||
|
||||
if (clientToken) {
|
||||
clientToken.onCancellationRequested(() => cancellationSource.cancel());
|
||||
}
|
||||
|
||||
const token = cancellationSource.token;
|
||||
const opts = {
|
||||
fuzzyMatch: true,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
useGitIgnore: true,
|
||||
...options
|
||||
};
|
||||
|
||||
// Merge root-specific options with global options
|
||||
const roots: FileSearchService.RootOptions = options.rootOptions || {};
|
||||
if (options.rootUris) {
|
||||
for (const rootUri of options.rootUris) {
|
||||
if (!roots[rootUri]) {
|
||||
roots[rootUri] = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const rootUri in roots) {
|
||||
const rootOptions = roots[rootUri];
|
||||
if (opts.includePatterns) {
|
||||
const includePatterns = rootOptions.includePatterns || [];
|
||||
rootOptions.includePatterns = [...includePatterns, ...opts.includePatterns];
|
||||
}
|
||||
if (opts.excludePatterns) {
|
||||
const excludePatterns = rootOptions.excludePatterns || [];
|
||||
rootOptions.excludePatterns = [...excludePatterns, ...opts.excludePatterns];
|
||||
}
|
||||
if (rootOptions.useGitIgnore === undefined) {
|
||||
rootOptions.useGitIgnore = opts.useGitIgnore;
|
||||
}
|
||||
}
|
||||
|
||||
const exactMatches = new Set<string>();
|
||||
const fuzzyMatches = new Set<string>();
|
||||
|
||||
// Split search pattern into individual terms for matching
|
||||
const patterns = searchPattern.toLowerCase().split(WHITESPACE_QUERY_SEPARATOR).map(pattern => pattern.trim()).filter(Boolean);
|
||||
|
||||
await Promise.all(Object.keys(roots).map(async root => {
|
||||
try {
|
||||
const rootUri = new URI(root);
|
||||
const rootOptions = roots[root];
|
||||
|
||||
await this.doFind(rootUri, rootOptions, (fileUri: string) => {
|
||||
|
||||
// Skip already matched files
|
||||
if (exactMatches.has(fileUri) || fuzzyMatches.has(fileUri)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for exact pattern matches
|
||||
const candidatePattern = fileUri.toLowerCase();
|
||||
const patternExists = patterns.every(pattern => candidatePattern.includes(pattern));
|
||||
|
||||
if (patternExists) {
|
||||
exactMatches.add(fileUri);
|
||||
} else if (!searchPattern || searchPattern === '*') {
|
||||
exactMatches.add(fileUri);
|
||||
} else {
|
||||
// Check for fuzzy matches if enabled
|
||||
const fuzzyPatternExists = patterns.every(pattern => fuzzy.test(pattern, candidatePattern));
|
||||
|
||||
if (opts.fuzzyMatch && fuzzyPatternExists) {
|
||||
fuzzyMatches.add(fileUri);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel search if limit reached
|
||||
if ((exactMatches.size + fuzzyMatches.size) >= opts.limit) {
|
||||
cancellationSource.cancel();
|
||||
}
|
||||
}, token);
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to search:', root, e);
|
||||
}
|
||||
}));
|
||||
|
||||
if (clientToken?.isCancellationRequested) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return results up to the specified limit
|
||||
return [...exactMatches, ...fuzzyMatches].slice(0, opts.limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual file search within a root directory.
|
||||
* @param rootUri - The root URI to search in
|
||||
* @param options - Search options for this root
|
||||
* @param accept - Callback function for each matching file
|
||||
* @param token - Cancellation token
|
||||
*/
|
||||
protected async doFind(
|
||||
rootUri: URI,
|
||||
options: FileSearchService.BaseOptions,
|
||||
accept: (fileUri: string) => void,
|
||||
token: CancellationToken
|
||||
): Promise<void> {
|
||||
const matcher = createIgnoreMatcher();
|
||||
const queue: URI[] = [rootUri];
|
||||
let queueIndex = 0;
|
||||
|
||||
while (queueIndex < queue.length) {
|
||||
if (token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUri = queue[queueIndex++];
|
||||
|
||||
try {
|
||||
// Skip excluded paths
|
||||
if (this.shouldExcludePath(currentUri, options.excludePatterns)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = await this.fs.resolve(currentUri);
|
||||
const relPath = currentUri.path.toString().replace(/^\/|^\.\//, '');
|
||||
|
||||
// Skip paths ignored by gitignore patterns
|
||||
if (options.useGitIgnore && relPath && matcher.ignores(relPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accept file if it matches include patterns
|
||||
if (stat.isFile && this.shouldIncludePath(currentUri, options.includePatterns)) {
|
||||
accept(currentUri.toString());
|
||||
} else if (stat.isDirectory && Array.isArray(stat.children)) {
|
||||
// Process ignore files in directory
|
||||
if (options.useGitIgnore) {
|
||||
const patterns = await getIgnorePatterns(
|
||||
currentUri,
|
||||
uri => this.fs.read(uri).then(content => content.value)
|
||||
);
|
||||
|
||||
matcher.add(patterns);
|
||||
}
|
||||
|
||||
// Add children to search queue
|
||||
for (const child of stat.children) {
|
||||
queue.push(child.resource);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`Error reading directory: ${currentUri.toString()}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path should be excluded based on exclude patterns.
|
||||
* @param uri - The URI to check
|
||||
* @param excludePatterns - Array of exclude patterns
|
||||
* @returns True if the path should be excluded
|
||||
*/
|
||||
private shouldExcludePath(uri: URI, excludePatterns: string[] | undefined): boolean {
|
||||
if (!excludePatterns?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const path = uri.path.toString();
|
||||
return matchesPattern(path, excludePatterns, {
|
||||
dot: true,
|
||||
matchBase: true,
|
||||
nocase: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path should be included based on include patterns.
|
||||
* @param uri - The URI to check
|
||||
* @param includePatterns - Array of include patterns
|
||||
* @returns True if the path should be included
|
||||
*/
|
||||
private shouldIncludePath(uri: URI, includePatterns: string[] | undefined): boolean {
|
||||
if (!includePatterns?.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const path = uri.path.toString();
|
||||
return matchesPattern(path, includePatterns, {
|
||||
dot: true,
|
||||
matchBase: true,
|
||||
nocase: true
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2017 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 { ContainerModule, interfaces } from '@theia/core/shared/inversify';
|
||||
import { CommandContribution, MenuContribution } from '@theia/core/lib/common';
|
||||
import { WebSocketConnectionProvider, KeybindingContribution } from '@theia/core/lib/browser';
|
||||
import { QuickFileOpenFrontendContribution } from './quick-file-open-contribution';
|
||||
import { QuickFileOpenService } from './quick-file-open';
|
||||
import { fileSearchServicePath, FileSearchService } from '../common/file-search-service';
|
||||
import { QuickAccessContribution } from '@theia/core/lib/browser/quick-input/quick-access';
|
||||
import { QuickFileSelectService } from './quick-file-select-service';
|
||||
|
||||
export default new ContainerModule((bind: interfaces.Bind) => {
|
||||
bind(FileSearchService).toDynamicValue(ctx => {
|
||||
const provider = ctx.container.get(WebSocketConnectionProvider);
|
||||
return provider.createProxy<FileSearchService>(fileSearchServicePath);
|
||||
}).inSingletonScope();
|
||||
|
||||
bind(QuickFileOpenFrontendContribution).toSelf().inSingletonScope();
|
||||
[CommandContribution, KeybindingContribution, MenuContribution, QuickAccessContribution].forEach(serviceIdentifier =>
|
||||
bind(serviceIdentifier).toService(QuickFileOpenFrontendContribution)
|
||||
);
|
||||
|
||||
bind(QuickFileSelectService).toSelf().inSingletonScope();
|
||||
bind(QuickFileOpenService).toSelf().inSingletonScope();
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2017 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 URI from '@theia/core/lib/common/uri';
|
||||
import { QuickFileOpenService, quickFileOpen } from './quick-file-open';
|
||||
import { CommandRegistry, CommandContribution, MenuContribution, MenuModelRegistry } from '@theia/core/lib/common';
|
||||
import { KeybindingRegistry, KeybindingContribution, QuickAccessContribution } from '@theia/core/lib/browser';
|
||||
import { EditorMainMenu } from '@theia/editor/lib/browser';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
|
||||
@injectable()
|
||||
export class QuickFileOpenFrontendContribution implements QuickAccessContribution, CommandContribution, KeybindingContribution, MenuContribution {
|
||||
|
||||
@inject(QuickFileOpenService)
|
||||
protected readonly quickFileOpenService: QuickFileOpenService;
|
||||
|
||||
registerCommands(commands: CommandRegistry): void {
|
||||
commands.registerCommand(quickFileOpen, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
execute: (...args: any[]) => {
|
||||
let fileURI: string | undefined;
|
||||
if (args) {
|
||||
[fileURI] = args;
|
||||
}
|
||||
if (fileURI) {
|
||||
this.quickFileOpenService.openFile(new URI(fileURI));
|
||||
} else {
|
||||
this.quickFileOpenService.open();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerKeybindings(keybindings: KeybindingRegistry): void {
|
||||
keybindings.registerKeybinding({
|
||||
command: quickFileOpen.id,
|
||||
keybinding: 'ctrlcmd+p'
|
||||
});
|
||||
}
|
||||
|
||||
registerMenus(menus: MenuModelRegistry): void {
|
||||
menus.registerMenuAction(EditorMainMenu.WORKSPACE_GROUP, {
|
||||
commandId: quickFileOpen.id,
|
||||
label: nls.localizeByDefault('Go to File...'),
|
||||
order: '1',
|
||||
});
|
||||
}
|
||||
|
||||
registerQuickAccessProvider(): void {
|
||||
this.quickFileOpenService.registerQuickAccessProvider();
|
||||
}
|
||||
}
|
||||
208
packages/file-search/src/browser/quick-file-open.ts
Normal file
208
packages/file-search/src/browser/quick-file-open.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2017 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 { CommonCommands, KeybindingRegistry, OpenerService, QuickAccessProvider, QuickAccessRegistry } from '@theia/core/lib/browser';
|
||||
import { QuickInputService, QuickPickItem, QuickPicks } from '@theia/core/lib/browser/quick-input/quick-input-service';
|
||||
import { CancellationToken, Command, nls } from '@theia/core/lib/common';
|
||||
import { MessageService } from '@theia/core/lib/common/message-service';
|
||||
import URI from '@theia/core/lib/common/uri';
|
||||
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { EditorOpenerOptions, EditorWidget, Position, Range } from '@theia/editor/lib/browser';
|
||||
import { NavigationLocationService } from '@theia/editor/lib/browser/navigation/navigation-location-service';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
||||
import { QuickFileSelectService } from './quick-file-select-service';
|
||||
|
||||
export const quickFileOpen = Command.toDefaultLocalizedCommand({
|
||||
id: 'file-search.openFile',
|
||||
category: CommonCommands.FILE_CATEGORY,
|
||||
label: 'Open File...'
|
||||
});
|
||||
export interface FilterAndRange {
|
||||
filter: string;
|
||||
range?: Range;
|
||||
}
|
||||
|
||||
// Supports patterns of <path><#|:><line><#|:|,><col?>
|
||||
const LINE_COLON_PATTERN = /\s?[#:\(](?:line )?(\d*)(?:[#:,](\d*))?\)?\s*$/;
|
||||
export type FileQuickPickItem = QuickPickItem & { uri: URI };
|
||||
|
||||
@injectable()
|
||||
export class QuickFileOpenService implements QuickAccessProvider {
|
||||
static readonly PREFIX = '';
|
||||
|
||||
@inject(KeybindingRegistry)
|
||||
protected readonly keybindingRegistry: KeybindingRegistry;
|
||||
@inject(WorkspaceService)
|
||||
protected readonly workspaceService: WorkspaceService;
|
||||
@inject(OpenerService)
|
||||
protected readonly openerService: OpenerService;
|
||||
@inject(QuickInputService) @optional()
|
||||
protected readonly quickInputService: QuickInputService;
|
||||
@inject(QuickAccessRegistry)
|
||||
protected readonly quickAccessRegistry: QuickAccessRegistry;
|
||||
@inject(NavigationLocationService)
|
||||
protected readonly navigationLocationService: NavigationLocationService;
|
||||
@inject(MessageService)
|
||||
protected readonly messageService: MessageService;
|
||||
@inject(QuickFileSelectService)
|
||||
protected readonly quickFileSelectService: QuickFileSelectService;
|
||||
|
||||
registerQuickAccessProvider(): void {
|
||||
this.quickAccessRegistry.registerQuickAccessProvider({
|
||||
getInstance: () => this,
|
||||
prefix: QuickFileOpenService.PREFIX,
|
||||
placeholder: this.getPlaceHolder(),
|
||||
helpEntries: [{ description: 'Open File', needsEditor: false }]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to hide .gitignored (and other ignored) files.
|
||||
*/
|
||||
protected hideIgnoredFiles = true;
|
||||
|
||||
/**
|
||||
* Whether the dialog is currently open.
|
||||
*/
|
||||
protected isOpen = false;
|
||||
private updateIsOpen = true;
|
||||
|
||||
protected filterAndRangeDefault = { filter: '', range: undefined };
|
||||
|
||||
/**
|
||||
* Tracks the user file search filter and location range e.g. fileFilter:line:column or fileFilter:line,column
|
||||
*/
|
||||
protected filterAndRange: FilterAndRange = this.filterAndRangeDefault;
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.quickInputService?.onHide(() => {
|
||||
if (this.updateIsOpen) {
|
||||
this.isOpen = false;
|
||||
} else {
|
||||
this.updateIsOpen = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.workspaceService.opened;
|
||||
}
|
||||
|
||||
open(): void {
|
||||
// Triggering the keyboard shortcut while the dialog is open toggles
|
||||
// showing the ignored files.
|
||||
if (this.isOpen) {
|
||||
this.hideIgnoredFiles = !this.hideIgnoredFiles;
|
||||
this.hideQuickPick();
|
||||
} else {
|
||||
this.hideIgnoredFiles = true;
|
||||
this.filterAndRange = this.filterAndRangeDefault;
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
this.quickInputService?.open(this.filterAndRange.filter);
|
||||
}
|
||||
|
||||
protected hideQuickPick(): void {
|
||||
this.updateIsOpen = false;
|
||||
this.quickInputService?.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a string (suitable to show to the user) representing the keyboard
|
||||
* shortcut used to open the quick file open menu.
|
||||
*/
|
||||
protected getKeyCommand(): string | undefined {
|
||||
const keyCommand = this.keybindingRegistry.getKeybindingsForCommand(quickFileOpen.id);
|
||||
if (keyCommand) {
|
||||
// We only consider the first keybinding.
|
||||
const accel = this.keybindingRegistry.acceleratorFor(keyCommand[0], '+');
|
||||
return accel.join(' ');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getPicks(filter: string, token: CancellationToken): Promise<QuickPicks> {
|
||||
this.filterAndRange = this.splitFilterAndRange(filter);
|
||||
const fileFilter = this.filterAndRange.filter;
|
||||
return this.quickFileSelectService.getPicks(fileFilter, token, {
|
||||
hideIgnoredFiles: this.hideIgnoredFiles,
|
||||
onSelect: item => this.openFile(item.uri)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
openFile(uri: URI): void {
|
||||
const options = this.buildOpenerOptions();
|
||||
const closedEditor = this.navigationLocationService.closedEditorsStack.find(editor => editor.uri.path.toString() === uri.path.toString());
|
||||
this.openerService.getOpener(uri, options)
|
||||
.then(opener => opener.open(uri, options))
|
||||
.then(widget => {
|
||||
// Attempt to restore the editor state if it exists, and no selection is explicitly requested.
|
||||
if (widget instanceof EditorWidget && closedEditor && !options.selection) {
|
||||
widget.editor.restoreViewState(closedEditor.viewState);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn(error);
|
||||
this.messageService.error(nls.localizeByDefault("Unable to open '{0}'", uri.path.toString()));
|
||||
});
|
||||
}
|
||||
|
||||
protected buildOpenerOptions(): EditorOpenerOptions {
|
||||
return { selection: this.filterAndRange.range };
|
||||
}
|
||||
|
||||
private getPlaceHolder(): string {
|
||||
let placeholder = nls.localizeByDefault('Search files by name (append {0} to go to line or {1} to go to symbol)', ':', '@');
|
||||
const keybinding = this.getKeyCommand();
|
||||
if (keybinding) {
|
||||
placeholder += nls.localize('theia/file-search/toggleIgnoredFiles', ' (Press {0} to show/hide ignored files)', keybinding);
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the given expression into a structure of search-file-filter and
|
||||
* location-range.
|
||||
*
|
||||
* @param expression patterns of <path><#|:><line><#|:|,><col?>
|
||||
*/
|
||||
protected splitFilterAndRange(expression: string): FilterAndRange {
|
||||
let filter = expression;
|
||||
let range = undefined;
|
||||
|
||||
// Find line and column number from the expression using RegExp.
|
||||
const patternMatch = LINE_COLON_PATTERN.exec(expression);
|
||||
|
||||
if (patternMatch) {
|
||||
const line = parseInt(patternMatch[1] ?? '', 10);
|
||||
if (Number.isFinite(line)) {
|
||||
const lineNumber = line > 0 ? line - 1 : 0;
|
||||
|
||||
const column = parseInt(patternMatch[2] ?? '', 10);
|
||||
const startColumn = Number.isFinite(column) && column > 0 ? column - 1 : 0;
|
||||
const position = Position.create(lineNumber, startColumn);
|
||||
|
||||
filter = expression.substring(0, patternMatch.index);
|
||||
range = Range.create(position, position);
|
||||
}
|
||||
}
|
||||
return { filter, range };
|
||||
}
|
||||
}
|
||||
298
packages/file-search/src/browser/quick-file-select-service.ts
Normal file
298
packages/file-search/src/browser/quick-file-select-service.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2017 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 { KeybindingRegistry, OpenerService, QuickAccessRegistry } from '@theia/core/lib/browser';
|
||||
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
|
||||
import { findMatches, QuickInputService, QuickPickItem, QuickPicks } from '@theia/core/lib/browser/quick-input/quick-input-service';
|
||||
import { CancellationToken, nls, PreferenceService, QuickPickSeparator } from '@theia/core/lib/common';
|
||||
import { MessageService } from '@theia/core/lib/common/message-service';
|
||||
import URI from '@theia/core/lib/common/uri';
|
||||
import * as fuzzy from '@theia/core/shared/fuzzy';
|
||||
import { inject, injectable, optional } from '@theia/core/shared/inversify';
|
||||
import { Position, Range } from '@theia/editor/lib/browser';
|
||||
import { NavigationLocationService } from '@theia/editor/lib/browser/navigation/navigation-location-service';
|
||||
import { FileSystemPreferences } from '@theia/filesystem/lib/common';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
||||
import { FileSearchService, WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service';
|
||||
|
||||
export interface FilterAndRange {
|
||||
filter: string;
|
||||
range?: Range;
|
||||
}
|
||||
|
||||
export interface QuickFileSelectOptions {
|
||||
/** Whether to hide .gitignored (and other ignored) files. */
|
||||
hideIgnoredFiles?: boolean;
|
||||
/** Executed when the item is selected. */
|
||||
onSelect?: (item: FileQuickPickItem) => void;
|
||||
}
|
||||
|
||||
// Supports patterns of <path><#|:><line><#|:|,><col?>
|
||||
const LINE_COLON_PATTERN = /\s?[#:\(](?:line )?(\d*)(?:[#:,](\d*))?\)?\s*$/;
|
||||
export type FileQuickPickItem = QuickPickItem & { uri: URI };
|
||||
|
||||
export namespace FileQuickPickItem {
|
||||
export function is(obj: QuickPickItem | QuickPickSeparator): obj is FileQuickPickItem {
|
||||
return obj && 'uri' in obj;
|
||||
}
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class QuickFileSelectService {
|
||||
|
||||
@inject(KeybindingRegistry)
|
||||
protected readonly keybindingRegistry: KeybindingRegistry;
|
||||
@inject(WorkspaceService)
|
||||
protected readonly workspaceService: WorkspaceService;
|
||||
@inject(OpenerService)
|
||||
protected readonly openerService: OpenerService;
|
||||
@inject(QuickInputService) @optional()
|
||||
protected readonly quickInputService: QuickInputService;
|
||||
@inject(QuickAccessRegistry)
|
||||
protected readonly quickAccessRegistry: QuickAccessRegistry;
|
||||
@inject(FileSearchService)
|
||||
protected readonly fileSearchService: FileSearchService;
|
||||
@inject(LabelProvider)
|
||||
protected readonly labelProvider: LabelProvider;
|
||||
@inject(NavigationLocationService)
|
||||
protected readonly navigationLocationService: NavigationLocationService;
|
||||
@inject(MessageService)
|
||||
protected readonly messageService: MessageService;
|
||||
@inject(FileSystemPreferences)
|
||||
protected readonly fsPreferences: FileSystemPreferences;
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferences: PreferenceService;
|
||||
|
||||
/**
|
||||
* The score constants when comparing file search results.
|
||||
*/
|
||||
private static readonly Scores = {
|
||||
max: 1000, // represents the maximum score from fuzzy matching (Infinity).
|
||||
exact: 500, // represents the score assigned to exact matching.
|
||||
partial: 250 // represents the score assigned to partial matching.
|
||||
};
|
||||
|
||||
async getPicks(
|
||||
fileFilter: string = '',
|
||||
token: CancellationToken = CancellationToken.None,
|
||||
options: QuickFileSelectOptions = {
|
||||
hideIgnoredFiles: true
|
||||
}
|
||||
): Promise<QuickPicks> {
|
||||
const roots = this.workspaceService.tryGetRoots();
|
||||
|
||||
const alreadyCollected = new Set<string>();
|
||||
const recentlyUsedItems: QuickPicks = [];
|
||||
|
||||
if (this.preferences.get('search.quickOpen.includeHistory')) {
|
||||
const locations = [...this.navigationLocationService.locations()].reverse();
|
||||
for (const location of locations) {
|
||||
const uriString = location.uri.toString();
|
||||
|
||||
if (location.uri.scheme === 'file' && !alreadyCollected.has(uriString) && fuzzy.test(fileFilter, uriString)) {
|
||||
if (recentlyUsedItems.length === 0) {
|
||||
recentlyUsedItems.push({
|
||||
type: 'separator',
|
||||
label: nls.localizeByDefault('recently opened')
|
||||
});
|
||||
}
|
||||
const item = this.toItem(fileFilter, location.uri, options.onSelect);
|
||||
recentlyUsedItems.push(item);
|
||||
alreadyCollected.add(uriString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileFilter.length > 0) {
|
||||
const handler = async (results: string[]) => {
|
||||
if (token.isCancellationRequested || results.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = [...recentlyUsedItems];
|
||||
const fileSearchResultItems: FileQuickPickItem[] = [];
|
||||
|
||||
for (const fileUri of results) {
|
||||
if (!alreadyCollected.has(fileUri)) {
|
||||
const item = this.toItem(fileFilter, fileUri, options.onSelect);
|
||||
fileSearchResultItems.push(item);
|
||||
alreadyCollected.add(fileUri);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a copy of the file search results and sort.
|
||||
const sortedResults = fileSearchResultItems.slice();
|
||||
sortedResults.sort((a, b) => this.compareItems(a, b, fileFilter));
|
||||
|
||||
if (sortedResults.length > 0) {
|
||||
result.push({
|
||||
type: 'separator',
|
||||
label: nls.localizeByDefault('file results')
|
||||
});
|
||||
result.push(...sortedResults);
|
||||
}
|
||||
|
||||
// Return the recently used items, followed by the search results.
|
||||
return result;
|
||||
};
|
||||
|
||||
return this.fileSearchService.find(fileFilter, {
|
||||
rootUris: roots.map(r => r.resource.toString()),
|
||||
fuzzyMatch: true,
|
||||
limit: 200,
|
||||
useGitIgnore: options.hideIgnoredFiles,
|
||||
excludePatterns: options.hideIgnoredFiles
|
||||
? Object.keys(this.fsPreferences['files.exclude'])
|
||||
: undefined,
|
||||
}, token).then(handler);
|
||||
} else {
|
||||
return roots.length !== 0 ? recentlyUsedItems : [];
|
||||
}
|
||||
}
|
||||
|
||||
protected compareItems(
|
||||
left: FileQuickPickItem,
|
||||
right: FileQuickPickItem,
|
||||
fileFilter: string
|
||||
): number {
|
||||
|
||||
/**
|
||||
* Score a given string.
|
||||
*
|
||||
* @param str the string to score on.
|
||||
* @returns the score.
|
||||
*/
|
||||
function score(str: string | undefined): number {
|
||||
if (!str) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let exactMatch = true;
|
||||
const partialMatches = querySplit.reduce((matched, part) => {
|
||||
const partMatches = str.includes(part);
|
||||
exactMatch = exactMatch && partMatches;
|
||||
return partMatches ? matched + QuickFileSelectService.Scores.partial : matched;
|
||||
}, 0);
|
||||
|
||||
// Check fuzzy matches.
|
||||
const fuzzyMatch = fuzzy.match(queryJoin, str) ?? { score: 0 };
|
||||
if (fuzzyMatch.score === Infinity && exactMatch) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
return fuzzyMatch.score + partialMatches + (exactMatch ? QuickFileSelectService.Scores.exact : 0);
|
||||
}
|
||||
|
||||
const query: string = normalize(fileFilter);
|
||||
// Adjust for whitespaces in the query.
|
||||
const querySplit = query.split(WHITESPACE_QUERY_SEPARATOR);
|
||||
const queryJoin = querySplit.join('');
|
||||
|
||||
const compareByLabelScore = (l: FileQuickPickItem, r: FileQuickPickItem) => score(r.label) - score(l.label);
|
||||
const compareByLabelIndex = (l: FileQuickPickItem, r: FileQuickPickItem) => r.label.indexOf(query) - l.label.indexOf(query);
|
||||
const compareByLabel = (l: FileQuickPickItem, r: FileQuickPickItem) => l.label.localeCompare(r.label);
|
||||
|
||||
const compareByPathScore = (l: FileQuickPickItem, r: FileQuickPickItem) => score(r.uri.path.toString()) - score(l.uri.path.toString());
|
||||
const compareByPathIndex = (l: FileQuickPickItem, r: FileQuickPickItem) => r.uri.path.toString().indexOf(query) - l.uri.path.toString().indexOf(query);
|
||||
const compareByPathLabel = (l: FileQuickPickItem, r: FileQuickPickItem) => l.uri.path.toString().localeCompare(r.uri.path.toString());
|
||||
|
||||
return compareWithDiscriminators(left, right, compareByLabelScore, compareByLabelIndex, compareByLabel, compareByPathScore, compareByPathIndex, compareByPathLabel);
|
||||
}
|
||||
|
||||
private toItem(lookFor: string, uriOrString: URI | string, onSelect?: ((item: FileQuickPickItem) => void) | undefined): FileQuickPickItem {
|
||||
const uri = uriOrString instanceof URI ? uriOrString : new URI(uriOrString);
|
||||
const label = this.labelProvider.getName(uri);
|
||||
const description = this.getItemDescription(uri);
|
||||
const iconClasses = this.getItemIconClasses(uri);
|
||||
|
||||
const item = <FileQuickPickItem>{
|
||||
label,
|
||||
description,
|
||||
highlights: {
|
||||
label: findMatches(label, lookFor),
|
||||
description: findMatches(description, lookFor)
|
||||
},
|
||||
iconClasses,
|
||||
uri
|
||||
};
|
||||
return {
|
||||
...item,
|
||||
execute: () => onSelect ? onSelect(item) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
private getItemIconClasses(uri: URI): string[] | undefined {
|
||||
const icon = this.labelProvider.getIcon(uri).split(' ').filter(v => v.length > 0);
|
||||
if (icon.length > 0) {
|
||||
icon.push('file-icon');
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
private getItemDescription(uri: URI): string {
|
||||
return this.labelProvider.getDetails(uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the given expression into a structure of search-file-filter and
|
||||
* location-range.
|
||||
*
|
||||
* @param expression patterns of <path><#|:><line><#|:|,><col?>
|
||||
*/
|
||||
protected splitFilterAndRange(expression: string): FilterAndRange {
|
||||
let filter = expression;
|
||||
let range = undefined;
|
||||
|
||||
// Find line and column number from the expression using RegExp.
|
||||
const patternMatch = LINE_COLON_PATTERN.exec(expression);
|
||||
|
||||
if (patternMatch) {
|
||||
const line = parseInt(patternMatch[1] ?? '', 10);
|
||||
if (Number.isFinite(line)) {
|
||||
const lineNumber = line > 0 ? line - 1 : 0;
|
||||
|
||||
const column = parseInt(patternMatch[2] ?? '', 10);
|
||||
const startColumn = Number.isFinite(column) && column > 0 ? column - 1 : 0;
|
||||
const position = Position.create(lineNumber, startColumn);
|
||||
|
||||
filter = expression.substring(0, patternMatch.index);
|
||||
range = Range.create(position, position);
|
||||
}
|
||||
}
|
||||
return { filter, range };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a given string.
|
||||
*
|
||||
* @param str the raw string value.
|
||||
* @returns the normalized string value.
|
||||
*/
|
||||
function normalize(str: string): string {
|
||||
return str.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function compareWithDiscriminators<T>(left: T, right: T, ...discriminators: ((left: T, right: T) => number)[]): number {
|
||||
let comparisonValue = 0;
|
||||
let i = 0;
|
||||
|
||||
while (comparisonValue === 0 && i < discriminators.length) {
|
||||
comparisonValue = discriminators[i](left, right);
|
||||
i++;
|
||||
}
|
||||
return comparisonValue;
|
||||
}
|
||||
52
packages/file-search/src/common/file-search-service.ts
Normal file
52
packages/file-search/src/common/file-search-service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2017 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 { CancellationToken } from '@theia/core';
|
||||
|
||||
export const fileSearchServicePath = '/services/search';
|
||||
|
||||
/**
|
||||
* The JSON-RPC file search service interface.
|
||||
*/
|
||||
export interface FileSearchService {
|
||||
|
||||
/**
|
||||
* finds files by a given search pattern.
|
||||
* @return the matching file uris
|
||||
*/
|
||||
find(searchPattern: string, options: FileSearchService.Options, cancellationToken?: CancellationToken): Promise<string[]>;
|
||||
|
||||
}
|
||||
|
||||
export const FileSearchService = Symbol('FileSearchService');
|
||||
export namespace FileSearchService {
|
||||
export interface BaseOptions {
|
||||
useGitIgnore?: boolean
|
||||
includePatterns?: string[]
|
||||
excludePatterns?: string[]
|
||||
}
|
||||
export interface RootOptions {
|
||||
[rootUri: string]: BaseOptions
|
||||
}
|
||||
export interface Options extends BaseOptions {
|
||||
rootUris?: string[]
|
||||
rootOptions?: RootOptions
|
||||
fuzzyMatch?: boolean
|
||||
limit?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const WHITESPACE_QUERY_SEPARATOR = /\s+/;
|
||||
30
packages/file-search/src/node/file-search-backend-module.ts
Normal file
30
packages/file-search/src/node/file-search-backend-module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2017 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 { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { ConnectionHandler, RpcConnectionHandler } from '@theia/core/lib/common';
|
||||
import { FileSearchServiceImpl } from './file-search-service-impl';
|
||||
import { fileSearchServicePath, FileSearchService } from '../common/file-search-service';
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
|
||||
bind(FileSearchService).to(FileSearchServiceImpl).inSingletonScope();
|
||||
bind(ConnectionHandler).toDynamicValue(ctx =>
|
||||
new RpcConnectionHandler(fileSearchServicePath, () =>
|
||||
ctx.container.get(FileSearchService)
|
||||
)
|
||||
).inSingletonScope();
|
||||
});
|
||||
230
packages/file-search/src/node/file-search-service-impl.spec.ts
Normal file
230
packages/file-search/src/node/file-search-service-impl.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2017 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 { expect } from 'chai';
|
||||
import * as assert from 'assert';
|
||||
import * as path from 'path';
|
||||
import { FileSearchServiceImpl } from './file-search-service-impl';
|
||||
import { FileUri } from '@theia/core/lib/node';
|
||||
import { Container, ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { CancellationTokenSource } from '@theia/core';
|
||||
import { bindLogger } from '@theia/core/lib/node/logger-backend-module';
|
||||
import URI from '@theia/core/lib/common/uri';
|
||||
import { FileSearchService } from '../common/file-search-service';
|
||||
import { RawProcessFactory } from '@theia/process/lib/node';
|
||||
|
||||
const testContainer = new Container();
|
||||
|
||||
bindLogger(testContainer.bind.bind(testContainer));
|
||||
testContainer.bind(RawProcessFactory).toConstantValue(() => {
|
||||
throw new Error('should not be used anymore');
|
||||
});
|
||||
testContainer.load(new ContainerModule(bind => {
|
||||
bind(FileSearchServiceImpl).toSelf().inSingletonScope();
|
||||
}));
|
||||
|
||||
describe('search-service', function (): void {
|
||||
|
||||
this.timeout(10000);
|
||||
|
||||
let service: FileSearchServiceImpl;
|
||||
|
||||
beforeEach(() => {
|
||||
service = testContainer.get(FileSearchServiceImpl);
|
||||
});
|
||||
|
||||
it('should fuzzy search this spec file', async () => {
|
||||
const rootUri = FileUri.create(path.resolve(__dirname, '..')).toString();
|
||||
const matches = await service.find('spc', { rootUris: [rootUri] });
|
||||
const expectedFile = FileUri.create(__filename).path.base;
|
||||
const testFile = matches.find(e => e.endsWith(expectedFile));
|
||||
expect(testFile).to.not.be.undefined;
|
||||
});
|
||||
|
||||
it.skip('should respect nested .gitignore', async () => {
|
||||
const rootUri = FileUri.create(path.resolve(__dirname, '../../test-resources')).toString();
|
||||
const matches = await service.find('foo', { rootUris: [rootUri], fuzzyMatch: false });
|
||||
|
||||
expect(matches.find(match => match.endsWith('subdir1/sub-bar/foo.txt'))).to.be.undefined;
|
||||
expect(matches.find(match => match.endsWith('subdir1/sub2/foo.txt'))).to.not.be.undefined;
|
||||
expect(matches.find(match => match.endsWith('subdir1/foo.txt'))).to.not.be.undefined;
|
||||
});
|
||||
|
||||
it('should cancel searches', async () => {
|
||||
const rootUri = FileUri.create(path.resolve(__dirname, '../../../../..')).toString();
|
||||
const cancelTokenSource = new CancellationTokenSource();
|
||||
cancelTokenSource.cancel();
|
||||
const matches = await service.find('foo', { rootUris: [rootUri], fuzzyMatch: false }, cancelTokenSource.token);
|
||||
|
||||
expect(matches).to.be.empty;
|
||||
});
|
||||
|
||||
it('should perform file search across all folders in the workspace', async () => {
|
||||
const dirA = FileUri.create(path.resolve(__dirname, '../../test-resources/subdir1/sub-bar')).toString();
|
||||
const dirB = FileUri.create(path.resolve(__dirname, '../../test-resources/subdir1/sub2')).toString();
|
||||
|
||||
const matches = await service.find('foo', { rootUris: [dirA, dirB] });
|
||||
expect(matches).to.not.be.undefined;
|
||||
expect(matches.length).to.eq(2);
|
||||
});
|
||||
|
||||
describe('search with glob', () => {
|
||||
it('should support file searches with globs', async () => {
|
||||
const rootUri = FileUri.create(path.resolve(__dirname, '../../test-resources/subdir1/sub2')).toString();
|
||||
|
||||
const matches = await service.find('', { rootUris: [rootUri], includePatterns: ['**/*oo.*'] });
|
||||
expect(matches).to.not.be.undefined;
|
||||
expect(matches.length).to.eq(1);
|
||||
});
|
||||
|
||||
it('should NOT support file searches with globs without the prefixed or trailing star (*)', async () => {
|
||||
const rootUri = FileUri.create(path.resolve(__dirname, '../../test-resources/subdir1/sub2')).toString();
|
||||
|
||||
const trailingMatches = await service.find('', { rootUris: [rootUri], includePatterns: ['*oo'] });
|
||||
expect(trailingMatches).to.not.be.undefined;
|
||||
expect(trailingMatches.length).to.eq(0);
|
||||
|
||||
const prefixedMatches = await service.find('', { rootUris: [rootUri], includePatterns: ['oo*'] });
|
||||
expect(prefixedMatches).to.not.be.undefined;
|
||||
expect(prefixedMatches.length).to.eq(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search with ignored patterns', () => {
|
||||
it('should NOT ignore strings passed through the search options', async () => {
|
||||
const rootUri = FileUri.create(path.resolve(__dirname, '../../test-resources/subdir1/sub2')).toString();
|
||||
|
||||
const matches = await service.find('', { rootUris: [rootUri], includePatterns: ['**/*oo.*'], excludePatterns: ['foo'] });
|
||||
expect(matches).to.not.be.undefined;
|
||||
expect(matches.length).to.eq(1);
|
||||
});
|
||||
|
||||
const ignoreGlobsUri = FileUri.create(path.resolve(__dirname, '../../test-resources/subdir1/sub2')).toString();
|
||||
it('should ignore globs passed through the search options #1', () => assertIgnoreGlobs({
|
||||
rootUris: [ignoreGlobsUri],
|
||||
includePatterns: ['**/*oo.*'],
|
||||
excludePatterns: ['*fo*']
|
||||
}));
|
||||
it('should ignore globs passed through the search options #2', () => assertIgnoreGlobs({
|
||||
rootOptions: {
|
||||
[ignoreGlobsUri]: {
|
||||
includePatterns: ['**/*oo.*'],
|
||||
excludePatterns: ['*fo*']
|
||||
}
|
||||
}
|
||||
}));
|
||||
it('should ignore globs passed through the search options #3', () => assertIgnoreGlobs({
|
||||
rootOptions: {
|
||||
[ignoreGlobsUri]: {
|
||||
includePatterns: ['**/*oo.*']
|
||||
}
|
||||
},
|
||||
excludePatterns: ['*fo*']
|
||||
}));
|
||||
it('should ignore globs passed through the search options #4', () => assertIgnoreGlobs({
|
||||
rootOptions: {
|
||||
[ignoreGlobsUri]: {
|
||||
excludePatterns: ['*fo*']
|
||||
}
|
||||
},
|
||||
includePatterns: ['**/*oo.*']
|
||||
}));
|
||||
it('should ignore globs passed through the search options #5', () => assertIgnoreGlobs({
|
||||
rootOptions: {
|
||||
[ignoreGlobsUri]: {}
|
||||
},
|
||||
excludePatterns: ['*fo*'],
|
||||
includePatterns: ['**/*oo.*']
|
||||
}));
|
||||
|
||||
async function assertIgnoreGlobs(options: FileSearchService.Options): Promise<void> {
|
||||
const matches = await service.find('', options);
|
||||
expect(matches).to.not.be.undefined;
|
||||
expect(matches.length).to.eq(0);
|
||||
}
|
||||
});
|
||||
|
||||
describe('irrelevant absolute results', () => {
|
||||
const rootUri = FileUri.create(path.resolve(__dirname, '../../../..'));
|
||||
|
||||
it('not fuzzy', async () => {
|
||||
const searchPattern = 'package'; // package.json should produce a result.
|
||||
const matches = await service.find(searchPattern, { rootUris: [rootUri.toString()], fuzzyMatch: false, useGitIgnore: true, limit: 200 });
|
||||
expect(matches).not.empty;
|
||||
for (const match of matches) {
|
||||
const relativeUri = rootUri.relative(new URI(match));
|
||||
assert.notStrictEqual(relativeUri, undefined);
|
||||
const relativeMatch = relativeUri!.toString();
|
||||
assert.notStrictEqual(relativeMatch.indexOf(searchPattern), -1, relativeMatch);
|
||||
}
|
||||
});
|
||||
|
||||
it('fuzzy', async () => {
|
||||
const matches = await service.find('shell', { rootUris: [rootUri.toString()], fuzzyMatch: true, useGitIgnore: true, limit: 200 });
|
||||
expect(matches).not.empty;
|
||||
for (const match of matches) {
|
||||
const relativeUri = rootUri.relative(new URI(match));
|
||||
assert.notStrictEqual(relativeUri, undefined);
|
||||
const relativeMatch = relativeUri!.toString();
|
||||
let position = 0;
|
||||
for (const ch of 'shell') {
|
||||
position = relativeMatch.toLowerCase().indexOf(ch, position);
|
||||
assert.notStrictEqual(position, -1, `character "${ch}" not found in "${relativeMatch}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should not look into .git', async () => {
|
||||
const matches = await service.find('master', { rootUris: [rootUri.toString()], fuzzyMatch: false, useGitIgnore: true, limit: 200 });
|
||||
// `**/.git/refs/remotes/*/master` files should not be picked up
|
||||
assert.deepStrictEqual([], matches);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search with whitespaces', () => {
|
||||
const rootUri = FileUri.create(path.resolve(__dirname, '../../test-resources')).toString();
|
||||
|
||||
it('should support file searches with whitespaces', async () => {
|
||||
const matches = await service.find('foo sub', { rootUris: [rootUri], fuzzyMatch: true, useGitIgnore: true, limit: 200 });
|
||||
|
||||
expect(matches).to.be.length(2);
|
||||
expect(matches[0].endsWith('subdir1/sub-bar/foo.txt'));
|
||||
expect(matches[1].endsWith('subdir1/sub2/foo.txt'));
|
||||
});
|
||||
|
||||
it('should support fuzzy file searches with whitespaces', async () => {
|
||||
const matchesExact = await service.find('foo sbd2', { rootUris: [rootUri], fuzzyMatch: false, useGitIgnore: true, limit: 200 });
|
||||
const matchesFuzzy = await service.find('foo sbd2', { rootUris: [rootUri], fuzzyMatch: true, useGitIgnore: true, limit: 200 });
|
||||
|
||||
expect(matchesExact).to.be.length(0);
|
||||
expect(matchesFuzzy).to.be.length(1);
|
||||
expect(matchesFuzzy[0].endsWith('subdir1/sub2/foo.txt'));
|
||||
});
|
||||
|
||||
it('should support file searches with whitespaces regardless of order', async () => {
|
||||
const matchesA = await service.find('foo sub', { rootUris: [rootUri], fuzzyMatch: true, useGitIgnore: true, limit: 200 });
|
||||
const matchesB = await service.find('sub foo', { rootUris: [rootUri], fuzzyMatch: true, useGitIgnore: true, limit: 200 });
|
||||
|
||||
expect(matchesA).to.not.be.empty;
|
||||
expect(matchesB).to.not.be.empty;
|
||||
expect(matchesA.length).to.equal(matchesB.length);
|
||||
|
||||
// Due to ripgrep parallelism we cannot deepEqual the matches since order is not guaranteed.
|
||||
expect(matchesA).to.have.members(matchesB);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
187
packages/file-search/src/node/file-search-service-impl.ts
Normal file
187
packages/file-search/src/node/file-search-service-impl.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2017 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 * as cp from 'child_process';
|
||||
import * as fuzzy from '@theia/core/shared/fuzzy';
|
||||
import * as readline from 'readline';
|
||||
import { rgPath } from '@vscode/ripgrep';
|
||||
import { injectable, inject } from '@theia/core/shared/inversify';
|
||||
import URI from '@theia/core/lib/common/uri';
|
||||
import { FileUri } from '@theia/core/lib/common/file-uri';
|
||||
import { CancellationTokenSource, CancellationToken, ILogger, isWindows } from '@theia/core';
|
||||
import { RawProcessFactory } from '@theia/process/lib/node';
|
||||
import { FileSearchService, WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service';
|
||||
import * as path from 'path';
|
||||
|
||||
@injectable()
|
||||
export class FileSearchServiceImpl implements FileSearchService {
|
||||
|
||||
constructor(
|
||||
@inject(ILogger) protected readonly logger: ILogger,
|
||||
/** @deprecated since 1.7.0 */
|
||||
@inject(RawProcessFactory) protected readonly rawProcessFactory: RawProcessFactory,
|
||||
) { }
|
||||
|
||||
async find(searchPattern: string, options: FileSearchService.Options, clientToken?: CancellationToken): Promise<string[]> {
|
||||
const cancellationSource = new CancellationTokenSource();
|
||||
if (clientToken) {
|
||||
clientToken.onCancellationRequested(() => cancellationSource.cancel());
|
||||
}
|
||||
const token = cancellationSource.token;
|
||||
const opts = {
|
||||
fuzzyMatch: true,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
useGitIgnore: true,
|
||||
...options
|
||||
};
|
||||
|
||||
const roots: FileSearchService.RootOptions = options.rootOptions || {};
|
||||
if (options.rootUris) {
|
||||
for (const rootUri of options.rootUris) {
|
||||
if (!roots[rootUri]) {
|
||||
roots[rootUri] = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const rootUri in roots) {
|
||||
const rootOptions = roots[rootUri];
|
||||
if (opts.includePatterns) {
|
||||
const includePatterns = rootOptions.includePatterns || [];
|
||||
rootOptions.includePatterns = [...includePatterns, ...opts.includePatterns];
|
||||
}
|
||||
if (opts.excludePatterns) {
|
||||
const excludePatterns = rootOptions.excludePatterns || [];
|
||||
rootOptions.excludePatterns = [...excludePatterns, ...opts.excludePatterns];
|
||||
}
|
||||
if (rootOptions.useGitIgnore === undefined) {
|
||||
rootOptions.useGitIgnore = opts.useGitIgnore;
|
||||
}
|
||||
}
|
||||
|
||||
const exactMatches = new Set<string>();
|
||||
const fuzzyMatches = new Set<string>();
|
||||
|
||||
if (isWindows) {
|
||||
// Allow users on Windows to search for paths using either forwards or backwards slash
|
||||
searchPattern = searchPattern.replace(/\//g, '\\');
|
||||
}
|
||||
|
||||
const patterns = searchPattern.toLocaleLowerCase().trim().split(WHITESPACE_QUERY_SEPARATOR);
|
||||
|
||||
await Promise.all(Object.keys(roots).map(async root => {
|
||||
try {
|
||||
const rootUri = new URI(root);
|
||||
const rootPath = FileUri.fsPath(rootUri);
|
||||
const rootOptions = roots[root];
|
||||
|
||||
await this.doFind(rootUri, rootOptions, candidate => {
|
||||
|
||||
// Convert OS-native candidate path to a file URI string
|
||||
const fileUri = FileUri.create(path.resolve(rootPath, candidate)).toString();
|
||||
|
||||
// Skip results that have already been matched.
|
||||
if (exactMatches.has(fileUri) || fuzzyMatches.has(fileUri)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if the candidate matches any of the patterns exactly or fuzzy
|
||||
const candidatePattern = candidate.toLocaleLowerCase();
|
||||
const patternExists = patterns.every(pattern => candidatePattern.indexOf(pattern) !== -1);
|
||||
if (patternExists) {
|
||||
exactMatches.add(fileUri);
|
||||
} else if (!searchPattern || searchPattern === '*') {
|
||||
exactMatches.add(fileUri);
|
||||
} else {
|
||||
const fuzzyPatternExists = patterns.every(pattern => fuzzy.test(pattern, candidate));
|
||||
if (opts.fuzzyMatch && fuzzyPatternExists) {
|
||||
fuzzyMatches.add(fileUri);
|
||||
}
|
||||
}
|
||||
|
||||
// Preemptively terminate the search when the list of exact matches reaches the limit.
|
||||
if (exactMatches.size === opts.limit) {
|
||||
cancellationSource.cancel();
|
||||
}
|
||||
}, token);
|
||||
} catch (e) {
|
||||
console.error('Failed to search:', root, e);
|
||||
}
|
||||
}));
|
||||
|
||||
if (clientToken && clientToken.isCancellationRequested) {
|
||||
return [];
|
||||
}
|
||||
// Return the list of results limited by the search limit.
|
||||
return [...exactMatches, ...fuzzyMatches].slice(0, opts.limit);
|
||||
}
|
||||
|
||||
protected doFind(rootUri: URI, options: FileSearchService.BaseOptions, accept: (fileUri: string) => void, token: CancellationToken): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cwd = FileUri.fsPath(rootUri);
|
||||
const args = this.getSearchArgs(options);
|
||||
const ripgrep = cp.spawn(rgPath, args, { cwd });
|
||||
ripgrep.on('error', reject);
|
||||
ripgrep.on('exit', (code, signal) => {
|
||||
if (typeof code === 'number' && code !== 0) {
|
||||
reject(new Error(`"${rgPath}" exited with code: ${code}`));
|
||||
} else if (typeof signal === 'string') {
|
||||
reject(new Error(`"${rgPath}" was terminated by signal: ${signal}`));
|
||||
}
|
||||
});
|
||||
token.onCancellationRequested(() => {
|
||||
ripgrep.kill(); // most likely sends a signal.
|
||||
resolve(); // avoid rejecting for no good reason.
|
||||
});
|
||||
const lineReader = readline.createInterface({
|
||||
input: ripgrep.stdout,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
lineReader.on('line', line => {
|
||||
if (!token.isCancellationRequested) {
|
||||
accept(line);
|
||||
}
|
||||
});
|
||||
lineReader.on('close', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
protected getSearchArgs(options: FileSearchService.BaseOptions): string[] {
|
||||
const args = ['--files', '--hidden', '--case-sensitive', '--no-require-git', '--no-config'];
|
||||
if (options.includePatterns) {
|
||||
for (const includePattern of options.includePatterns) {
|
||||
if (includePattern) {
|
||||
args.push('--glob', includePattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.excludePatterns) {
|
||||
for (const excludePattern of options.excludePatterns) {
|
||||
if (excludePattern) {
|
||||
args.push('--glob', `!${excludePattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.useGitIgnore) {
|
||||
// ripgrep follows `.gitignore` by default, but it doesn't exclude `.git`:
|
||||
args.push('--glob', '!.git');
|
||||
} else {
|
||||
args.push('--no-ignore');
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
}
|
||||
1
packages/file-search/test-resources/.gitignore
vendored
Normal file
1
packages/file-search/test-resources/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
foo.txt
|
||||
2
packages/file-search/test-resources/subdir1/.gitignore
vendored
Normal file
2
packages/file-search/test-resources/subdir1/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*-bar
|
||||
!foo.txt
|
||||
0
packages/file-search/test-resources/subdir1/foo.txt
Normal file
0
packages/file-search/test-resources/subdir1/foo.txt
Normal file
28
packages/file-search/tsconfig.json
Normal file
28
packages/file-search/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../configs/base.tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../core"
|
||||
},
|
||||
{
|
||||
"path": "../editor"
|
||||
},
|
||||
{
|
||||
"path": "../filesystem"
|
||||
},
|
||||
{
|
||||
"path": "../process"
|
||||
},
|
||||
{
|
||||
"path": "../workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user