@theia/file-search 1.45.0 → 1.46.0-next.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -30
- package/lib/browser/file-search-frontend-module.d.ts +3 -3
- package/lib/browser/file-search-frontend-module.js +33 -33
- package/lib/browser/quick-file-open-contribution.d.ts +10 -10
- package/lib/browser/quick-file-open-contribution.js +75 -75
- package/lib/browser/quick-file-open.d.ts +79 -79
- package/lib/browser/quick-file-open.js +380 -380
- package/lib/common/file-search-service.d.ts +30 -30
- package/lib/common/file-search-service.js +21 -21
- package/lib/node/file-search-backend-module.d.ts +3 -3
- package/lib/node/file-search-backend-module.js +25 -25
- package/lib/node/file-search-service-impl.d.ts +15 -15
- package/lib/node/file-search-service-impl.js +196 -196
- package/lib/node/file-search-service-impl.js.map +1 -1
- package/lib/node/file-search-service-impl.spec.d.ts +1 -1
- package/lib/node/file-search-service-impl.spec.js +195 -195
- package/package.json +8 -8
- package/src/browser/file-search-frontend-module.ts +37 -37
- package/src/browser/quick-file-open-contribution.ts +66 -66
- package/src/browser/quick-file-open.ts +387 -387
- package/src/common/file-search-service.ts +52 -52
- package/src/node/file-search-backend-module.ts +30 -30
- package/src/node/file-search-service-impl.spec.ts +230 -230
- package/src/node/file-search-service-impl.ts +187 -187
|
@@ -1,387 +1,387 @@
|
|
|
1
|
-
// *****************************************************************************
|
|
2
|
-
// Copyright (C) 2017 TypeFox and others.
|
|
3
|
-
//
|
|
4
|
-
// This program and the accompanying materials are made available under the
|
|
5
|
-
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
-
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
-
//
|
|
8
|
-
// This Source Code may also be made available under the following Secondary
|
|
9
|
-
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
-
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
-
// with the GNU Classpath Exception which is available at
|
|
12
|
-
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
-
//
|
|
14
|
-
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
-
// *****************************************************************************
|
|
16
|
-
|
|
17
|
-
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
|
|
18
|
-
import { OpenerService, KeybindingRegistry, QuickAccessRegistry, QuickAccessProvider, CommonCommands, PreferenceService } from '@theia/core/lib/browser';
|
|
19
|
-
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
|
20
|
-
import URI from '@theia/core/lib/common/uri';
|
|
21
|
-
import { FileSearchService, WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service';
|
|
22
|
-
import { CancellationToken, Command, nls } from '@theia/core/lib/common';
|
|
23
|
-
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
|
|
24
|
-
import { NavigationLocationService } from '@theia/editor/lib/browser/navigation/navigation-location-service';
|
|
25
|
-
import * as fuzzy from '@theia/core/shared/fuzzy';
|
|
26
|
-
import { MessageService } from '@theia/core/lib/common/message-service';
|
|
27
|
-
import { FileSystemPreferences } from '@theia/filesystem/lib/browser';
|
|
28
|
-
import { EditorOpenerOptions, EditorWidget, Position, Range } from '@theia/editor/lib/browser';
|
|
29
|
-
import { findMatches, QuickInputService, QuickPickItem, QuickPicks } from '@theia/core/lib/browser/quick-input/quick-input-service';
|
|
30
|
-
|
|
31
|
-
export const quickFileOpen = Command.toDefaultLocalizedCommand({
|
|
32
|
-
id: 'file-search.openFile',
|
|
33
|
-
category: CommonCommands.FILE_CATEGORY,
|
|
34
|
-
label: 'Open File...'
|
|
35
|
-
});
|
|
36
|
-
export interface FilterAndRange {
|
|
37
|
-
filter: string;
|
|
38
|
-
range?: Range;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Supports patterns of <path><#|:><line><#|:|,><col?>
|
|
42
|
-
const LINE_COLON_PATTERN = /\s?[#:\(](?:line )?(\d*)(?:[#:,](\d*))?\)?\s*$/;
|
|
43
|
-
export type FileQuickPickItem = QuickPickItem & { uri: URI };
|
|
44
|
-
|
|
45
|
-
@injectable()
|
|
46
|
-
export class QuickFileOpenService implements QuickAccessProvider {
|
|
47
|
-
static readonly PREFIX = '';
|
|
48
|
-
|
|
49
|
-
@inject(KeybindingRegistry)
|
|
50
|
-
protected readonly keybindingRegistry: KeybindingRegistry;
|
|
51
|
-
@inject(WorkspaceService)
|
|
52
|
-
protected readonly workspaceService: WorkspaceService;
|
|
53
|
-
@inject(OpenerService)
|
|
54
|
-
protected readonly openerService: OpenerService;
|
|
55
|
-
@inject(QuickInputService) @optional()
|
|
56
|
-
protected readonly quickInputService: QuickInputService;
|
|
57
|
-
@inject(QuickAccessRegistry)
|
|
58
|
-
protected readonly quickAccessRegistry: QuickAccessRegistry;
|
|
59
|
-
@inject(FileSearchService)
|
|
60
|
-
protected readonly fileSearchService: FileSearchService;
|
|
61
|
-
@inject(LabelProvider)
|
|
62
|
-
protected readonly labelProvider: LabelProvider;
|
|
63
|
-
@inject(NavigationLocationService)
|
|
64
|
-
protected readonly navigationLocationService: NavigationLocationService;
|
|
65
|
-
@inject(MessageService)
|
|
66
|
-
protected readonly messageService: MessageService;
|
|
67
|
-
@inject(FileSystemPreferences)
|
|
68
|
-
protected readonly fsPreferences: FileSystemPreferences;
|
|
69
|
-
@inject(PreferenceService)
|
|
70
|
-
protected readonly preferences: PreferenceService;
|
|
71
|
-
|
|
72
|
-
registerQuickAccessProvider(): void {
|
|
73
|
-
this.quickAccessRegistry.registerQuickAccessProvider({
|
|
74
|
-
getInstance: () => this,
|
|
75
|
-
prefix: QuickFileOpenService.PREFIX,
|
|
76
|
-
placeholder: this.getPlaceHolder(),
|
|
77
|
-
helpEntries: [{ description: 'Open File', needsEditor: false }]
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Whether to hide .gitignored (and other ignored) files.
|
|
83
|
-
*/
|
|
84
|
-
protected hideIgnoredFiles = true;
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Whether the dialog is currently open.
|
|
88
|
-
*/
|
|
89
|
-
protected isOpen = false;
|
|
90
|
-
private updateIsOpen = true;
|
|
91
|
-
|
|
92
|
-
protected filterAndRangeDefault = { filter: '', range: undefined };
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Tracks the user file search filter and location range e.g. fileFilter:line:column or fileFilter:line,column
|
|
96
|
-
*/
|
|
97
|
-
protected filterAndRange: FilterAndRange = this.filterAndRangeDefault;
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The score constants when comparing file search results.
|
|
101
|
-
*/
|
|
102
|
-
private static readonly Scores = {
|
|
103
|
-
max: 1000, // represents the maximum score from fuzzy matching (Infinity).
|
|
104
|
-
exact: 500, // represents the score assigned to exact matching.
|
|
105
|
-
partial: 250 // represents the score assigned to partial matching.
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
@postConstruct()
|
|
109
|
-
protected init(): void {
|
|
110
|
-
this.quickInputService?.onHide(() => {
|
|
111
|
-
if (this.updateIsOpen) {
|
|
112
|
-
this.isOpen = false;
|
|
113
|
-
} else {
|
|
114
|
-
this.updateIsOpen = true;
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
isEnabled(): boolean {
|
|
120
|
-
return this.workspaceService.opened;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
open(): void {
|
|
124
|
-
// Triggering the keyboard shortcut while the dialog is open toggles
|
|
125
|
-
// showing the ignored files.
|
|
126
|
-
if (this.isOpen) {
|
|
127
|
-
this.hideIgnoredFiles = !this.hideIgnoredFiles;
|
|
128
|
-
this.hideQuickPick();
|
|
129
|
-
} else {
|
|
130
|
-
this.hideIgnoredFiles = true;
|
|
131
|
-
this.filterAndRange = this.filterAndRangeDefault;
|
|
132
|
-
this.isOpen = true;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
this.quickInputService?.open(this.filterAndRange.filter);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
protected hideQuickPick(): void {
|
|
139
|
-
this.updateIsOpen = false;
|
|
140
|
-
this.quickInputService?.hide();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Get a string (suitable to show to the user) representing the keyboard
|
|
145
|
-
* shortcut used to open the quick file open menu.
|
|
146
|
-
*/
|
|
147
|
-
protected getKeyCommand(): string | undefined {
|
|
148
|
-
const keyCommand = this.keybindingRegistry.getKeybindingsForCommand(quickFileOpen.id);
|
|
149
|
-
if (keyCommand) {
|
|
150
|
-
// We only consider the first keybinding.
|
|
151
|
-
const accel = this.keybindingRegistry.acceleratorFor(keyCommand[0], '+');
|
|
152
|
-
return accel.join(' ');
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return undefined;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async getPicks(filter: string, token: CancellationToken): Promise<QuickPicks> {
|
|
159
|
-
const roots = this.workspaceService.tryGetRoots();
|
|
160
|
-
|
|
161
|
-
this.filterAndRange = this.splitFilterAndRange(filter);
|
|
162
|
-
const fileFilter = this.filterAndRange.filter;
|
|
163
|
-
|
|
164
|
-
const alreadyCollected = new Set<string>();
|
|
165
|
-
const recentlyUsedItems: QuickPicks = [];
|
|
166
|
-
|
|
167
|
-
if (this.preferences.get('search.quickOpen.includeHistory')) {
|
|
168
|
-
const locations = [...this.navigationLocationService.locations()].reverse();
|
|
169
|
-
for (const location of locations) {
|
|
170
|
-
const uriString = location.uri.toString();
|
|
171
|
-
|
|
172
|
-
if (location.uri.scheme === 'file' && !alreadyCollected.has(uriString) && fuzzy.test(fileFilter, uriString)) {
|
|
173
|
-
if (recentlyUsedItems.length === 0) {
|
|
174
|
-
recentlyUsedItems.push({
|
|
175
|
-
type: 'separator',
|
|
176
|
-
label: nls.localizeByDefault('recently opened')
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
const item = this.toItem(fileFilter, location.uri);
|
|
180
|
-
recentlyUsedItems.push(item);
|
|
181
|
-
alreadyCollected.add(uriString);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (fileFilter.length > 0) {
|
|
187
|
-
const handler = async (results: string[]) => {
|
|
188
|
-
if (token.isCancellationRequested || results.length <= 0) {
|
|
189
|
-
return [];
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const result = [...recentlyUsedItems];
|
|
193
|
-
const fileSearchResultItems: FileQuickPickItem[] = [];
|
|
194
|
-
|
|
195
|
-
for (const fileUri of results) {
|
|
196
|
-
if (!alreadyCollected.has(fileUri)) {
|
|
197
|
-
const item = this.toItem(fileFilter, fileUri);
|
|
198
|
-
fileSearchResultItems.push(item);
|
|
199
|
-
alreadyCollected.add(fileUri);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Create a copy of the file search results and sort.
|
|
204
|
-
const sortedResults = fileSearchResultItems.slice();
|
|
205
|
-
sortedResults.sort((a, b) => this.compareItems(a, b));
|
|
206
|
-
|
|
207
|
-
if (sortedResults.length > 0) {
|
|
208
|
-
result.push({
|
|
209
|
-
type: 'separator',
|
|
210
|
-
label: nls.localizeByDefault('file results')
|
|
211
|
-
});
|
|
212
|
-
result.push(...sortedResults);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Return the recently used items, followed by the search results.
|
|
216
|
-
return result;
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
return this.fileSearchService.find(fileFilter, {
|
|
220
|
-
rootUris: roots.map(r => r.resource.toString()),
|
|
221
|
-
fuzzyMatch: true,
|
|
222
|
-
limit: 200,
|
|
223
|
-
useGitIgnore: this.hideIgnoredFiles,
|
|
224
|
-
excludePatterns: this.hideIgnoredFiles
|
|
225
|
-
? Object.keys(this.fsPreferences['files.exclude'])
|
|
226
|
-
: undefined,
|
|
227
|
-
}, token).then(handler);
|
|
228
|
-
} else {
|
|
229
|
-
return roots.length !== 0 ? recentlyUsedItems : [];
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
protected compareItems(
|
|
234
|
-
left: FileQuickPickItem,
|
|
235
|
-
right: FileQuickPickItem): number {
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Score a given string.
|
|
239
|
-
*
|
|
240
|
-
* @param str the string to score on.
|
|
241
|
-
* @returns the score.
|
|
242
|
-
*/
|
|
243
|
-
function score(str: string | undefined): number {
|
|
244
|
-
if (!str) {
|
|
245
|
-
return 0;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
let exactMatch = true;
|
|
249
|
-
const partialMatches = querySplit.reduce((matched, part) => {
|
|
250
|
-
const partMatches = str.includes(part);
|
|
251
|
-
exactMatch = exactMatch && partMatches;
|
|
252
|
-
return partMatches ? matched + QuickFileOpenService.Scores.partial : matched;
|
|
253
|
-
}, 0);
|
|
254
|
-
|
|
255
|
-
// Check fuzzy matches.
|
|
256
|
-
const fuzzyMatch = fuzzy.match(queryJoin, str) ?? { score: 0 };
|
|
257
|
-
if (fuzzyMatch.score === Infinity && exactMatch) {
|
|
258
|
-
return Number.MAX_SAFE_INTEGER;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return fuzzyMatch.score + partialMatches + (exactMatch ? QuickFileOpenService.Scores.exact : 0);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const query: string = normalize(this.filterAndRange.filter);
|
|
265
|
-
// Adjust for whitespaces in the query.
|
|
266
|
-
const querySplit = query.split(WHITESPACE_QUERY_SEPARATOR);
|
|
267
|
-
const queryJoin = querySplit.join('');
|
|
268
|
-
|
|
269
|
-
const compareByLabelScore = (l: FileQuickPickItem, r: FileQuickPickItem) => score(r.label) - score(l.label);
|
|
270
|
-
const compareByLabelIndex = (l: FileQuickPickItem, r: FileQuickPickItem) => r.label.indexOf(query) - l.label.indexOf(query);
|
|
271
|
-
const compareByLabel = (l: FileQuickPickItem, r: FileQuickPickItem) => l.label.localeCompare(r.label);
|
|
272
|
-
|
|
273
|
-
const compareByPathScore = (l: FileQuickPickItem, r: FileQuickPickItem) => score(r.uri.path.toString()) - score(l.uri.path.toString());
|
|
274
|
-
const compareByPathIndex = (l: FileQuickPickItem, r: FileQuickPickItem) => r.uri.path.toString().indexOf(query) - l.uri.path.toString().indexOf(query);
|
|
275
|
-
const compareByPathLabel = (l: FileQuickPickItem, r: FileQuickPickItem) => l.uri.path.toString().localeCompare(r.uri.path.toString());
|
|
276
|
-
|
|
277
|
-
return compareWithDiscriminators(left, right, compareByLabelScore, compareByLabelIndex, compareByLabel, compareByPathScore, compareByPathIndex, compareByPathLabel);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
openFile(uri: URI): void {
|
|
281
|
-
const options = this.buildOpenerOptions();
|
|
282
|
-
const closedEditor = this.navigationLocationService.closedEditorsStack.find(editor => editor.uri.path.toString() === uri.path.toString());
|
|
283
|
-
this.openerService.getOpener(uri, options)
|
|
284
|
-
.then(opener => opener.open(uri, options))
|
|
285
|
-
.then(widget => {
|
|
286
|
-
// Attempt to restore the editor state if it exists, and no selection is explicitly requested.
|
|
287
|
-
if (widget instanceof EditorWidget && closedEditor && !options.selection) {
|
|
288
|
-
widget.editor.restoreViewState(closedEditor.viewState);
|
|
289
|
-
}
|
|
290
|
-
})
|
|
291
|
-
.catch(error => {
|
|
292
|
-
console.warn(error);
|
|
293
|
-
this.messageService.error(nls.localizeByDefault("Unable to open '{0}'", uri.path.toString()));
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
protected buildOpenerOptions(): EditorOpenerOptions {
|
|
298
|
-
return { selection: this.filterAndRange.range };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private toItem(lookFor: string, uriOrString: URI | string): FileQuickPickItem {
|
|
302
|
-
const uri = uriOrString instanceof URI ? uriOrString : new URI(uriOrString);
|
|
303
|
-
const label = this.labelProvider.getName(uri);
|
|
304
|
-
const description = this.getItemDescription(uri);
|
|
305
|
-
const iconClasses = this.getItemIconClasses(uri);
|
|
306
|
-
|
|
307
|
-
return {
|
|
308
|
-
label,
|
|
309
|
-
description,
|
|
310
|
-
highlights: {
|
|
311
|
-
label: findMatches(label, lookFor),
|
|
312
|
-
description: findMatches(description, lookFor)
|
|
313
|
-
},
|
|
314
|
-
iconClasses,
|
|
315
|
-
uri,
|
|
316
|
-
execute: () => this.openFile(uri)
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
private getItemIconClasses(uri: URI): string[] | undefined {
|
|
321
|
-
const icon = this.labelProvider.getIcon(uri);
|
|
322
|
-
return icon !== '' ? [icon + ' file-icon'] : [];
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
private getItemDescription(uri: URI): string {
|
|
326
|
-
return this.labelProvider.getDetails(uri);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
private getPlaceHolder(): string {
|
|
330
|
-
let placeholder = nls.localizeByDefault('Search files by name (append {0} to go to line or {1} to go to symbol)', ':', '@');
|
|
331
|
-
const keybinding = this.getKeyCommand();
|
|
332
|
-
if (keybinding) {
|
|
333
|
-
placeholder += nls.localize('theia/file-search/toggleIgnoredFiles', ' (Press {0} to show/hide ignored files)', keybinding);
|
|
334
|
-
}
|
|
335
|
-
return placeholder;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Splits the given expression into a structure of search-file-filter and
|
|
340
|
-
* location-range.
|
|
341
|
-
*
|
|
342
|
-
* @param expression patterns of <path><#|:><line><#|:|,><col?>
|
|
343
|
-
*/
|
|
344
|
-
protected splitFilterAndRange(expression: string): FilterAndRange {
|
|
345
|
-
let filter = expression;
|
|
346
|
-
let range = undefined;
|
|
347
|
-
|
|
348
|
-
// Find line and column number from the expression using RegExp.
|
|
349
|
-
const patternMatch = LINE_COLON_PATTERN.exec(expression);
|
|
350
|
-
|
|
351
|
-
if (patternMatch) {
|
|
352
|
-
const line = parseInt(patternMatch[1] ?? '', 10);
|
|
353
|
-
if (Number.isFinite(line)) {
|
|
354
|
-
const lineNumber = line > 0 ? line - 1 : 0;
|
|
355
|
-
|
|
356
|
-
const column = parseInt(patternMatch[2] ?? '', 10);
|
|
357
|
-
const startColumn = Number.isFinite(column) && column > 0 ? column - 1 : 0;
|
|
358
|
-
const position = Position.create(lineNumber, startColumn);
|
|
359
|
-
|
|
360
|
-
filter = expression.substring(0, patternMatch.index);
|
|
361
|
-
range = Range.create(position, position);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
return { filter, range };
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Normalize a given string.
|
|
370
|
-
*
|
|
371
|
-
* @param str the raw string value.
|
|
372
|
-
* @returns the normalized string value.
|
|
373
|
-
*/
|
|
374
|
-
function normalize(str: string): string {
|
|
375
|
-
return str.trim().toLowerCase();
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function compareWithDiscriminators<T>(left: T, right: T, ...discriminators: ((left: T, right: T) => number)[]): number {
|
|
379
|
-
let comparisonValue = 0;
|
|
380
|
-
let i = 0;
|
|
381
|
-
|
|
382
|
-
while (comparisonValue === 0 && i < discriminators.length) {
|
|
383
|
-
comparisonValue = discriminators[i](left, right);
|
|
384
|
-
i++;
|
|
385
|
-
}
|
|
386
|
-
return comparisonValue;
|
|
387
|
-
}
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2017 TypeFox and others.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
|
|
18
|
+
import { OpenerService, KeybindingRegistry, QuickAccessRegistry, QuickAccessProvider, CommonCommands, PreferenceService } from '@theia/core/lib/browser';
|
|
19
|
+
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
|
20
|
+
import URI from '@theia/core/lib/common/uri';
|
|
21
|
+
import { FileSearchService, WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service';
|
|
22
|
+
import { CancellationToken, Command, nls } from '@theia/core/lib/common';
|
|
23
|
+
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
|
|
24
|
+
import { NavigationLocationService } from '@theia/editor/lib/browser/navigation/navigation-location-service';
|
|
25
|
+
import * as fuzzy from '@theia/core/shared/fuzzy';
|
|
26
|
+
import { MessageService } from '@theia/core/lib/common/message-service';
|
|
27
|
+
import { FileSystemPreferences } from '@theia/filesystem/lib/browser';
|
|
28
|
+
import { EditorOpenerOptions, EditorWidget, Position, Range } from '@theia/editor/lib/browser';
|
|
29
|
+
import { findMatches, QuickInputService, QuickPickItem, QuickPicks } from '@theia/core/lib/browser/quick-input/quick-input-service';
|
|
30
|
+
|
|
31
|
+
export const quickFileOpen = Command.toDefaultLocalizedCommand({
|
|
32
|
+
id: 'file-search.openFile',
|
|
33
|
+
category: CommonCommands.FILE_CATEGORY,
|
|
34
|
+
label: 'Open File...'
|
|
35
|
+
});
|
|
36
|
+
export interface FilterAndRange {
|
|
37
|
+
filter: string;
|
|
38
|
+
range?: Range;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Supports patterns of <path><#|:><line><#|:|,><col?>
|
|
42
|
+
const LINE_COLON_PATTERN = /\s?[#:\(](?:line )?(\d*)(?:[#:,](\d*))?\)?\s*$/;
|
|
43
|
+
export type FileQuickPickItem = QuickPickItem & { uri: URI };
|
|
44
|
+
|
|
45
|
+
@injectable()
|
|
46
|
+
export class QuickFileOpenService implements QuickAccessProvider {
|
|
47
|
+
static readonly PREFIX = '';
|
|
48
|
+
|
|
49
|
+
@inject(KeybindingRegistry)
|
|
50
|
+
protected readonly keybindingRegistry: KeybindingRegistry;
|
|
51
|
+
@inject(WorkspaceService)
|
|
52
|
+
protected readonly workspaceService: WorkspaceService;
|
|
53
|
+
@inject(OpenerService)
|
|
54
|
+
protected readonly openerService: OpenerService;
|
|
55
|
+
@inject(QuickInputService) @optional()
|
|
56
|
+
protected readonly quickInputService: QuickInputService;
|
|
57
|
+
@inject(QuickAccessRegistry)
|
|
58
|
+
protected readonly quickAccessRegistry: QuickAccessRegistry;
|
|
59
|
+
@inject(FileSearchService)
|
|
60
|
+
protected readonly fileSearchService: FileSearchService;
|
|
61
|
+
@inject(LabelProvider)
|
|
62
|
+
protected readonly labelProvider: LabelProvider;
|
|
63
|
+
@inject(NavigationLocationService)
|
|
64
|
+
protected readonly navigationLocationService: NavigationLocationService;
|
|
65
|
+
@inject(MessageService)
|
|
66
|
+
protected readonly messageService: MessageService;
|
|
67
|
+
@inject(FileSystemPreferences)
|
|
68
|
+
protected readonly fsPreferences: FileSystemPreferences;
|
|
69
|
+
@inject(PreferenceService)
|
|
70
|
+
protected readonly preferences: PreferenceService;
|
|
71
|
+
|
|
72
|
+
registerQuickAccessProvider(): void {
|
|
73
|
+
this.quickAccessRegistry.registerQuickAccessProvider({
|
|
74
|
+
getInstance: () => this,
|
|
75
|
+
prefix: QuickFileOpenService.PREFIX,
|
|
76
|
+
placeholder: this.getPlaceHolder(),
|
|
77
|
+
helpEntries: [{ description: 'Open File', needsEditor: false }]
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether to hide .gitignored (and other ignored) files.
|
|
83
|
+
*/
|
|
84
|
+
protected hideIgnoredFiles = true;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether the dialog is currently open.
|
|
88
|
+
*/
|
|
89
|
+
protected isOpen = false;
|
|
90
|
+
private updateIsOpen = true;
|
|
91
|
+
|
|
92
|
+
protected filterAndRangeDefault = { filter: '', range: undefined };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Tracks the user file search filter and location range e.g. fileFilter:line:column or fileFilter:line,column
|
|
96
|
+
*/
|
|
97
|
+
protected filterAndRange: FilterAndRange = this.filterAndRangeDefault;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The score constants when comparing file search results.
|
|
101
|
+
*/
|
|
102
|
+
private static readonly Scores = {
|
|
103
|
+
max: 1000, // represents the maximum score from fuzzy matching (Infinity).
|
|
104
|
+
exact: 500, // represents the score assigned to exact matching.
|
|
105
|
+
partial: 250 // represents the score assigned to partial matching.
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
@postConstruct()
|
|
109
|
+
protected init(): void {
|
|
110
|
+
this.quickInputService?.onHide(() => {
|
|
111
|
+
if (this.updateIsOpen) {
|
|
112
|
+
this.isOpen = false;
|
|
113
|
+
} else {
|
|
114
|
+
this.updateIsOpen = true;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
isEnabled(): boolean {
|
|
120
|
+
return this.workspaceService.opened;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
open(): void {
|
|
124
|
+
// Triggering the keyboard shortcut while the dialog is open toggles
|
|
125
|
+
// showing the ignored files.
|
|
126
|
+
if (this.isOpen) {
|
|
127
|
+
this.hideIgnoredFiles = !this.hideIgnoredFiles;
|
|
128
|
+
this.hideQuickPick();
|
|
129
|
+
} else {
|
|
130
|
+
this.hideIgnoredFiles = true;
|
|
131
|
+
this.filterAndRange = this.filterAndRangeDefault;
|
|
132
|
+
this.isOpen = true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.quickInputService?.open(this.filterAndRange.filter);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
protected hideQuickPick(): void {
|
|
139
|
+
this.updateIsOpen = false;
|
|
140
|
+
this.quickInputService?.hide();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get a string (suitable to show to the user) representing the keyboard
|
|
145
|
+
* shortcut used to open the quick file open menu.
|
|
146
|
+
*/
|
|
147
|
+
protected getKeyCommand(): string | undefined {
|
|
148
|
+
const keyCommand = this.keybindingRegistry.getKeybindingsForCommand(quickFileOpen.id);
|
|
149
|
+
if (keyCommand) {
|
|
150
|
+
// We only consider the first keybinding.
|
|
151
|
+
const accel = this.keybindingRegistry.acceleratorFor(keyCommand[0], '+');
|
|
152
|
+
return accel.join(' ');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async getPicks(filter: string, token: CancellationToken): Promise<QuickPicks> {
|
|
159
|
+
const roots = this.workspaceService.tryGetRoots();
|
|
160
|
+
|
|
161
|
+
this.filterAndRange = this.splitFilterAndRange(filter);
|
|
162
|
+
const fileFilter = this.filterAndRange.filter;
|
|
163
|
+
|
|
164
|
+
const alreadyCollected = new Set<string>();
|
|
165
|
+
const recentlyUsedItems: QuickPicks = [];
|
|
166
|
+
|
|
167
|
+
if (this.preferences.get('search.quickOpen.includeHistory')) {
|
|
168
|
+
const locations = [...this.navigationLocationService.locations()].reverse();
|
|
169
|
+
for (const location of locations) {
|
|
170
|
+
const uriString = location.uri.toString();
|
|
171
|
+
|
|
172
|
+
if (location.uri.scheme === 'file' && !alreadyCollected.has(uriString) && fuzzy.test(fileFilter, uriString)) {
|
|
173
|
+
if (recentlyUsedItems.length === 0) {
|
|
174
|
+
recentlyUsedItems.push({
|
|
175
|
+
type: 'separator',
|
|
176
|
+
label: nls.localizeByDefault('recently opened')
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const item = this.toItem(fileFilter, location.uri);
|
|
180
|
+
recentlyUsedItems.push(item);
|
|
181
|
+
alreadyCollected.add(uriString);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (fileFilter.length > 0) {
|
|
187
|
+
const handler = async (results: string[]) => {
|
|
188
|
+
if (token.isCancellationRequested || results.length <= 0) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const result = [...recentlyUsedItems];
|
|
193
|
+
const fileSearchResultItems: FileQuickPickItem[] = [];
|
|
194
|
+
|
|
195
|
+
for (const fileUri of results) {
|
|
196
|
+
if (!alreadyCollected.has(fileUri)) {
|
|
197
|
+
const item = this.toItem(fileFilter, fileUri);
|
|
198
|
+
fileSearchResultItems.push(item);
|
|
199
|
+
alreadyCollected.add(fileUri);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Create a copy of the file search results and sort.
|
|
204
|
+
const sortedResults = fileSearchResultItems.slice();
|
|
205
|
+
sortedResults.sort((a, b) => this.compareItems(a, b));
|
|
206
|
+
|
|
207
|
+
if (sortedResults.length > 0) {
|
|
208
|
+
result.push({
|
|
209
|
+
type: 'separator',
|
|
210
|
+
label: nls.localizeByDefault('file results')
|
|
211
|
+
});
|
|
212
|
+
result.push(...sortedResults);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Return the recently used items, followed by the search results.
|
|
216
|
+
return result;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return this.fileSearchService.find(fileFilter, {
|
|
220
|
+
rootUris: roots.map(r => r.resource.toString()),
|
|
221
|
+
fuzzyMatch: true,
|
|
222
|
+
limit: 200,
|
|
223
|
+
useGitIgnore: this.hideIgnoredFiles,
|
|
224
|
+
excludePatterns: this.hideIgnoredFiles
|
|
225
|
+
? Object.keys(this.fsPreferences['files.exclude'])
|
|
226
|
+
: undefined,
|
|
227
|
+
}, token).then(handler);
|
|
228
|
+
} else {
|
|
229
|
+
return roots.length !== 0 ? recentlyUsedItems : [];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
protected compareItems(
|
|
234
|
+
left: FileQuickPickItem,
|
|
235
|
+
right: FileQuickPickItem): number {
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Score a given string.
|
|
239
|
+
*
|
|
240
|
+
* @param str the string to score on.
|
|
241
|
+
* @returns the score.
|
|
242
|
+
*/
|
|
243
|
+
function score(str: string | undefined): number {
|
|
244
|
+
if (!str) {
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let exactMatch = true;
|
|
249
|
+
const partialMatches = querySplit.reduce((matched, part) => {
|
|
250
|
+
const partMatches = str.includes(part);
|
|
251
|
+
exactMatch = exactMatch && partMatches;
|
|
252
|
+
return partMatches ? matched + QuickFileOpenService.Scores.partial : matched;
|
|
253
|
+
}, 0);
|
|
254
|
+
|
|
255
|
+
// Check fuzzy matches.
|
|
256
|
+
const fuzzyMatch = fuzzy.match(queryJoin, str) ?? { score: 0 };
|
|
257
|
+
if (fuzzyMatch.score === Infinity && exactMatch) {
|
|
258
|
+
return Number.MAX_SAFE_INTEGER;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return fuzzyMatch.score + partialMatches + (exactMatch ? QuickFileOpenService.Scores.exact : 0);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const query: string = normalize(this.filterAndRange.filter);
|
|
265
|
+
// Adjust for whitespaces in the query.
|
|
266
|
+
const querySplit = query.split(WHITESPACE_QUERY_SEPARATOR);
|
|
267
|
+
const queryJoin = querySplit.join('');
|
|
268
|
+
|
|
269
|
+
const compareByLabelScore = (l: FileQuickPickItem, r: FileQuickPickItem) => score(r.label) - score(l.label);
|
|
270
|
+
const compareByLabelIndex = (l: FileQuickPickItem, r: FileQuickPickItem) => r.label.indexOf(query) - l.label.indexOf(query);
|
|
271
|
+
const compareByLabel = (l: FileQuickPickItem, r: FileQuickPickItem) => l.label.localeCompare(r.label);
|
|
272
|
+
|
|
273
|
+
const compareByPathScore = (l: FileQuickPickItem, r: FileQuickPickItem) => score(r.uri.path.toString()) - score(l.uri.path.toString());
|
|
274
|
+
const compareByPathIndex = (l: FileQuickPickItem, r: FileQuickPickItem) => r.uri.path.toString().indexOf(query) - l.uri.path.toString().indexOf(query);
|
|
275
|
+
const compareByPathLabel = (l: FileQuickPickItem, r: FileQuickPickItem) => l.uri.path.toString().localeCompare(r.uri.path.toString());
|
|
276
|
+
|
|
277
|
+
return compareWithDiscriminators(left, right, compareByLabelScore, compareByLabelIndex, compareByLabel, compareByPathScore, compareByPathIndex, compareByPathLabel);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
openFile(uri: URI): void {
|
|
281
|
+
const options = this.buildOpenerOptions();
|
|
282
|
+
const closedEditor = this.navigationLocationService.closedEditorsStack.find(editor => editor.uri.path.toString() === uri.path.toString());
|
|
283
|
+
this.openerService.getOpener(uri, options)
|
|
284
|
+
.then(opener => opener.open(uri, options))
|
|
285
|
+
.then(widget => {
|
|
286
|
+
// Attempt to restore the editor state if it exists, and no selection is explicitly requested.
|
|
287
|
+
if (widget instanceof EditorWidget && closedEditor && !options.selection) {
|
|
288
|
+
widget.editor.restoreViewState(closedEditor.viewState);
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
.catch(error => {
|
|
292
|
+
console.warn(error);
|
|
293
|
+
this.messageService.error(nls.localizeByDefault("Unable to open '{0}'", uri.path.toString()));
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
protected buildOpenerOptions(): EditorOpenerOptions {
|
|
298
|
+
return { selection: this.filterAndRange.range };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private toItem(lookFor: string, uriOrString: URI | string): FileQuickPickItem {
|
|
302
|
+
const uri = uriOrString instanceof URI ? uriOrString : new URI(uriOrString);
|
|
303
|
+
const label = this.labelProvider.getName(uri);
|
|
304
|
+
const description = this.getItemDescription(uri);
|
|
305
|
+
const iconClasses = this.getItemIconClasses(uri);
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
label,
|
|
309
|
+
description,
|
|
310
|
+
highlights: {
|
|
311
|
+
label: findMatches(label, lookFor),
|
|
312
|
+
description: findMatches(description, lookFor)
|
|
313
|
+
},
|
|
314
|
+
iconClasses,
|
|
315
|
+
uri,
|
|
316
|
+
execute: () => this.openFile(uri)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private getItemIconClasses(uri: URI): string[] | undefined {
|
|
321
|
+
const icon = this.labelProvider.getIcon(uri);
|
|
322
|
+
return icon !== '' ? [icon + ' file-icon'] : [];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private getItemDescription(uri: URI): string {
|
|
326
|
+
return this.labelProvider.getDetails(uri);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private getPlaceHolder(): string {
|
|
330
|
+
let placeholder = nls.localizeByDefault('Search files by name (append {0} to go to line or {1} to go to symbol)', ':', '@');
|
|
331
|
+
const keybinding = this.getKeyCommand();
|
|
332
|
+
if (keybinding) {
|
|
333
|
+
placeholder += nls.localize('theia/file-search/toggleIgnoredFiles', ' (Press {0} to show/hide ignored files)', keybinding);
|
|
334
|
+
}
|
|
335
|
+
return placeholder;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Splits the given expression into a structure of search-file-filter and
|
|
340
|
+
* location-range.
|
|
341
|
+
*
|
|
342
|
+
* @param expression patterns of <path><#|:><line><#|:|,><col?>
|
|
343
|
+
*/
|
|
344
|
+
protected splitFilterAndRange(expression: string): FilterAndRange {
|
|
345
|
+
let filter = expression;
|
|
346
|
+
let range = undefined;
|
|
347
|
+
|
|
348
|
+
// Find line and column number from the expression using RegExp.
|
|
349
|
+
const patternMatch = LINE_COLON_PATTERN.exec(expression);
|
|
350
|
+
|
|
351
|
+
if (patternMatch) {
|
|
352
|
+
const line = parseInt(patternMatch[1] ?? '', 10);
|
|
353
|
+
if (Number.isFinite(line)) {
|
|
354
|
+
const lineNumber = line > 0 ? line - 1 : 0;
|
|
355
|
+
|
|
356
|
+
const column = parseInt(patternMatch[2] ?? '', 10);
|
|
357
|
+
const startColumn = Number.isFinite(column) && column > 0 ? column - 1 : 0;
|
|
358
|
+
const position = Position.create(lineNumber, startColumn);
|
|
359
|
+
|
|
360
|
+
filter = expression.substring(0, patternMatch.index);
|
|
361
|
+
range = Range.create(position, position);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return { filter, range };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Normalize a given string.
|
|
370
|
+
*
|
|
371
|
+
* @param str the raw string value.
|
|
372
|
+
* @returns the normalized string value.
|
|
373
|
+
*/
|
|
374
|
+
function normalize(str: string): string {
|
|
375
|
+
return str.trim().toLowerCase();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function compareWithDiscriminators<T>(left: T, right: T, ...discriminators: ((left: T, right: T) => number)[]): number {
|
|
379
|
+
let comparisonValue = 0;
|
|
380
|
+
let i = 0;
|
|
381
|
+
|
|
382
|
+
while (comparisonValue === 0 && i < discriminators.length) {
|
|
383
|
+
comparisonValue = discriminators[i](left, right);
|
|
384
|
+
i++;
|
|
385
|
+
}
|
|
386
|
+
return comparisonValue;
|
|
387
|
+
}
|