@theia/search-in-workspace 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.
Files changed (58) hide show
  1. package/README.md +40 -40
  2. package/lib/browser/components/search-in-workspace-input.d.ts +39 -39
  3. package/lib/browser/components/search-in-workspace-input.js +123 -123
  4. package/lib/browser/components/search-in-workspace-textarea.d.ts +39 -39
  5. package/lib/browser/components/search-in-workspace-textarea.js +130 -130
  6. package/lib/browser/search-in-workspace-context-key-service.d.ts +23 -23
  7. package/lib/browser/search-in-workspace-context-key-service.js +90 -90
  8. package/lib/browser/search-in-workspace-factory.d.ts +10 -10
  9. package/lib/browser/search-in-workspace-factory.js +68 -68
  10. package/lib/browser/search-in-workspace-frontend-contribution.d.ts +57 -55
  11. package/lib/browser/search-in-workspace-frontend-contribution.d.ts.map +1 -1
  12. package/lib/browser/search-in-workspace-frontend-contribution.js +516 -482
  13. package/lib/browser/search-in-workspace-frontend-contribution.js.map +1 -1
  14. package/lib/browser/search-in-workspace-frontend-module.d.ts +6 -6
  15. package/lib/browser/search-in-workspace-frontend-module.js +71 -71
  16. package/lib/browser/search-in-workspace-label-provider.d.ts +9 -9
  17. package/lib/browser/search-in-workspace-label-provider.js +57 -57
  18. package/lib/browser/search-in-workspace-preferences.d.ts +17 -17
  19. package/lib/browser/search-in-workspace-preferences.js +87 -87
  20. package/lib/browser/search-in-workspace-result-tree-widget.d.ts +259 -255
  21. package/lib/browser/search-in-workspace-result-tree-widget.d.ts.map +1 -1
  22. package/lib/browser/search-in-workspace-result-tree-widget.js +1172 -1099
  23. package/lib/browser/search-in-workspace-result-tree-widget.js.map +1 -1
  24. package/lib/browser/search-in-workspace-service.d.ts +35 -35
  25. package/lib/browser/search-in-workspace-service.js +158 -158
  26. package/lib/browser/search-in-workspace-widget.d.ts +121 -121
  27. package/lib/browser/search-in-workspace-widget.js +629 -629
  28. package/lib/browser/search-layout-migrations.d.ts +5 -5
  29. package/lib/browser/search-layout-migrations.js +64 -64
  30. package/lib/common/search-in-workspace-interface.d.ts +116 -116
  31. package/lib/common/search-in-workspace-interface.js +35 -35
  32. package/lib/node/ripgrep-search-in-workspace-server.d.ts +94 -94
  33. package/lib/node/ripgrep-search-in-workspace-server.js +430 -430
  34. package/lib/node/ripgrep-search-in-workspace-server.js.map +1 -1
  35. package/lib/node/ripgrep-search-in-workspace-server.slow-spec.d.ts +1 -1
  36. package/lib/node/ripgrep-search-in-workspace-server.slow-spec.js +899 -899
  37. package/lib/node/ripgrep-search-in-workspace-server.slow-spec.js.map +1 -1
  38. package/lib/node/search-in-workspace-backend-module.d.ts +3 -3
  39. package/lib/node/search-in-workspace-backend-module.js +32 -32
  40. package/package.json +9 -9
  41. package/src/browser/components/search-in-workspace-input.tsx +139 -139
  42. package/src/browser/components/search-in-workspace-textarea.tsx +153 -153
  43. package/src/browser/search-in-workspace-context-key-service.ts +93 -93
  44. package/src/browser/search-in-workspace-factory.ts +59 -59
  45. package/src/browser/search-in-workspace-frontend-contribution.ts +510 -474
  46. package/src/browser/search-in-workspace-frontend-module.ts +83 -83
  47. package/src/browser/search-in-workspace-label-provider.ts +48 -48
  48. package/src/browser/search-in-workspace-preferences.ts +96 -96
  49. package/src/browser/search-in-workspace-result-tree-widget.tsx +1318 -1245
  50. package/src/browser/search-in-workspace-service.ts +152 -152
  51. package/src/browser/search-in-workspace-widget.tsx +727 -727
  52. package/src/browser/search-layout-migrations.ts +53 -53
  53. package/src/browser/styles/index.css +400 -400
  54. package/src/browser/styles/search.svg +6 -6
  55. package/src/common/search-in-workspace-interface.ts +153 -153
  56. package/src/node/ripgrep-search-in-workspace-server.slow-spec.ts +1073 -1073
  57. package/src/node/ripgrep-search-in-workspace-server.ts +490 -490
  58. package/src/node/search-in-workspace-backend-module.ts +33 -33
@@ -1,1245 +1,1318 @@
1
- // *****************************************************************************
2
- // Copyright (C) 2018 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, postConstruct } from '@theia/core/shared/inversify';
18
- import {
19
- TreeWidget,
20
- CompositeTreeNode,
21
- ConfirmDialog,
22
- ContextMenuRenderer,
23
- ExpandableTreeNode,
24
- SelectableTreeNode,
25
- TreeModel,
26
- TreeNode,
27
- NodeProps,
28
- TreeProps,
29
- TreeExpansionService,
30
- ApplicationShell,
31
- DiffUris,
32
- TREE_NODE_INFO_CLASS,
33
- codicon,
34
- TopDownTreeIterator
35
- } from '@theia/core/lib/browser';
36
- import { CancellationTokenSource, Emitter, EOL, Event, ProgressService } from '@theia/core';
37
- import {
38
- EditorManager, EditorDecoration, TrackedRangeStickiness, OverviewRulerLane,
39
- EditorWidget, EditorOpenerOptions, FindMatch, Position
40
- } from '@theia/editor/lib/browser';
41
- import { WorkspaceService } from '@theia/workspace/lib/browser';
42
- import { FileResourceResolver, FileSystemPreferences } from '@theia/filesystem/lib/browser';
43
- import { FileService } from '@theia/filesystem/lib/browser/file-service';
44
- import { SearchInWorkspaceResult, SearchInWorkspaceOptions, SearchMatch } from '../common/search-in-workspace-interface';
45
- import { SearchInWorkspaceService } from './search-in-workspace-service';
46
- import { MEMORY_TEXT } from '@theia/core/lib/common';
47
- import URI from '@theia/core/lib/common/uri';
48
- import * as React from '@theia/core/shared/react';
49
- import { SearchInWorkspacePreferences } from './search-in-workspace-preferences';
50
- import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
51
- import * as minimatch from 'minimatch';
52
- import { DisposableCollection } from '@theia/core/lib/common/disposable';
53
- import debounce = require('@theia/core/shared/lodash.debounce');
54
- import { nls } from '@theia/core/lib/common/nls';
55
-
56
- const ROOT_ID = 'ResultTree';
57
-
58
- export interface SearchInWorkspaceRoot extends CompositeTreeNode {
59
- children: SearchInWorkspaceRootFolderNode[];
60
- }
61
- export namespace SearchInWorkspaceRoot {
62
- export function is(node: unknown): node is SearchInWorkspaceRoot {
63
- return CompositeTreeNode.is(node) && node.id === ROOT_ID;
64
- }
65
- }
66
- export interface SearchInWorkspaceRootFolderNode extends ExpandableTreeNode, SelectableTreeNode { // root folder node
67
- name?: undefined
68
- icon?: undefined
69
- children: SearchInWorkspaceFileNode[];
70
- parent: SearchInWorkspaceRoot;
71
- path: string;
72
- folderUri: string;
73
- uri: URI;
74
- }
75
- export namespace SearchInWorkspaceRootFolderNode {
76
- export function is(node: unknown): node is SearchInWorkspaceRootFolderNode {
77
- return ExpandableTreeNode.is(node) && SelectableTreeNode.is(node) && 'path' in node && 'folderUri' in node && !('fileUri' in node);
78
- }
79
- }
80
-
81
- export interface SearchInWorkspaceFileNode extends ExpandableTreeNode, SelectableTreeNode { // file node
82
- name?: undefined
83
- icon?: undefined
84
- children: SearchInWorkspaceResultLineNode[];
85
- parent: SearchInWorkspaceRootFolderNode;
86
- path: string;
87
- fileUri: string;
88
- uri: URI;
89
- }
90
- export namespace SearchInWorkspaceFileNode {
91
- export function is(node: unknown): node is SearchInWorkspaceFileNode {
92
- return ExpandableTreeNode.is(node) && SelectableTreeNode.is(node) && 'path' in node && 'fileUri' in node && !('folderUri' in node);
93
- }
94
- }
95
-
96
- export interface SearchInWorkspaceResultLineNode extends SelectableTreeNode, SearchInWorkspaceResult, SearchMatch { // line node
97
- parent: SearchInWorkspaceFileNode
98
- }
99
- export namespace SearchInWorkspaceResultLineNode {
100
- export function is(node: unknown): node is SearchInWorkspaceResultLineNode {
101
- return SelectableTreeNode.is(node) && 'line' in node && 'character' in node && 'lineText' in node;
102
- }
103
- }
104
-
105
- @injectable()
106
- export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
107
-
108
- protected resultTree: Map<string, SearchInWorkspaceRootFolderNode>;
109
-
110
- protected _showReplaceButtons = false;
111
- protected _replaceTerm = '';
112
- protected searchTerm = '';
113
- protected searchOptions: SearchInWorkspaceOptions;
114
-
115
- protected readonly startSearchOnModification = (activeEditor: EditorWidget) => debounce(
116
- () => this.searchActiveEditor(activeEditor, this.searchTerm, this.searchOptions),
117
- this.searchOnEditorModificationDelay
118
- );
119
-
120
- protected readonly searchOnEditorModificationDelay = 300;
121
- protected readonly toDisposeOnActiveEditorChanged = new DisposableCollection();
122
-
123
- // The default root name to add external search results in the case that a workspace is opened.
124
- protected readonly defaultRootName = nls.localizeByDefault('Other files');
125
- protected forceVisibleRootNode = false;
126
-
127
- protected appliedDecorations = new Map<string, string[]>();
128
-
129
- cancelIndicator?: CancellationTokenSource;
130
-
131
- protected changeEmitter = new Emitter<Map<string, SearchInWorkspaceRootFolderNode>>();
132
-
133
- protected onExpansionChangedEmitter = new Emitter();
134
- readonly onExpansionChanged: Event<void> = this.onExpansionChangedEmitter.event;
135
-
136
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
- protected focusInputEmitter = new Emitter<any>();
138
-
139
- @inject(SearchInWorkspaceService) protected readonly searchService: SearchInWorkspaceService;
140
- @inject(EditorManager) protected readonly editorManager: EditorManager;
141
- @inject(FileResourceResolver) protected readonly fileResourceResolver: FileResourceResolver;
142
- @inject(ApplicationShell) protected readonly shell: ApplicationShell;
143
- @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
144
- @inject(TreeExpansionService) protected readonly expansionService: TreeExpansionService;
145
- @inject(SearchInWorkspacePreferences) protected readonly searchInWorkspacePreferences: SearchInWorkspacePreferences;
146
- @inject(ProgressService) protected readonly progressService: ProgressService;
147
- @inject(ColorRegistry) protected readonly colorRegistry: ColorRegistry;
148
- @inject(FileSystemPreferences) protected readonly filesystemPreferences: FileSystemPreferences;
149
- @inject(FileService) protected readonly fileService: FileService;
150
-
151
- constructor(
152
- @inject(TreeProps) props: TreeProps,
153
- @inject(TreeModel) model: TreeModel,
154
- @inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer
155
- ) {
156
- super(props, model, contextMenuRenderer);
157
-
158
- model.root = {
159
- id: ROOT_ID,
160
- parent: undefined,
161
- visible: false,
162
- children: []
163
- } as SearchInWorkspaceRoot;
164
-
165
- this.toDispose.push(model.onSelectionChanged(nodes => {
166
- const node = nodes[0];
167
- if (SearchInWorkspaceResultLineNode.is(node)) {
168
- this.doOpen(node, true, true);
169
- }
170
- }));
171
- this.toDispose.push(model.onOpenNode(node => {
172
- if (SearchInWorkspaceResultLineNode.is(node)) {
173
- this.doOpen(node, true, false);
174
- }
175
- }));
176
-
177
- this.resultTree = new Map<string, SearchInWorkspaceRootFolderNode>();
178
- this.toDispose.push(model.onNodeRefreshed(() => this.changeEmitter.fire(this.resultTree)));
179
- }
180
-
181
- @postConstruct()
182
- protected override init(): void {
183
- super.init();
184
- this.addClass('resultContainer');
185
-
186
- this.toDispose.push(this.changeEmitter);
187
- this.toDispose.push(this.focusInputEmitter);
188
-
189
- this.toDispose.push(this.editorManager.onActiveEditorChanged(activeEditor => {
190
- this.updateCurrentEditorDecorations();
191
- this.toDisposeOnActiveEditorChanged.dispose();
192
- this.toDispose.push(this.toDisposeOnActiveEditorChanged);
193
- if (activeEditor) {
194
- this.toDisposeOnActiveEditorChanged.push(activeEditor.editor.onDocumentContentChanged(() => {
195
- if (this.searchTerm !== '' && this.searchInWorkspacePreferences['search.searchOnEditorModification']) {
196
- this.startSearchOnModification(activeEditor)();
197
- }
198
- }));
199
- }
200
- }));
201
-
202
- this.toDispose.push(this.searchInWorkspacePreferences.onPreferenceChanged(() => {
203
- this.update();
204
- }));
205
-
206
- this.toDispose.push(this.fileService.onDidFilesChange(event => {
207
- if (event.gotDeleted()) {
208
- event.getDeleted().forEach(deletedFile => {
209
- const fileNodes = this.getFileNodesByUri(deletedFile.resource);
210
- fileNodes.forEach(node => this.removeFileNode(node));
211
- });
212
- this.model.refresh();
213
- }
214
- }));
215
-
216
- this.toDispose.push(this.model.onExpansionChanged(() => {
217
- this.onExpansionChangedEmitter.fire(undefined);
218
- }));
219
- }
220
-
221
- get fileNumber(): number {
222
- let num = 0;
223
- for (const rootFolderNode of this.resultTree.values()) {
224
- num += rootFolderNode.children.length;
225
- }
226
- return num;
227
- }
228
-
229
- set showReplaceButtons(srb: boolean) {
230
- this._showReplaceButtons = srb;
231
- this.update();
232
- }
233
-
234
- set replaceTerm(rt: string) {
235
- this._replaceTerm = rt;
236
- this.update();
237
- }
238
-
239
- get isReplacing(): boolean {
240
- return this._replaceTerm !== '' && this._showReplaceButtons;
241
- }
242
-
243
- get onChange(): Event<Map<string, SearchInWorkspaceRootFolderNode>> {
244
- return this.changeEmitter.event;
245
- }
246
-
247
- get onFocusInput(): Event<void> {
248
- return this.focusInputEmitter.event;
249
- }
250
-
251
- collapseAll(): void {
252
- for (const rootFolderNode of this.resultTree.values()) {
253
- for (const fileNode of rootFolderNode.children) {
254
- this.expansionService.collapseNode(fileNode);
255
- }
256
- if (rootFolderNode.visible) {
257
- this.expansionService.collapseNode(rootFolderNode);
258
- }
259
- }
260
- }
261
-
262
- expandAll(): void {
263
- for (const rootFolderNode of this.resultTree.values()) {
264
- for (const fileNode of rootFolderNode.children) {
265
- this.expansionService.expandNode(fileNode);
266
- }
267
- if (rootFolderNode.visible) {
268
- this.expansionService.expandNode(rootFolderNode);
269
- }
270
- }
271
- }
272
-
273
- areResultsCollapsed(): boolean {
274
- for (const rootFolderNode of this.resultTree.values()) {
275
- for (const fileNode of rootFolderNode.children) {
276
- if (!ExpandableTreeNode.isCollapsed(fileNode)) {
277
- return false;
278
- }
279
- }
280
- }
281
- return true;
282
- }
283
-
284
- /**
285
- * Find matches for the given editor.
286
- * @param searchTerm the search term.
287
- * @param widget the editor widget.
288
- * @param searchOptions the search options to apply.
289
- *
290
- * @returns the list of matches.
291
- */
292
- protected findMatches(searchTerm: string, widget: EditorWidget, searchOptions: SearchInWorkspaceOptions): SearchMatch[] {
293
- if (!widget.editor.document.findMatches) {
294
- return [];
295
- }
296
- const results: FindMatch[] = widget.editor.document.findMatches({
297
- searchString: searchTerm,
298
- isRegex: !!searchOptions.useRegExp,
299
- matchCase: !!searchOptions.matchCase,
300
- matchWholeWord: !!searchOptions.matchWholeWord,
301
- limitResultCount: searchOptions.maxResults
302
- });
303
-
304
- const matches: SearchMatch[] = [];
305
- results.forEach(r => {
306
- const numberOfLines = searchTerm.split('\n').length;
307
- const lineTexts = [];
308
- for (let i = 0; i < numberOfLines; i++) {
309
- lineTexts.push(widget.editor.document.getLineContent(r.range.start.line + i));
310
- }
311
- matches.push({
312
- line: r.range.start.line,
313
- character: r.range.start.character,
314
- length: searchTerm.length,
315
- lineText: lineTexts.join('\n')
316
- });
317
- });
318
-
319
- return matches;
320
- }
321
-
322
- /**
323
- * Convert a pattern to match all directories.
324
- * @param workspaceRootUri the uri of the current workspace root.
325
- * @param pattern the pattern to be converted.
326
- */
327
- protected convertPatternToGlob(workspaceRootUri: URI | undefined, pattern: string): string {
328
- if (pattern.startsWith('**/')) {
329
- return pattern;
330
- }
331
- if (pattern.startsWith('./')) {
332
- if (workspaceRootUri === undefined) {
333
- return pattern;
334
- }
335
- return workspaceRootUri.toString() + pattern.replace('./', '/');
336
- }
337
- return pattern.startsWith('/')
338
- ? '**' + pattern
339
- : '**/' + pattern;
340
- }
341
-
342
- /**
343
- * Determine if the URI matches any of the patterns.
344
- * @param uri the editor URI.
345
- * @param patterns the glob patterns to verify.
346
- */
347
- protected inPatternList(uri: URI, patterns: string[]): boolean {
348
- const opts: minimatch.IOptions = { dot: true, matchBase: true };
349
- return patterns.some(pattern => minimatch(
350
- uri.toString(),
351
- this.convertPatternToGlob(this.workspaceService.getWorkspaceRootUri(uri), pattern),
352
- opts
353
- ));
354
- }
355
-
356
- /**
357
- * Determine if the given editor satisfies the filtering criteria.
358
- * An editor should be searched only if:
359
- * - it is not excluded through the `excludes` list.
360
- * - it is not explicitly present in a non-empty `includes` list.
361
- */
362
- protected shouldApplySearch(editorWidget: EditorWidget, searchOptions: SearchInWorkspaceOptions): boolean {
363
- const excludePatterns = this.getExcludeGlobs(searchOptions.exclude);
364
- if (this.inPatternList(editorWidget.editor.uri, excludePatterns)) {
365
- return false;
366
- }
367
-
368
- const includePatterns = searchOptions.include;
369
- if (!!includePatterns?.length && !this.inPatternList(editorWidget.editor.uri, includePatterns)) {
370
- return false;
371
- }
372
-
373
- return true;
374
- }
375
-
376
- /**
377
- * Search the active editor only and update the tree with those results.
378
- */
379
- protected searchActiveEditor(activeEditor: EditorWidget, searchTerm: string, searchOptions: SearchInWorkspaceOptions): void {
380
- const includesExternalResults = () => !!this.resultTree.get(this.defaultRootName);
381
-
382
- // Check if outside workspace results are present before searching.
383
- const hasExternalResultsBefore = includesExternalResults();
384
-
385
- // Collect search results for the given editor.
386
- const results = this.searchInEditor(activeEditor, searchTerm, searchOptions);
387
-
388
- // Update the tree by removing the result node, and add new results if applicable.
389
- this.getFileNodesByUri(activeEditor.editor.uri).forEach(fileNode => this.removeFileNode(fileNode));
390
- if (results) {
391
- this.appendToResultTree(results);
392
- }
393
-
394
- // Check if outside workspace results are present after searching.
395
- const hasExternalResultsAfter = includesExternalResults();
396
-
397
- // Redo a search to update the tree node visibility if:
398
- // + `Other files` node was present, now it is not.
399
- // + `Other files` node was not present, now it is.
400
- if (hasExternalResultsBefore ? !hasExternalResultsAfter : hasExternalResultsAfter) {
401
- this.search(this.searchTerm, this.searchOptions);
402
- return;
403
- }
404
-
405
- this.handleSearchCompleted();
406
- }
407
-
408
- /**
409
- * Perform a search in all open editors.
410
- * @param searchTerm the search term.
411
- * @param searchOptions the search options to apply.
412
- *
413
- * @returns the tuple of result count, and the list of search results.
414
- */
415
- protected searchInOpenEditors(searchTerm: string, searchOptions: SearchInWorkspaceOptions): {
416
- numberOfResults: number,
417
- matches: SearchInWorkspaceResult[]
418
- } {
419
- // Track the number of results found.
420
- let numberOfResults = 0;
421
-
422
- const searchResults: SearchInWorkspaceResult[] = [];
423
-
424
- this.editorManager.all.forEach(e => {
425
- const editorResults = this.searchInEditor(e, searchTerm, searchOptions);
426
- if (editorResults) {
427
- numberOfResults += editorResults.matches.length;
428
- searchResults.push(editorResults);
429
- }
430
- });
431
-
432
- return {
433
- numberOfResults,
434
- matches: searchResults
435
- };
436
- }
437
-
438
- /**
439
- * Perform a search in the target editor.
440
- * @param editorWidget the editor widget.
441
- * @param searchTerm the search term.
442
- * @param searchOptions the search options to apply.
443
- *
444
- * @returns the search results from the given editor, undefined if the editor is either filtered or has no matches found.
445
- */
446
- protected searchInEditor(editorWidget: EditorWidget, searchTerm: string, searchOptions: SearchInWorkspaceOptions): SearchInWorkspaceResult | undefined {
447
- if (!this.shouldApplySearch(editorWidget, searchOptions)) {
448
- return undefined;
449
- }
450
-
451
- const matches: SearchMatch[] = this.findMatches(searchTerm, editorWidget, searchOptions);
452
- if (matches.length <= 0) {
453
- return undefined;
454
- }
455
-
456
- const fileUri = editorWidget.editor.uri.toString();
457
- const root: string | undefined = this.workspaceService.getWorkspaceRootUri(editorWidget.editor.uri)?.toString();
458
- return {
459
- root: root ?? this.defaultRootName,
460
- fileUri,
461
- matches
462
- };
463
- }
464
-
465
- /**
466
- * Append search results to the result tree.
467
- * @param result Search result.
468
- */
469
- protected appendToResultTree(result: SearchInWorkspaceResult): void {
470
- const collapseValue: string = this.searchInWorkspacePreferences['search.collapseResults'];
471
- let path: string;
472
- if (result.root === this.defaultRootName) {
473
- path = new URI(result.fileUri).path.dir.fsPath();
474
- } else {
475
- path = this.filenameAndPath(result.root, result.fileUri).path;
476
- }
477
- const tree = this.resultTree;
478
- let rootFolderNode = tree.get(result.root);
479
- if (!rootFolderNode) {
480
- rootFolderNode = this.createRootFolderNode(result.root);
481
- tree.set(result.root, rootFolderNode);
482
- }
483
- let fileNode = rootFolderNode.children.find(f => f.fileUri === result.fileUri);
484
- if (!fileNode) {
485
- fileNode = this.createFileNode(result.root, path, result.fileUri, rootFolderNode);
486
- rootFolderNode.children.push(fileNode);
487
- }
488
- for (const match of result.matches) {
489
- const line = this.createResultLineNode(result, match, fileNode);
490
- if (fileNode.children.findIndex(lineNode => lineNode.id === line.id) < 0) {
491
- fileNode.children.push(line);
492
- }
493
- }
494
- this.collapseFileNode(fileNode, collapseValue);
495
- }
496
-
497
- /**
498
- * Handle when searching completed.
499
- */
500
- protected handleSearchCompleted(cancelIndicator?: CancellationTokenSource): void {
501
- if (cancelIndicator) {
502
- cancelIndicator.cancel();
503
- }
504
- this.sortResultTree();
505
- this.refreshModelChildren();
506
- }
507
-
508
- /**
509
- * Sort the result tree by URIs.
510
- */
511
- protected sortResultTree(): void {
512
- // Sort the result map by folder URI.
513
- const entries = [...this.resultTree.entries()];
514
- entries.sort(([, a], [, b]) => this.compare(a.folderUri, b.folderUri));
515
- this.resultTree = new Map(entries);
516
- // Update the list of children nodes, sorting them by their file URI.
517
- entries.forEach(([, folder]) => {
518
- folder.children.sort((a, b) => this.compare(a.fileUri, b.fileUri));
519
- });
520
- }
521
-
522
- /**
523
- * Search and populate the result tree with matches.
524
- * @param searchTerm the search term.
525
- * @param searchOptions the search options to apply.
526
- */
527
- async search(searchTerm: string, searchOptions: SearchInWorkspaceOptions): Promise<void> {
528
- this.searchTerm = searchTerm;
529
- this.searchOptions = searchOptions;
530
- searchOptions = {
531
- ...searchOptions,
532
- exclude: this.getExcludeGlobs(searchOptions.exclude)
533
- };
534
- this.resultTree.clear();
535
- this.forceVisibleRootNode = false;
536
- if (this.cancelIndicator) {
537
- this.cancelIndicator.cancel();
538
- }
539
- if (searchTerm === '') {
540
- this.refreshModelChildren();
541
- return;
542
- }
543
- this.cancelIndicator = new CancellationTokenSource();
544
- const cancelIndicator = this.cancelIndicator;
545
- const token = this.cancelIndicator.token;
546
- const progress = await this.progressService.showProgress({ text: `search: ${searchTerm}`, options: { location: 'search' } });
547
- token.onCancellationRequested(() => {
548
- progress.cancel();
549
- if (searchId) {
550
- this.searchService.cancel(searchId);
551
- }
552
- this.cancelIndicator = undefined;
553
- this.changeEmitter.fire(this.resultTree);
554
- });
555
-
556
- // Collect search results for opened editors which otherwise may not be found by ripgrep (ex: dirty editors).
557
- const { numberOfResults, matches } = this.searchInOpenEditors(searchTerm, searchOptions);
558
-
559
- // The root node is visible if outside workspace results are found and workspace root(s) are present.
560
- this.forceVisibleRootNode = matches.some(m => m.root === this.defaultRootName) && this.workspaceService.opened;
561
-
562
- matches.forEach(m => this.appendToResultTree(m));
563
-
564
- // Exclude files already covered by searching open editors.
565
- this.editorManager.all.forEach(e => {
566
- const excludePath: string = e.editor.uri.path.toString();
567
- searchOptions.exclude = searchOptions.exclude ? searchOptions.exclude.concat(excludePath) : [excludePath];
568
- });
569
-
570
- // Reduce `maxResults` due to editor results.
571
- if (searchOptions.maxResults) {
572
- searchOptions.maxResults -= numberOfResults;
573
- }
574
-
575
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
576
- let pendingRefreshTimeout: any;
577
- const searchId = await this.searchService.search(searchTerm, {
578
- onResult: (aSearchId: number, result: SearchInWorkspaceResult) => {
579
- if (token.isCancellationRequested || aSearchId !== searchId) {
580
- return;
581
- }
582
- this.appendToResultTree(result);
583
- if (pendingRefreshTimeout) {
584
- clearTimeout(pendingRefreshTimeout);
585
- }
586
- pendingRefreshTimeout = setTimeout(() => this.refreshModelChildren(), 100);
587
- },
588
- onDone: () => {
589
- this.handleSearchCompleted(cancelIndicator);
590
- }
591
- }, searchOptions).catch(() => {
592
- this.handleSearchCompleted(cancelIndicator);
593
- });
594
- }
595
-
596
- focusFirstResult(): void {
597
- if (SearchInWorkspaceRoot.is(this.model.root) && this.model.root.children.length > 0) {
598
- const node = this.model.root.children[0];
599
- if (SelectableTreeNode.is(node)) {
600
- this.node.focus();
601
- this.model.selectNode(node);
602
- }
603
- }
604
- }
605
-
606
- /**
607
- * Collapse the search-in-workspace file node
608
- * based on the preference value.
609
- */
610
- protected collapseFileNode(node: SearchInWorkspaceFileNode, preferenceValue: string): void {
611
- if (preferenceValue === 'auto' && node.children.length >= 10) {
612
- node.expanded = false;
613
- } else if (preferenceValue === 'alwaysCollapse') {
614
- node.expanded = false;
615
- } else if (preferenceValue === 'alwaysExpand') {
616
- node.expanded = true;
617
- }
618
- }
619
-
620
- protected override handleUp(event: KeyboardEvent): void {
621
- if (!this.model.getPrevSelectableNode(this.model.getFocusedNode())) {
622
- this.focusInputEmitter.fire(true);
623
- } else {
624
- super.handleUp(event);
625
- }
626
- }
627
-
628
- protected async refreshModelChildren(): Promise<void> {
629
- if (SearchInWorkspaceRoot.is(this.model.root)) {
630
- this.model.root.children = Array.from(this.resultTree.values());
631
- this.model.refresh();
632
- this.updateCurrentEditorDecorations();
633
- }
634
- }
635
-
636
- protected updateCurrentEditorDecorations(): void {
637
- this.shell.allTabBars.forEach(tb => {
638
- const currentTitle = tb.currentTitle;
639
- if (currentTitle && currentTitle.owner instanceof EditorWidget) {
640
- const widget = currentTitle.owner;
641
- const fileNodes = this.getFileNodesByUri(widget.editor.uri);
642
- if (fileNodes.length > 0) {
643
- fileNodes.forEach(node => {
644
- this.decorateEditor(node, widget);
645
- });
646
- } else {
647
- this.decorateEditor(undefined, widget);
648
- }
649
- }
650
- });
651
-
652
- const currentWidget = this.editorManager.currentEditor;
653
- if (currentWidget) {
654
- const fileNodes = this.getFileNodesByUri(currentWidget.editor.uri);
655
- fileNodes.forEach(node => {
656
- this.decorateEditor(node, currentWidget);
657
- });
658
- }
659
- }
660
-
661
- protected createRootFolderNode(rootUri: string): SearchInWorkspaceRootFolderNode {
662
- const uri = new URI(rootUri);
663
- return {
664
- selected: false,
665
- path: uri.path.fsPath(),
666
- folderUri: rootUri,
667
- uri: new URI(rootUri),
668
- children: [],
669
- expanded: true,
670
- id: rootUri,
671
- parent: this.model.root as SearchInWorkspaceRoot,
672
- visible: this.forceVisibleRootNode || this.workspaceService.isMultiRootWorkspaceOpened
673
- };
674
- }
675
-
676
- protected createFileNode(rootUri: string, path: string, fileUri: string, parent: SearchInWorkspaceRootFolderNode): SearchInWorkspaceFileNode {
677
- return {
678
- selected: false,
679
- path,
680
- children: [],
681
- expanded: true,
682
- id: `${rootUri}::${fileUri}`,
683
- parent,
684
- fileUri,
685
- uri: new URI(fileUri),
686
- };
687
- }
688
-
689
- protected createResultLineNode(result: SearchInWorkspaceResult, match: SearchMatch, fileNode: SearchInWorkspaceFileNode): SearchInWorkspaceResultLineNode {
690
- return {
691
- ...result,
692
- ...match,
693
- selected: false,
694
- id: result.fileUri + '-' + match.line + '-' + match.character + '-' + match.length,
695
- name: typeof match.lineText === 'string' ? match.lineText : match.lineText.text,
696
- parent: fileNode
697
- };
698
- }
699
-
700
- protected getFileNodesByUri(uri: URI): SearchInWorkspaceFileNode[] {
701
- const nodes: SearchInWorkspaceFileNode[] = [];
702
- const fileUri = uri.withScheme('file').toString();
703
- for (const rootFolderNode of this.resultTree.values()) {
704
- const rootUri = new URI(rootFolderNode.path).withScheme('file');
705
- if (rootUri.isEqualOrParent(uri) || rootFolderNode.id === this.defaultRootName) {
706
- for (const fileNode of rootFolderNode.children) {
707
- if (fileNode.fileUri === fileUri) {
708
- nodes.push(fileNode);
709
- }
710
- }
711
- }
712
- }
713
- return nodes;
714
- }
715
-
716
- protected filenameAndPath(rootUriStr: string, uriStr: string): { name: string, path: string } {
717
- const uri: URI = new URI(uriStr);
718
- const relativePath = new URI(rootUriStr).relative(uri.parent);
719
- return {
720
- name: this.labelProvider.getName(uri),
721
- path: relativePath ? relativePath.fsPath() : ''
722
- };
723
- }
724
-
725
- protected override getDepthPadding(depth: number): number {
726
- return super.getDepthPadding(depth) + 5;
727
- }
728
-
729
- protected override renderCaption(node: TreeNode, props: NodeProps): React.ReactNode {
730
- if (SearchInWorkspaceRootFolderNode.is(node)) {
731
- return this.renderRootFolderNode(node);
732
- } else if (SearchInWorkspaceFileNode.is(node)) {
733
- return this.renderFileNode(node);
734
- } else if (SearchInWorkspaceResultLineNode.is(node)) {
735
- return this.renderResultLineNode(node);
736
- }
737
- return '';
738
- }
739
-
740
- protected override renderTailDecorations(node: TreeNode, props: NodeProps): React.ReactNode {
741
- return <div className='result-node-buttons'>
742
- {this._showReplaceButtons && this.renderReplaceButton(node)}
743
- {this.renderRemoveButton(node)}
744
- </div>;
745
- }
746
-
747
- protected doReplace(node: TreeNode, e: React.MouseEvent<HTMLElement>): void {
748
- const selection = SelectableTreeNode.isSelected(node) ? (this.selectionService.selection as SelectableTreeNode[]) : [node];
749
- selection.forEach(n => this.replace(n));
750
- e.stopPropagation();
751
- }
752
-
753
- protected renderReplaceButton(node: TreeNode): React.ReactNode {
754
- const isResultLineNode = SearchInWorkspaceResultLineNode.is(node);
755
- return <span className={isResultLineNode ? codicon('replace') : codicon('replace-all')}
756
- onClick={e => this.doReplace(node, e)}
757
- title={isResultLineNode
758
- ? nls.localizeByDefault('Replace')
759
- : nls.localizeByDefault('Replace All')
760
- }></span>;
761
- }
762
-
763
- protected getFileCount(node: TreeNode): number {
764
- if (SearchInWorkspaceRoot.is(node)) {
765
- return node.children.reduce((acc, current) => acc + this.getFileCount(current), 0);
766
- } else if (SearchInWorkspaceRootFolderNode.is(node)) {
767
- return node.children.length;
768
- } else if (SearchInWorkspaceFileNode.is(node)) {
769
- return 1;
770
- }
771
- return 0;
772
- }
773
-
774
- protected getResultCount(node: TreeNode): number {
775
- if (SearchInWorkspaceRoot.is(node)) {
776
- return node.children.reduce((acc, current) => acc + this.getResultCount(current), 0);
777
- } else if (SearchInWorkspaceRootFolderNode.is(node)) {
778
- return node.children.reduce((acc, current) => acc + this.getResultCount(current), 0);
779
- } else if (SearchInWorkspaceFileNode.is(node)) {
780
- return node.children.length;
781
- } else if (SearchInWorkspaceResultLineNode.is(node)) {
782
- return 1;
783
- }
784
- return 0;
785
- }
786
-
787
- /**
788
- * Replace results under the node passed into the function. If node is undefined, replace all results.
789
- * @param node Node in the tree widget where the "replace all" operation is performed
790
- */
791
- async replace(node: TreeNode | undefined): Promise<void> {
792
- const replaceForNode = node || this.model.root!;
793
- const needConfirm = !SearchInWorkspaceFileNode.is(node) && !SearchInWorkspaceResultLineNode.is(node);
794
- const replacementText = this._replaceTerm;
795
- if (!needConfirm || await this.confirmReplaceAll(this.getResultCount(replaceForNode), this.getFileCount(replaceForNode), replacementText)) {
796
- (node ? [node] : Array.from(this.resultTree.values())).forEach(n => {
797
- this.replaceResult(n, !!node, replacementText);
798
- this.removeNode(n);
799
- });
800
- }
801
- }
802
-
803
- protected confirmReplaceAll(resultNumber: number, fileNumber: number, replacementText: string): Promise<boolean | undefined> {
804
- return new ConfirmDialog({
805
- title: nls.localizeByDefault('Replace All'),
806
- msg: this.buildReplaceAllConfirmationMessage(resultNumber, fileNumber, replacementText)
807
- }).open();
808
- }
809
-
810
- protected buildReplaceAllConfirmationMessage(occurrences: number, fileCount: number, replaceValue: string): string {
811
- if (occurrences === 1) {
812
- if (fileCount === 1) {
813
- if (replaceValue) {
814
- return nls.localizeByDefault(
815
- "Replace {0} occurrence across {1} file with '{2}'?", occurrences, fileCount, replaceValue);
816
- }
817
-
818
- return nls.localizeByDefault(
819
- 'Replace {0} occurrence across {1} file?', occurrences, fileCount);
820
- }
821
-
822
- if (replaceValue) {
823
- return nls.localizeByDefault(
824
- "Replace {0} occurrence across {1} files with '{2}'?", occurrences, fileCount, replaceValue);
825
- }
826
-
827
- return nls.localizeByDefault(
828
- 'Replace {0} occurrence across {1} files?', occurrences, fileCount);
829
- }
830
-
831
- if (fileCount === 1) {
832
- if (replaceValue) {
833
- return nls.localizeByDefault(
834
- "Replace {0} occurrences across {1} file with '{2}'?", occurrences, fileCount, replaceValue);
835
- }
836
-
837
- return nls.localizeByDefault(
838
- 'Replace {0} occurrences across {1} file?', occurrences, fileCount);
839
- }
840
-
841
- if (replaceValue) {
842
- return nls.localizeByDefault(
843
- "Replace {0} occurrences across {1} files with '{2}'?", occurrences, fileCount, replaceValue);
844
- }
845
-
846
- return nls.localizeByDefault(
847
- 'Replace {0} occurrences across {1} files?', occurrences, fileCount);
848
- }
849
-
850
- protected updateRightResults(node: SearchInWorkspaceResultLineNode): void {
851
- const fileNode = node.parent;
852
- const rightPositionedNodes = fileNode.children.filter(rl => rl.line === node.line && rl.character > node.character);
853
- const diff = this._replaceTerm.length - this.searchTerm.length;
854
- rightPositionedNodes.forEach(r => r.character += diff);
855
- }
856
-
857
- /**
858
- * Replace text either in all search matches under a node or in all search matches, and save the changes.
859
- * @param node - node in the tree widget in which the "replace all" is performed.
860
- * @param {boolean} replaceOne - whether the function is to replace all matches under a node. If it is false, replace all.
861
- * @param replacementText - text to be used for all replacements in the current replacement cycle.
862
- */
863
- protected async replaceResult(node: TreeNode, replaceOne: boolean, replacementText: string): Promise<void> {
864
- const toReplace: SearchInWorkspaceResultLineNode[] = [];
865
- if (SearchInWorkspaceRootFolderNode.is(node)) {
866
- node.children.forEach(fileNode => this.replaceResult(fileNode, replaceOne, replacementText));
867
- } else if (SearchInWorkspaceFileNode.is(node)) {
868
- toReplace.push(...node.children);
869
- } else if (SearchInWorkspaceResultLineNode.is(node)) {
870
- toReplace.push(node);
871
- this.updateRightResults(node);
872
- }
873
-
874
- if (toReplace.length > 0) {
875
- // Store the state of all tracked editors before another editor widget might be created for text replacing.
876
- const trackedEditors: EditorWidget[] = this.editorManager.all;
877
- // Open the file only if the function is called to replace all matches under a specific node.
878
- const widget: EditorWidget = replaceOne ? await this.doOpen(toReplace[0]) : await this.doGetWidget(toReplace[0]);
879
- const source: string = widget.editor.document.getText();
880
-
881
- const replaceOperations = toReplace.map(resultLineNode => ({
882
- text: replacementText,
883
- range: {
884
- start: {
885
- line: resultLineNode.line - 1,
886
- character: resultLineNode.character - 1
887
- },
888
- end: this.findEndCharacterPosition(resultLineNode),
889
- }
890
- }));
891
-
892
- // Replace the text.
893
- await widget.editor.replaceText({
894
- source,
895
- replaceOperations
896
- });
897
- // Save the text replacement changes in the editor.
898
- await widget.saveable.save();
899
- // Dispose the widget if it is not opened but created for `replaceAll`.
900
- if (!replaceOne) {
901
- if (trackedEditors.indexOf(widget) === -1) {
902
- widget.dispose();
903
- }
904
- }
905
- }
906
- }
907
-
908
- protected readonly remove = (node: TreeNode, e: React.MouseEvent<HTMLElement>) => this.doRemove(node, e);
909
- protected doRemove(node: TreeNode, e: React.MouseEvent<HTMLElement>): void {
910
- const selection = SelectableTreeNode.isSelected(node) ? (this.selectionService.selection as SelectableTreeNode[]) : [node];
911
- selection.forEach(n => this.removeNode(n));
912
- e.stopPropagation();
913
- }
914
-
915
- protected renderRemoveButton(node: TreeNode): React.ReactNode {
916
- return <span className={codicon('close')} onClick={e => this.remove(node, e)} title='Dismiss'></span>;
917
- }
918
-
919
- removeNode(node: TreeNode): void {
920
- if (SearchInWorkspaceRootFolderNode.is(node)) {
921
- this.removeRootFolderNode(node);
922
- } else if (SearchInWorkspaceFileNode.is(node)) {
923
- this.removeFileNode(node);
924
- } else if (SearchInWorkspaceResultLineNode.is(node)) {
925
- this.removeResultLineNode(node);
926
- }
927
- this.refreshModelChildren();
928
- }
929
-
930
- private removeRootFolderNode(node: SearchInWorkspaceRootFolderNode): void {
931
- for (const rootUri of this.resultTree.keys()) {
932
- if (rootUri === node.folderUri) {
933
- this.resultTree.delete(rootUri);
934
- break;
935
- }
936
- }
937
- }
938
-
939
- private removeFileNode(node: SearchInWorkspaceFileNode): void {
940
- const rootFolderNode = node.parent;
941
- const index = rootFolderNode.children.findIndex(fileNode => fileNode.id === node.id);
942
- if (index > -1) {
943
- rootFolderNode.children.splice(index, 1);
944
- }
945
- if (this.getFileCount(rootFolderNode) === 0) {
946
- this.removeRootFolderNode(rootFolderNode);
947
- }
948
- }
949
-
950
- private removeResultLineNode(node: SearchInWorkspaceResultLineNode): void {
951
- const fileNode = node.parent;
952
- const index = fileNode.children.findIndex(n => n.fileUri === node.fileUri && n.line === node.line && n.character === node.character);
953
- if (index > -1) {
954
- fileNode.children.splice(index, 1);
955
- if (this.getResultCount(fileNode) === 0) {
956
- this.removeFileNode(fileNode);
957
- }
958
- }
959
- }
960
-
961
- private findEndCharacterPosition(node: SearchInWorkspaceResultLineNode): Position {
962
- const lineText = typeof node.lineText === 'string' ? node.lineText : node.lineText.text;
963
- const lines = lineText.split('\n');
964
- const line = node.line + lines.length - 2;
965
- let character = node.character - 1 + node.length;
966
- if (lines.length > 1) {
967
- character = node.length - lines[0].length + node.character - lines.length;
968
- if (lines.length > 2) {
969
- for (const lineNum of Array(lines.length - 2).keys()) {
970
- character -= lines[lineNum + 1].length;
971
- }
972
- }
973
- }
974
-
975
- return { line, character };
976
- }
977
-
978
- protected renderRootFolderNode(node: SearchInWorkspaceRootFolderNode): React.ReactNode {
979
- return <div className='result'>
980
- <div className='result-head'>
981
- <div className={`result-head-info noWrapInfo noselect ${node.selected ? 'selected' : ''}`}>
982
- <span className={`file-icon ${this.toNodeIcon(node) || ''}`}></span>
983
- <div className='noWrapInfo'>
984
- <span className={'file-name'}>
985
- {this.toNodeName(node)}
986
- </span>
987
- {node.path !== '/' + this.defaultRootName &&
988
- <span className={'file-path ' + TREE_NODE_INFO_CLASS}>
989
- {node.path}
990
- </span>
991
- }
992
- </div>
993
- </div>
994
- <span className='notification-count-container highlighted-count-container'>
995
- <span className='notification-count'>
996
- {this.getFileCount(node)}
997
- </span>
998
- </span>
999
- </div>
1000
- </div>;
1001
- }
1002
-
1003
- protected renderFileNode(node: SearchInWorkspaceFileNode): React.ReactNode {
1004
- return <div className='result'>
1005
- <div className='result-head'>
1006
- <div className={`result-head-info noWrapInfo noselect ${node.selected ? 'selected' : ''}`}
1007
- title={new URI(node.fileUri).path.fsPath()}>
1008
- <span className={`file-icon ${this.toNodeIcon(node)}`}></span>
1009
- <div className='noWrapInfo'>
1010
- <span className={'file-name'}>
1011
- {this.toNodeName(node)}
1012
- </span>
1013
- <span className={'file-path ' + TREE_NODE_INFO_CLASS}>
1014
- {node.path}
1015
- </span>
1016
- </div>
1017
- </div>
1018
- <span className='notification-count-container'>
1019
- <span className='notification-count'>
1020
- {this.getResultCount(node)}
1021
- </span>
1022
- </span>
1023
- </div>
1024
- </div>;
1025
- }
1026
-
1027
- protected renderResultLineNode(node: SearchInWorkspaceResultLineNode): React.ReactNode {
1028
- const character = typeof node.lineText === 'string' ? node.character : node.lineText.character;
1029
- const lineText = typeof node.lineText === 'string' ? node.lineText : node.lineText.text;
1030
- let start = Math.max(0, character - 26);
1031
- const wordBreak = /\b/g;
1032
- while (start > 0 && wordBreak.test(lineText) && wordBreak.lastIndex < character) {
1033
- if (character - wordBreak.lastIndex < 26) {
1034
- break;
1035
- }
1036
- start = wordBreak.lastIndex;
1037
- wordBreak.lastIndex++;
1038
- }
1039
-
1040
- const before = lineText.slice(start, character - 1).trimStart();
1041
- const lineCount = lineText.split('\n').length;
1042
-
1043
- return <>
1044
- <div className={`resultLine noWrapInfo noselect ${node.selected ? 'selected' : ''}`} title={lineText.trim()}>
1045
- {this.searchInWorkspacePreferences['search.lineNumbers'] && <span className='theia-siw-lineNumber'>{node.line}</span>}
1046
- <span>
1047
- {before}
1048
- </span>
1049
- {this.renderMatchLinePart(node)}
1050
- {lineCount > 1 || <span>
1051
- {lineText.slice(node.character + node.length - 1, 250 - before.length + node.length)}
1052
- </span>}
1053
- </div>
1054
- {lineCount > 1 && <div className='match-line-num'>+{lineCount - 1}</div>}
1055
- </>;
1056
- }
1057
-
1058
- protected renderMatchLinePart(node: SearchInWorkspaceResultLineNode): React.ReactNode {
1059
- const replaceTermLines = this._replaceTerm.split('\n');
1060
- const replaceTerm = this.isReplacing ? <span className='replace-term'>{replaceTermLines[0]}</span> : '';
1061
- const className = `match${this.isReplacing ? ' strike-through' : ''}`;
1062
- const text = typeof node.lineText === 'string' ? node.lineText : node.lineText.text;
1063
- const match = text.substring(node.character - 1, node.character + node.length - 1);
1064
- const matchLines = match.split('\n');
1065
- return <React.Fragment>
1066
- <span className={className}>{matchLines[0]}</span>
1067
- {replaceTerm}
1068
- </React.Fragment>;
1069
- }
1070
-
1071
- /**
1072
- * Get the editor widget by the node.
1073
- * @param {SearchInWorkspaceResultLineNode} node - the node representing a match in the search results.
1074
- * @returns The editor widget to which the text replace will be done.
1075
- */
1076
- protected async doGetWidget(node: SearchInWorkspaceResultLineNode): Promise<EditorWidget> {
1077
- const fileUri = new URI(node.fileUri);
1078
- const editorWidget = await this.editorManager.getOrCreateByUri(fileUri);
1079
- return editorWidget;
1080
- }
1081
-
1082
- protected async doOpen(node: SearchInWorkspaceResultLineNode, asDiffWidget = false, preview = false): Promise<EditorWidget> {
1083
- let fileUri: URI;
1084
- const resultNode = node.parent;
1085
- if (resultNode && this.isReplacing && asDiffWidget) {
1086
- const leftUri = new URI(node.fileUri);
1087
- const rightUri = await this.createReplacePreview(resultNode);
1088
- fileUri = DiffUris.encode(leftUri, rightUri);
1089
- } else {
1090
- fileUri = new URI(node.fileUri);
1091
- }
1092
-
1093
- const opts: EditorOpenerOptions = {
1094
- selection: {
1095
- start: {
1096
- line: node.line - 1,
1097
- character: node.character - 1
1098
- },
1099
- end: this.findEndCharacterPosition(node),
1100
- },
1101
- mode: preview ? 'reveal' : 'activate',
1102
- preview,
1103
- };
1104
-
1105
- const editorWidget = await this.editorManager.open(fileUri, opts);
1106
-
1107
- if (!DiffUris.isDiffUri(fileUri)) {
1108
- this.decorateEditor(resultNode, editorWidget);
1109
- }
1110
-
1111
- return editorWidget;
1112
- }
1113
-
1114
- protected async createReplacePreview(node: SearchInWorkspaceFileNode): Promise<URI> {
1115
- const fileUri = new URI(node.fileUri).withScheme('file');
1116
- const openedEditor = this.editorManager.all.find(({ editor }) => editor.uri.toString() === fileUri.toString());
1117
- let content: string;
1118
- if (openedEditor) {
1119
- content = openedEditor.editor.document.getText();
1120
- } else {
1121
- const resource = await this.fileResourceResolver.resolve(fileUri);
1122
- content = await resource.readContents();
1123
- }
1124
-
1125
- const searchTermRegExp = new RegExp(this.searchTerm, 'g');
1126
- return fileUri.withScheme(MEMORY_TEXT).withQuery(content.replace(searchTermRegExp, this._replaceTerm));
1127
- }
1128
-
1129
- protected decorateEditor(node: SearchInWorkspaceFileNode | undefined, editorWidget: EditorWidget): void {
1130
- if (!DiffUris.isDiffUri(editorWidget.editor.uri)) {
1131
- const key = `${editorWidget.editor.uri.toString()}#search-in-workspace-matches`;
1132
- const oldDecorations = this.appliedDecorations.get(key) || [];
1133
- const newDecorations = this.createEditorDecorations(node);
1134
- const appliedDecorations = editorWidget.editor.deltaDecorations({
1135
- newDecorations,
1136
- oldDecorations,
1137
- });
1138
- this.appliedDecorations.set(key, appliedDecorations);
1139
- }
1140
- }
1141
-
1142
- protected createEditorDecorations(resultNode: SearchInWorkspaceFileNode | undefined): EditorDecoration[] {
1143
- const decorations: EditorDecoration[] = [];
1144
- if (resultNode) {
1145
- resultNode.children.forEach(res => {
1146
- decorations.push({
1147
- range: {
1148
- start: {
1149
- line: res.line - 1,
1150
- character: res.character - 1
1151
- },
1152
- end: {
1153
- line: res.line - 1,
1154
- character: res.character - 1 + res.length
1155
- }
1156
- },
1157
- options: {
1158
- overviewRuler: {
1159
- color: {
1160
- id: 'editor.findMatchHighlightBackground'
1161
- },
1162
- position: OverviewRulerLane.Center
1163
- },
1164
- className: res.selected ? 'current-search-in-workspace-editor-match' : 'search-in-workspace-editor-match',
1165
- stickiness: TrackedRangeStickiness.GrowsOnlyWhenTypingBefore
1166
- }
1167
- });
1168
- });
1169
- }
1170
- return decorations;
1171
- }
1172
-
1173
- /**
1174
- * Get the list of exclude globs.
1175
- * @param excludeOptions the exclude search option.
1176
- *
1177
- * @returns the list of exclude globs.
1178
- */
1179
- protected getExcludeGlobs(excludeOptions?: string[]): string[] {
1180
- const excludePreferences = this.filesystemPreferences['files.exclude'];
1181
- const excludePreferencesGlobs = Object.keys(excludePreferences).filter(key => !!excludePreferences[key]);
1182
- return [...new Set([...excludePreferencesGlobs, ...excludeOptions || []])];
1183
- }
1184
-
1185
- /**
1186
- * Compare two normalized strings.
1187
- *
1188
- * @param a {string} the first string.
1189
- * @param b {string} the second string.
1190
- */
1191
- private compare(a: string, b: string): number {
1192
- const itemA: string = a.toLowerCase().trim();
1193
- const itemB: string = b.toLowerCase().trim();
1194
- return itemA.localeCompare(itemB);
1195
- }
1196
-
1197
- /**
1198
- * @param recursive if true, all child nodes will be included in the stringified result.
1199
- */
1200
- nodeToString(node: TreeNode, recursive: boolean): string {
1201
- if (SearchInWorkspaceFileNode.is(node) || SearchInWorkspaceRootFolderNode.is(node)) {
1202
- if (recursive) {
1203
- return this.nodeIteratorToString(new TopDownTreeIterator(node, { pruneSiblings: true }));
1204
- }
1205
- return this.labelProvider.getLongName(node.uri);
1206
- }
1207
- if (SearchInWorkspaceResultLineNode.is(node)) {
1208
- return ` ${node.line}:${node.character}: ${node.lineText}`;
1209
- }
1210
- return '';
1211
- }
1212
-
1213
- treeToString(): string {
1214
- return this.nodeIteratorToString(this.getVisibleNodes());
1215
- }
1216
-
1217
- protected *getVisibleNodes(): IterableIterator<TreeNode> {
1218
- for (const { node } of this.rows.values()) {
1219
- yield node;
1220
- }
1221
- }
1222
-
1223
- protected nodeIteratorToString(nodes: Iterable<TreeNode>): string {
1224
- const strings = [];
1225
- for (const node of nodes) {
1226
- const string = this.nodeToString(node, false);
1227
- if (string.length !== 0) {
1228
- strings.push(string);
1229
- }
1230
- }
1231
- return strings.join(EOL);
1232
- }
1233
- }
1234
-
1235
- export namespace SearchInWorkspaceResultTreeWidget {
1236
- export namespace Menus {
1237
- export const BASE = ['siw-tree-context-menu'];
1238
- /** Dismiss command, or others that only affect the widget itself */
1239
- export const INTERNAL = [...BASE, '1_internal'];
1240
- /** Copy a stringified representation of content */
1241
- export const COPY = [...BASE, '2_copy'];
1242
- /** Commands that lead out of the widget, like revealing a file in the navigator */
1243
- export const EXTERNAL = [...BASE, '3_external'];
1244
- }
1245
- }
1
+ // *****************************************************************************
2
+ // Copyright (C) 2018 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, postConstruct } from '@theia/core/shared/inversify';
18
+ import {
19
+ TreeWidget,
20
+ CompositeTreeNode,
21
+ ConfirmDialog,
22
+ ContextMenuRenderer,
23
+ ExpandableTreeNode,
24
+ SelectableTreeNode,
25
+ TreeModel,
26
+ TreeNode,
27
+ NodeProps,
28
+ TreeProps,
29
+ TreeExpansionService,
30
+ ApplicationShell,
31
+ DiffUris,
32
+ TREE_NODE_INFO_CLASS,
33
+ codicon,
34
+ TopDownTreeIterator
35
+ } from '@theia/core/lib/browser';
36
+ import { CancellationTokenSource, Emitter, EOL, Event, ProgressService } from '@theia/core';
37
+ import {
38
+ EditorManager, EditorDecoration, TrackedRangeStickiness, OverviewRulerLane,
39
+ EditorWidget, EditorOpenerOptions, FindMatch, Position
40
+ } from '@theia/editor/lib/browser';
41
+ import { WorkspaceService } from '@theia/workspace/lib/browser';
42
+ import { FileResourceResolver, FileSystemPreferences } from '@theia/filesystem/lib/browser';
43
+ import { FileService } from '@theia/filesystem/lib/browser/file-service';
44
+ import { SearchInWorkspaceResult, SearchInWorkspaceOptions, SearchMatch } from '../common/search-in-workspace-interface';
45
+ import { SearchInWorkspaceService } from './search-in-workspace-service';
46
+ import { MEMORY_TEXT } from '@theia/core/lib/common';
47
+ import URI from '@theia/core/lib/common/uri';
48
+ import * as React from '@theia/core/shared/react';
49
+ import { SearchInWorkspacePreferences } from './search-in-workspace-preferences';
50
+ import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
51
+ import * as minimatch from 'minimatch';
52
+ import { DisposableCollection } from '@theia/core/lib/common/disposable';
53
+ import debounce = require('@theia/core/shared/lodash.debounce');
54
+ import { nls } from '@theia/core/lib/common/nls';
55
+
56
+ const ROOT_ID = 'ResultTree';
57
+
58
+ export interface SearchInWorkspaceRoot extends CompositeTreeNode {
59
+ children: SearchInWorkspaceRootFolderNode[];
60
+ }
61
+ export namespace SearchInWorkspaceRoot {
62
+ export function is(node: unknown): node is SearchInWorkspaceRoot {
63
+ return CompositeTreeNode.is(node) && node.id === ROOT_ID;
64
+ }
65
+ }
66
+ export interface SearchInWorkspaceRootFolderNode extends ExpandableTreeNode, SelectableTreeNode { // root folder node
67
+ name?: undefined
68
+ icon?: undefined
69
+ children: SearchInWorkspaceFileNode[];
70
+ parent: SearchInWorkspaceRoot;
71
+ path: string;
72
+ folderUri: string;
73
+ uri: URI;
74
+ }
75
+ export namespace SearchInWorkspaceRootFolderNode {
76
+ export function is(node: unknown): node is SearchInWorkspaceRootFolderNode {
77
+ return ExpandableTreeNode.is(node) && SelectableTreeNode.is(node) && 'path' in node && 'folderUri' in node && !('fileUri' in node);
78
+ }
79
+ }
80
+
81
+ export interface SearchInWorkspaceFileNode extends ExpandableTreeNode, SelectableTreeNode { // file node
82
+ name?: undefined
83
+ icon?: undefined
84
+ children: SearchInWorkspaceResultLineNode[];
85
+ parent: SearchInWorkspaceRootFolderNode;
86
+ path: string;
87
+ fileUri: string;
88
+ uri: URI;
89
+ }
90
+ export namespace SearchInWorkspaceFileNode {
91
+ export function is(node: unknown): node is SearchInWorkspaceFileNode {
92
+ return ExpandableTreeNode.is(node) && SelectableTreeNode.is(node) && 'path' in node && 'fileUri' in node && !('folderUri' in node);
93
+ }
94
+ }
95
+
96
+ export interface SearchInWorkspaceResultLineNode extends SelectableTreeNode, SearchInWorkspaceResult, SearchMatch { // line node
97
+ parent: SearchInWorkspaceFileNode
98
+ }
99
+ export namespace SearchInWorkspaceResultLineNode {
100
+ export function is(node: unknown): node is SearchInWorkspaceResultLineNode {
101
+ return SelectableTreeNode.is(node) && 'line' in node && 'character' in node && 'lineText' in node;
102
+ }
103
+ }
104
+
105
+ @injectable()
106
+ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
107
+
108
+ protected resultTree: Map<string, SearchInWorkspaceRootFolderNode>;
109
+
110
+ protected _showReplaceButtons = false;
111
+ protected _replaceTerm = '';
112
+ protected searchTerm = '';
113
+ protected searchOptions: SearchInWorkspaceOptions;
114
+
115
+ protected readonly startSearchOnModification = (activeEditor: EditorWidget) => debounce(
116
+ () => this.searchActiveEditor(activeEditor, this.searchTerm, this.searchOptions),
117
+ this.searchOnEditorModificationDelay
118
+ );
119
+
120
+ protected readonly searchOnEditorModificationDelay = 300;
121
+ protected readonly toDisposeOnActiveEditorChanged = new DisposableCollection();
122
+
123
+ // The default root name to add external search results in the case that a workspace is opened.
124
+ protected readonly defaultRootName = nls.localizeByDefault('Other files');
125
+ protected forceVisibleRootNode = false;
126
+
127
+ protected appliedDecorations = new Map<string, string[]>();
128
+
129
+ cancelIndicator?: CancellationTokenSource;
130
+
131
+ protected changeEmitter = new Emitter<Map<string, SearchInWorkspaceRootFolderNode>>();
132
+
133
+ protected onExpansionChangedEmitter = new Emitter();
134
+ readonly onExpansionChanged: Event<void> = this.onExpansionChangedEmitter.event;
135
+
136
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
+ protected focusInputEmitter = new Emitter<any>();
138
+
139
+ @inject(SearchInWorkspaceService) protected readonly searchService: SearchInWorkspaceService;
140
+ @inject(EditorManager) protected readonly editorManager: EditorManager;
141
+ @inject(FileResourceResolver) protected readonly fileResourceResolver: FileResourceResolver;
142
+ @inject(ApplicationShell) protected readonly shell: ApplicationShell;
143
+ @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
144
+ @inject(TreeExpansionService) protected readonly expansionService: TreeExpansionService;
145
+ @inject(SearchInWorkspacePreferences) protected readonly searchInWorkspacePreferences: SearchInWorkspacePreferences;
146
+ @inject(ProgressService) protected readonly progressService: ProgressService;
147
+ @inject(ColorRegistry) protected readonly colorRegistry: ColorRegistry;
148
+ @inject(FileSystemPreferences) protected readonly filesystemPreferences: FileSystemPreferences;
149
+ @inject(FileService) protected readonly fileService: FileService;
150
+
151
+ constructor(
152
+ @inject(TreeProps) props: TreeProps,
153
+ @inject(TreeModel) model: TreeModel,
154
+ @inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer
155
+ ) {
156
+ super(props, model, contextMenuRenderer);
157
+
158
+ model.root = {
159
+ id: ROOT_ID,
160
+ parent: undefined,
161
+ visible: false,
162
+ children: []
163
+ } as SearchInWorkspaceRoot;
164
+
165
+ this.toDispose.push(model.onSelectionChanged(nodes => {
166
+ const node = nodes[0];
167
+ if (SearchInWorkspaceResultLineNode.is(node)) {
168
+ this.doOpen(node, true, true);
169
+ }
170
+ }));
171
+ this.toDispose.push(model.onOpenNode(node => {
172
+ if (SearchInWorkspaceResultLineNode.is(node)) {
173
+ this.doOpen(node, true, false);
174
+ }
175
+ }));
176
+
177
+ this.resultTree = new Map<string, SearchInWorkspaceRootFolderNode>();
178
+ this.toDispose.push(model.onNodeRefreshed(() => this.changeEmitter.fire(this.resultTree)));
179
+ }
180
+
181
+ @postConstruct()
182
+ protected override init(): void {
183
+ super.init();
184
+ this.addClass('resultContainer');
185
+
186
+ this.toDispose.push(this.changeEmitter);
187
+ this.toDispose.push(this.focusInputEmitter);
188
+
189
+ this.toDispose.push(this.editorManager.onActiveEditorChanged(activeEditor => {
190
+ this.updateCurrentEditorDecorations();
191
+ this.toDisposeOnActiveEditorChanged.dispose();
192
+ this.toDispose.push(this.toDisposeOnActiveEditorChanged);
193
+ if (activeEditor) {
194
+ this.toDisposeOnActiveEditorChanged.push(activeEditor.editor.onDocumentContentChanged(() => {
195
+ if (this.searchTerm !== '' && this.searchInWorkspacePreferences['search.searchOnEditorModification']) {
196
+ this.startSearchOnModification(activeEditor)();
197
+ }
198
+ }));
199
+ }
200
+ }));
201
+
202
+ this.toDispose.push(this.searchInWorkspacePreferences.onPreferenceChanged(() => {
203
+ this.update();
204
+ }));
205
+
206
+ this.toDispose.push(this.fileService.onDidFilesChange(event => {
207
+ if (event.gotDeleted()) {
208
+ event.getDeleted().forEach(deletedFile => {
209
+ const fileNodes = this.getFileNodesByUri(deletedFile.resource);
210
+ fileNodes.forEach(node => this.removeFileNode(node));
211
+ });
212
+ this.model.refresh();
213
+ }
214
+ }));
215
+
216
+ this.toDispose.push(this.model.onExpansionChanged(() => {
217
+ this.onExpansionChangedEmitter.fire(undefined);
218
+ }));
219
+ }
220
+
221
+ get fileNumber(): number {
222
+ let num = 0;
223
+ for (const rootFolderNode of this.resultTree.values()) {
224
+ num += rootFolderNode.children.length;
225
+ }
226
+ return num;
227
+ }
228
+
229
+ set showReplaceButtons(srb: boolean) {
230
+ this._showReplaceButtons = srb;
231
+ this.update();
232
+ }
233
+
234
+ set replaceTerm(rt: string) {
235
+ this._replaceTerm = rt;
236
+ this.update();
237
+ }
238
+
239
+ get isReplacing(): boolean {
240
+ return this._replaceTerm !== '' && this._showReplaceButtons;
241
+ }
242
+
243
+ get onChange(): Event<Map<string, SearchInWorkspaceRootFolderNode>> {
244
+ return this.changeEmitter.event;
245
+ }
246
+
247
+ get onFocusInput(): Event<void> {
248
+ return this.focusInputEmitter.event;
249
+ }
250
+
251
+ collapseAll(): void {
252
+ for (const rootFolderNode of this.resultTree.values()) {
253
+ for (const fileNode of rootFolderNode.children) {
254
+ this.expansionService.collapseNode(fileNode);
255
+ }
256
+ if (rootFolderNode.visible) {
257
+ this.expansionService.collapseNode(rootFolderNode);
258
+ }
259
+ }
260
+ }
261
+
262
+ expandAll(): void {
263
+ for (const rootFolderNode of this.resultTree.values()) {
264
+ for (const fileNode of rootFolderNode.children) {
265
+ this.expansionService.expandNode(fileNode);
266
+ }
267
+ if (rootFolderNode.visible) {
268
+ this.expansionService.expandNode(rootFolderNode);
269
+ }
270
+ }
271
+ }
272
+
273
+ areResultsCollapsed(): boolean {
274
+ for (const rootFolderNode of this.resultTree.values()) {
275
+ for (const fileNode of rootFolderNode.children) {
276
+ if (!ExpandableTreeNode.isCollapsed(fileNode)) {
277
+ return false;
278
+ }
279
+ }
280
+ }
281
+ return true;
282
+ }
283
+
284
+ selectNextResult(): void {
285
+ if (!this.model.getFocusedNode()) {
286
+ return this.selectFirstResult();
287
+ }
288
+ let foundNextResult = false;
289
+ while (!foundNextResult) {
290
+ const nextNode = this.model.getNextNode();
291
+ if (!nextNode) {
292
+ return this.selectFirstResult();
293
+ } else if (SearchInWorkspaceResultLineNode.is(nextNode)) {
294
+ foundNextResult = true;
295
+ this.selectExpandOpenResultNode(nextNode);
296
+ } else {
297
+ this.model.selectNext();
298
+ }
299
+ }
300
+ }
301
+
302
+ selectPreviousResult(): void {
303
+ if (!this.model.getFocusedNode()) {
304
+ return this.selectLastResult();
305
+ }
306
+ let foundSelectedNode = false;
307
+ while (!foundSelectedNode) {
308
+ const prevNode = this.model.getPrevNode();
309
+ if (!prevNode) {
310
+ return this.selectLastResult();
311
+ } else if (SearchInWorkspaceResultLineNode.is(prevNode)) {
312
+ foundSelectedNode = true;
313
+ this.selectExpandOpenResultNode(prevNode);
314
+ } else if (prevNode.id === 'ResultTree') {
315
+ return this.selectLastResult();
316
+ } else {
317
+ this.model.selectPrev();
318
+ }
319
+ }
320
+ }
321
+
322
+ protected selectExpandOpenResultNode(node: SearchInWorkspaceResultLineNode): void {
323
+ this.model.expandNode(node.parent.parent);
324
+ this.model.expandNode(node.parent);
325
+ this.model.selectNode(node);
326
+ this.model.openNode(node);
327
+ }
328
+
329
+ protected selectFirstResult(): void {
330
+ for (const rootFolder of this.resultTree.values()) {
331
+ for (const file of rootFolder.children) {
332
+ for (const result of file.children) {
333
+ if (SelectableTreeNode.is(result)) {
334
+ return this.selectExpandOpenResultNode(result);
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ protected selectLastResult(): void {
342
+ const rootFolders = Array.from(this.resultTree.values());
343
+ for (let i = rootFolders.length - 1; i >= 0; i--) {
344
+ const rootFolder = rootFolders[i];
345
+ for (let j = rootFolder.children.length - 1; j >= 0; j--) {
346
+ const file = rootFolder.children[j];
347
+ for (let k = file.children.length - 1; k >= 0; k--) {
348
+ const result = file.children[k];
349
+ if (SelectableTreeNode.is(result)) {
350
+ return this.selectExpandOpenResultNode(result);
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Find matches for the given editor.
359
+ * @param searchTerm the search term.
360
+ * @param widget the editor widget.
361
+ * @param searchOptions the search options to apply.
362
+ *
363
+ * @returns the list of matches.
364
+ */
365
+ protected findMatches(searchTerm: string, widget: EditorWidget, searchOptions: SearchInWorkspaceOptions): SearchMatch[] {
366
+ if (!widget.editor.document.findMatches) {
367
+ return [];
368
+ }
369
+ const results: FindMatch[] = widget.editor.document.findMatches({
370
+ searchString: searchTerm,
371
+ isRegex: !!searchOptions.useRegExp,
372
+ matchCase: !!searchOptions.matchCase,
373
+ matchWholeWord: !!searchOptions.matchWholeWord,
374
+ limitResultCount: searchOptions.maxResults
375
+ });
376
+
377
+ const matches: SearchMatch[] = [];
378
+ results.forEach(r => {
379
+ const numberOfLines = searchTerm.split('\n').length;
380
+ const lineTexts = [];
381
+ for (let i = 0; i < numberOfLines; i++) {
382
+ lineTexts.push(widget.editor.document.getLineContent(r.range.start.line + i));
383
+ }
384
+ matches.push({
385
+ line: r.range.start.line,
386
+ character: r.range.start.character,
387
+ length: searchTerm.length,
388
+ lineText: lineTexts.join('\n')
389
+ });
390
+ });
391
+
392
+ return matches;
393
+ }
394
+
395
+ /**
396
+ * Convert a pattern to match all directories.
397
+ * @param workspaceRootUri the uri of the current workspace root.
398
+ * @param pattern the pattern to be converted.
399
+ */
400
+ protected convertPatternToGlob(workspaceRootUri: URI | undefined, pattern: string): string {
401
+ if (pattern.startsWith('**/')) {
402
+ return pattern;
403
+ }
404
+ if (pattern.startsWith('./')) {
405
+ if (workspaceRootUri === undefined) {
406
+ return pattern;
407
+ }
408
+ return workspaceRootUri.toString() + pattern.replace('./', '/');
409
+ }
410
+ return pattern.startsWith('/')
411
+ ? '**' + pattern
412
+ : '**/' + pattern;
413
+ }
414
+
415
+ /**
416
+ * Determine if the URI matches any of the patterns.
417
+ * @param uri the editor URI.
418
+ * @param patterns the glob patterns to verify.
419
+ */
420
+ protected inPatternList(uri: URI, patterns: string[]): boolean {
421
+ const opts: minimatch.IOptions = { dot: true, matchBase: true };
422
+ return patterns.some(pattern => minimatch(
423
+ uri.toString(),
424
+ this.convertPatternToGlob(this.workspaceService.getWorkspaceRootUri(uri), pattern),
425
+ opts
426
+ ));
427
+ }
428
+
429
+ /**
430
+ * Determine if the given editor satisfies the filtering criteria.
431
+ * An editor should be searched only if:
432
+ * - it is not excluded through the `excludes` list.
433
+ * - it is not explicitly present in a non-empty `includes` list.
434
+ */
435
+ protected shouldApplySearch(editorWidget: EditorWidget, searchOptions: SearchInWorkspaceOptions): boolean {
436
+ const excludePatterns = this.getExcludeGlobs(searchOptions.exclude);
437
+ if (this.inPatternList(editorWidget.editor.uri, excludePatterns)) {
438
+ return false;
439
+ }
440
+
441
+ const includePatterns = searchOptions.include;
442
+ if (!!includePatterns?.length && !this.inPatternList(editorWidget.editor.uri, includePatterns)) {
443
+ return false;
444
+ }
445
+
446
+ return true;
447
+ }
448
+
449
+ /**
450
+ * Search the active editor only and update the tree with those results.
451
+ */
452
+ protected searchActiveEditor(activeEditor: EditorWidget, searchTerm: string, searchOptions: SearchInWorkspaceOptions): void {
453
+ const includesExternalResults = () => !!this.resultTree.get(this.defaultRootName);
454
+
455
+ // Check if outside workspace results are present before searching.
456
+ const hasExternalResultsBefore = includesExternalResults();
457
+
458
+ // Collect search results for the given editor.
459
+ const results = this.searchInEditor(activeEditor, searchTerm, searchOptions);
460
+
461
+ // Update the tree by removing the result node, and add new results if applicable.
462
+ this.getFileNodesByUri(activeEditor.editor.uri).forEach(fileNode => this.removeFileNode(fileNode));
463
+ if (results) {
464
+ this.appendToResultTree(results);
465
+ }
466
+
467
+ // Check if outside workspace results are present after searching.
468
+ const hasExternalResultsAfter = includesExternalResults();
469
+
470
+ // Redo a search to update the tree node visibility if:
471
+ // + `Other files` node was present, now it is not.
472
+ // + `Other files` node was not present, now it is.
473
+ if (hasExternalResultsBefore ? !hasExternalResultsAfter : hasExternalResultsAfter) {
474
+ this.search(this.searchTerm, this.searchOptions);
475
+ return;
476
+ }
477
+
478
+ this.handleSearchCompleted();
479
+ }
480
+
481
+ /**
482
+ * Perform a search in all open editors.
483
+ * @param searchTerm the search term.
484
+ * @param searchOptions the search options to apply.
485
+ *
486
+ * @returns the tuple of result count, and the list of search results.
487
+ */
488
+ protected searchInOpenEditors(searchTerm: string, searchOptions: SearchInWorkspaceOptions): {
489
+ numberOfResults: number,
490
+ matches: SearchInWorkspaceResult[]
491
+ } {
492
+ // Track the number of results found.
493
+ let numberOfResults = 0;
494
+
495
+ const searchResults: SearchInWorkspaceResult[] = [];
496
+
497
+ this.editorManager.all.forEach(e => {
498
+ const editorResults = this.searchInEditor(e, searchTerm, searchOptions);
499
+ if (editorResults) {
500
+ numberOfResults += editorResults.matches.length;
501
+ searchResults.push(editorResults);
502
+ }
503
+ });
504
+
505
+ return {
506
+ numberOfResults,
507
+ matches: searchResults
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Perform a search in the target editor.
513
+ * @param editorWidget the editor widget.
514
+ * @param searchTerm the search term.
515
+ * @param searchOptions the search options to apply.
516
+ *
517
+ * @returns the search results from the given editor, undefined if the editor is either filtered or has no matches found.
518
+ */
519
+ protected searchInEditor(editorWidget: EditorWidget, searchTerm: string, searchOptions: SearchInWorkspaceOptions): SearchInWorkspaceResult | undefined {
520
+ if (!this.shouldApplySearch(editorWidget, searchOptions)) {
521
+ return undefined;
522
+ }
523
+
524
+ const matches: SearchMatch[] = this.findMatches(searchTerm, editorWidget, searchOptions);
525
+ if (matches.length <= 0) {
526
+ return undefined;
527
+ }
528
+
529
+ const fileUri = editorWidget.editor.uri.toString();
530
+ const root: string | undefined = this.workspaceService.getWorkspaceRootUri(editorWidget.editor.uri)?.toString();
531
+ return {
532
+ root: root ?? this.defaultRootName,
533
+ fileUri,
534
+ matches
535
+ };
536
+ }
537
+
538
+ /**
539
+ * Append search results to the result tree.
540
+ * @param result Search result.
541
+ */
542
+ protected appendToResultTree(result: SearchInWorkspaceResult): void {
543
+ const collapseValue: string = this.searchInWorkspacePreferences['search.collapseResults'];
544
+ let path: string;
545
+ if (result.root === this.defaultRootName) {
546
+ path = new URI(result.fileUri).path.dir.fsPath();
547
+ } else {
548
+ path = this.filenameAndPath(result.root, result.fileUri).path;
549
+ }
550
+ const tree = this.resultTree;
551
+ let rootFolderNode = tree.get(result.root);
552
+ if (!rootFolderNode) {
553
+ rootFolderNode = this.createRootFolderNode(result.root);
554
+ tree.set(result.root, rootFolderNode);
555
+ }
556
+ let fileNode = rootFolderNode.children.find(f => f.fileUri === result.fileUri);
557
+ if (!fileNode) {
558
+ fileNode = this.createFileNode(result.root, path, result.fileUri, rootFolderNode);
559
+ rootFolderNode.children.push(fileNode);
560
+ }
561
+ for (const match of result.matches) {
562
+ const line = this.createResultLineNode(result, match, fileNode);
563
+ if (fileNode.children.findIndex(lineNode => lineNode.id === line.id) < 0) {
564
+ fileNode.children.push(line);
565
+ }
566
+ }
567
+ this.collapseFileNode(fileNode, collapseValue);
568
+ }
569
+
570
+ /**
571
+ * Handle when searching completed.
572
+ */
573
+ protected handleSearchCompleted(cancelIndicator?: CancellationTokenSource): void {
574
+ if (cancelIndicator) {
575
+ cancelIndicator.cancel();
576
+ }
577
+ this.sortResultTree();
578
+ this.refreshModelChildren();
579
+ }
580
+
581
+ /**
582
+ * Sort the result tree by URIs.
583
+ */
584
+ protected sortResultTree(): void {
585
+ // Sort the result map by folder URI.
586
+ const entries = [...this.resultTree.entries()];
587
+ entries.sort(([, a], [, b]) => this.compare(a.folderUri, b.folderUri));
588
+ this.resultTree = new Map(entries);
589
+ // Update the list of children nodes, sorting them by their file URI.
590
+ entries.forEach(([, folder]) => {
591
+ folder.children.sort((a, b) => this.compare(a.fileUri, b.fileUri));
592
+ });
593
+ }
594
+
595
+ /**
596
+ * Search and populate the result tree with matches.
597
+ * @param searchTerm the search term.
598
+ * @param searchOptions the search options to apply.
599
+ */
600
+ async search(searchTerm: string, searchOptions: SearchInWorkspaceOptions): Promise<void> {
601
+ this.searchTerm = searchTerm;
602
+ this.searchOptions = searchOptions;
603
+ searchOptions = {
604
+ ...searchOptions,
605
+ exclude: this.getExcludeGlobs(searchOptions.exclude)
606
+ };
607
+ this.resultTree.clear();
608
+ this.forceVisibleRootNode = false;
609
+ if (this.cancelIndicator) {
610
+ this.cancelIndicator.cancel();
611
+ }
612
+ if (searchTerm === '') {
613
+ this.refreshModelChildren();
614
+ return;
615
+ }
616
+ this.cancelIndicator = new CancellationTokenSource();
617
+ const cancelIndicator = this.cancelIndicator;
618
+ const token = this.cancelIndicator.token;
619
+ const progress = await this.progressService.showProgress({ text: `search: ${searchTerm}`, options: { location: 'search' } });
620
+ token.onCancellationRequested(() => {
621
+ progress.cancel();
622
+ if (searchId) {
623
+ this.searchService.cancel(searchId);
624
+ }
625
+ this.cancelIndicator = undefined;
626
+ this.changeEmitter.fire(this.resultTree);
627
+ });
628
+
629
+ // Collect search results for opened editors which otherwise may not be found by ripgrep (ex: dirty editors).
630
+ const { numberOfResults, matches } = this.searchInOpenEditors(searchTerm, searchOptions);
631
+
632
+ // The root node is visible if outside workspace results are found and workspace root(s) are present.
633
+ this.forceVisibleRootNode = matches.some(m => m.root === this.defaultRootName) && this.workspaceService.opened;
634
+
635
+ matches.forEach(m => this.appendToResultTree(m));
636
+
637
+ // Exclude files already covered by searching open editors.
638
+ this.editorManager.all.forEach(e => {
639
+ const excludePath: string = e.editor.uri.path.toString();
640
+ searchOptions.exclude = searchOptions.exclude ? searchOptions.exclude.concat(excludePath) : [excludePath];
641
+ });
642
+
643
+ // Reduce `maxResults` due to editor results.
644
+ if (searchOptions.maxResults) {
645
+ searchOptions.maxResults -= numberOfResults;
646
+ }
647
+
648
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
649
+ let pendingRefreshTimeout: any;
650
+ const searchId = await this.searchService.search(searchTerm, {
651
+ onResult: (aSearchId: number, result: SearchInWorkspaceResult) => {
652
+ if (token.isCancellationRequested || aSearchId !== searchId) {
653
+ return;
654
+ }
655
+ this.appendToResultTree(result);
656
+ if (pendingRefreshTimeout) {
657
+ clearTimeout(pendingRefreshTimeout);
658
+ }
659
+ pendingRefreshTimeout = setTimeout(() => this.refreshModelChildren(), 100);
660
+ },
661
+ onDone: () => {
662
+ this.handleSearchCompleted(cancelIndicator);
663
+ }
664
+ }, searchOptions).catch(() => {
665
+ this.handleSearchCompleted(cancelIndicator);
666
+ });
667
+ }
668
+
669
+ focusFirstResult(): void {
670
+ if (SearchInWorkspaceRoot.is(this.model.root) && this.model.root.children.length > 0) {
671
+ const node = this.model.root.children[0];
672
+ if (SelectableTreeNode.is(node)) {
673
+ this.node.focus();
674
+ this.model.selectNode(node);
675
+ }
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Collapse the search-in-workspace file node
681
+ * based on the preference value.
682
+ */
683
+ protected collapseFileNode(node: SearchInWorkspaceFileNode, preferenceValue: string): void {
684
+ if (preferenceValue === 'auto' && node.children.length >= 10) {
685
+ node.expanded = false;
686
+ } else if (preferenceValue === 'alwaysCollapse') {
687
+ node.expanded = false;
688
+ } else if (preferenceValue === 'alwaysExpand') {
689
+ node.expanded = true;
690
+ }
691
+ }
692
+
693
+ protected override handleUp(event: KeyboardEvent): void {
694
+ if (!this.model.getPrevSelectableNode(this.model.getFocusedNode())) {
695
+ this.focusInputEmitter.fire(true);
696
+ } else {
697
+ super.handleUp(event);
698
+ }
699
+ }
700
+
701
+ protected async refreshModelChildren(): Promise<void> {
702
+ if (SearchInWorkspaceRoot.is(this.model.root)) {
703
+ this.model.root.children = Array.from(this.resultTree.values());
704
+ this.model.refresh();
705
+ this.updateCurrentEditorDecorations();
706
+ }
707
+ }
708
+
709
+ protected updateCurrentEditorDecorations(): void {
710
+ this.shell.allTabBars.forEach(tb => {
711
+ const currentTitle = tb.currentTitle;
712
+ if (currentTitle && currentTitle.owner instanceof EditorWidget) {
713
+ const widget = currentTitle.owner;
714
+ const fileNodes = this.getFileNodesByUri(widget.editor.uri);
715
+ if (fileNodes.length > 0) {
716
+ fileNodes.forEach(node => {
717
+ this.decorateEditor(node, widget);
718
+ });
719
+ } else {
720
+ this.decorateEditor(undefined, widget);
721
+ }
722
+ }
723
+ });
724
+
725
+ const currentWidget = this.editorManager.currentEditor;
726
+ if (currentWidget) {
727
+ const fileNodes = this.getFileNodesByUri(currentWidget.editor.uri);
728
+ fileNodes.forEach(node => {
729
+ this.decorateEditor(node, currentWidget);
730
+ });
731
+ }
732
+ }
733
+
734
+ protected createRootFolderNode(rootUri: string): SearchInWorkspaceRootFolderNode {
735
+ const uri = new URI(rootUri);
736
+ return {
737
+ selected: false,
738
+ path: uri.path.fsPath(),
739
+ folderUri: rootUri,
740
+ uri: new URI(rootUri),
741
+ children: [],
742
+ expanded: true,
743
+ id: rootUri,
744
+ parent: this.model.root as SearchInWorkspaceRoot,
745
+ visible: this.forceVisibleRootNode || this.workspaceService.isMultiRootWorkspaceOpened
746
+ };
747
+ }
748
+
749
+ protected createFileNode(rootUri: string, path: string, fileUri: string, parent: SearchInWorkspaceRootFolderNode): SearchInWorkspaceFileNode {
750
+ return {
751
+ selected: false,
752
+ path,
753
+ children: [],
754
+ expanded: true,
755
+ id: `${rootUri}::${fileUri}`,
756
+ parent,
757
+ fileUri,
758
+ uri: new URI(fileUri),
759
+ };
760
+ }
761
+
762
+ protected createResultLineNode(result: SearchInWorkspaceResult, match: SearchMatch, fileNode: SearchInWorkspaceFileNode): SearchInWorkspaceResultLineNode {
763
+ return {
764
+ ...result,
765
+ ...match,
766
+ selected: false,
767
+ id: result.fileUri + '-' + match.line + '-' + match.character + '-' + match.length,
768
+ name: typeof match.lineText === 'string' ? match.lineText : match.lineText.text,
769
+ parent: fileNode
770
+ };
771
+ }
772
+
773
+ protected getFileNodesByUri(uri: URI): SearchInWorkspaceFileNode[] {
774
+ const nodes: SearchInWorkspaceFileNode[] = [];
775
+ const fileUri = uri.withScheme('file').toString();
776
+ for (const rootFolderNode of this.resultTree.values()) {
777
+ const rootUri = new URI(rootFolderNode.path).withScheme('file');
778
+ if (rootUri.isEqualOrParent(uri) || rootFolderNode.id === this.defaultRootName) {
779
+ for (const fileNode of rootFolderNode.children) {
780
+ if (fileNode.fileUri === fileUri) {
781
+ nodes.push(fileNode);
782
+ }
783
+ }
784
+ }
785
+ }
786
+ return nodes;
787
+ }
788
+
789
+ protected filenameAndPath(rootUriStr: string, uriStr: string): { name: string, path: string } {
790
+ const uri: URI = new URI(uriStr);
791
+ const relativePath = new URI(rootUriStr).relative(uri.parent);
792
+ return {
793
+ name: this.labelProvider.getName(uri),
794
+ path: relativePath ? relativePath.fsPath() : ''
795
+ };
796
+ }
797
+
798
+ protected override getDepthPadding(depth: number): number {
799
+ return super.getDepthPadding(depth) + 5;
800
+ }
801
+
802
+ protected override renderCaption(node: TreeNode, props: NodeProps): React.ReactNode {
803
+ if (SearchInWorkspaceRootFolderNode.is(node)) {
804
+ return this.renderRootFolderNode(node);
805
+ } else if (SearchInWorkspaceFileNode.is(node)) {
806
+ return this.renderFileNode(node);
807
+ } else if (SearchInWorkspaceResultLineNode.is(node)) {
808
+ return this.renderResultLineNode(node);
809
+ }
810
+ return '';
811
+ }
812
+
813
+ protected override renderTailDecorations(node: TreeNode, props: NodeProps): React.ReactNode {
814
+ return <div className='result-node-buttons'>
815
+ {this._showReplaceButtons && this.renderReplaceButton(node)}
816
+ {this.renderRemoveButton(node)}
817
+ </div>;
818
+ }
819
+
820
+ protected doReplace(node: TreeNode, e: React.MouseEvent<HTMLElement>): void {
821
+ const selection = SelectableTreeNode.isSelected(node) ? (this.selectionService.selection as SelectableTreeNode[]) : [node];
822
+ selection.forEach(n => this.replace(n));
823
+ e.stopPropagation();
824
+ }
825
+
826
+ protected renderReplaceButton(node: TreeNode): React.ReactNode {
827
+ const isResultLineNode = SearchInWorkspaceResultLineNode.is(node);
828
+ return <span className={isResultLineNode ? codicon('replace') : codicon('replace-all')}
829
+ onClick={e => this.doReplace(node, e)}
830
+ title={isResultLineNode
831
+ ? nls.localizeByDefault('Replace')
832
+ : nls.localizeByDefault('Replace All')
833
+ }></span>;
834
+ }
835
+
836
+ protected getFileCount(node: TreeNode): number {
837
+ if (SearchInWorkspaceRoot.is(node)) {
838
+ return node.children.reduce((acc, current) => acc + this.getFileCount(current), 0);
839
+ } else if (SearchInWorkspaceRootFolderNode.is(node)) {
840
+ return node.children.length;
841
+ } else if (SearchInWorkspaceFileNode.is(node)) {
842
+ return 1;
843
+ }
844
+ return 0;
845
+ }
846
+
847
+ protected getResultCount(node: TreeNode): number {
848
+ if (SearchInWorkspaceRoot.is(node)) {
849
+ return node.children.reduce((acc, current) => acc + this.getResultCount(current), 0);
850
+ } else if (SearchInWorkspaceRootFolderNode.is(node)) {
851
+ return node.children.reduce((acc, current) => acc + this.getResultCount(current), 0);
852
+ } else if (SearchInWorkspaceFileNode.is(node)) {
853
+ return node.children.length;
854
+ } else if (SearchInWorkspaceResultLineNode.is(node)) {
855
+ return 1;
856
+ }
857
+ return 0;
858
+ }
859
+
860
+ /**
861
+ * Replace results under the node passed into the function. If node is undefined, replace all results.
862
+ * @param node Node in the tree widget where the "replace all" operation is performed
863
+ */
864
+ async replace(node: TreeNode | undefined): Promise<void> {
865
+ const replaceForNode = node || this.model.root!;
866
+ const needConfirm = !SearchInWorkspaceFileNode.is(node) && !SearchInWorkspaceResultLineNode.is(node);
867
+ const replacementText = this._replaceTerm;
868
+ if (!needConfirm || await this.confirmReplaceAll(this.getResultCount(replaceForNode), this.getFileCount(replaceForNode), replacementText)) {
869
+ (node ? [node] : Array.from(this.resultTree.values())).forEach(n => {
870
+ this.replaceResult(n, !!node, replacementText);
871
+ this.removeNode(n);
872
+ });
873
+ }
874
+ }
875
+
876
+ protected confirmReplaceAll(resultNumber: number, fileNumber: number, replacementText: string): Promise<boolean | undefined> {
877
+ return new ConfirmDialog({
878
+ title: nls.localizeByDefault('Replace All'),
879
+ msg: this.buildReplaceAllConfirmationMessage(resultNumber, fileNumber, replacementText)
880
+ }).open();
881
+ }
882
+
883
+ protected buildReplaceAllConfirmationMessage(occurrences: number, fileCount: number, replaceValue: string): string {
884
+ if (occurrences === 1) {
885
+ if (fileCount === 1) {
886
+ if (replaceValue) {
887
+ return nls.localizeByDefault(
888
+ "Replace {0} occurrence across {1} file with '{2}'?", occurrences, fileCount, replaceValue);
889
+ }
890
+
891
+ return nls.localizeByDefault(
892
+ 'Replace {0} occurrence across {1} file?', occurrences, fileCount);
893
+ }
894
+
895
+ if (replaceValue) {
896
+ return nls.localizeByDefault(
897
+ "Replace {0} occurrence across {1} files with '{2}'?", occurrences, fileCount, replaceValue);
898
+ }
899
+
900
+ return nls.localizeByDefault(
901
+ 'Replace {0} occurrence across {1} files?', occurrences, fileCount);
902
+ }
903
+
904
+ if (fileCount === 1) {
905
+ if (replaceValue) {
906
+ return nls.localizeByDefault(
907
+ "Replace {0} occurrences across {1} file with '{2}'?", occurrences, fileCount, replaceValue);
908
+ }
909
+
910
+ return nls.localizeByDefault(
911
+ 'Replace {0} occurrences across {1} file?', occurrences, fileCount);
912
+ }
913
+
914
+ if (replaceValue) {
915
+ return nls.localizeByDefault(
916
+ "Replace {0} occurrences across {1} files with '{2}'?", occurrences, fileCount, replaceValue);
917
+ }
918
+
919
+ return nls.localizeByDefault(
920
+ 'Replace {0} occurrences across {1} files?', occurrences, fileCount);
921
+ }
922
+
923
+ protected updateRightResults(node: SearchInWorkspaceResultLineNode): void {
924
+ const fileNode = node.parent;
925
+ const rightPositionedNodes = fileNode.children.filter(rl => rl.line === node.line && rl.character > node.character);
926
+ const diff = this._replaceTerm.length - this.searchTerm.length;
927
+ rightPositionedNodes.forEach(r => r.character += diff);
928
+ }
929
+
930
+ /**
931
+ * Replace text either in all search matches under a node or in all search matches, and save the changes.
932
+ * @param node - node in the tree widget in which the "replace all" is performed.
933
+ * @param {boolean} replaceOne - whether the function is to replace all matches under a node. If it is false, replace all.
934
+ * @param replacementText - text to be used for all replacements in the current replacement cycle.
935
+ */
936
+ protected async replaceResult(node: TreeNode, replaceOne: boolean, replacementText: string): Promise<void> {
937
+ const toReplace: SearchInWorkspaceResultLineNode[] = [];
938
+ if (SearchInWorkspaceRootFolderNode.is(node)) {
939
+ node.children.forEach(fileNode => this.replaceResult(fileNode, replaceOne, replacementText));
940
+ } else if (SearchInWorkspaceFileNode.is(node)) {
941
+ toReplace.push(...node.children);
942
+ } else if (SearchInWorkspaceResultLineNode.is(node)) {
943
+ toReplace.push(node);
944
+ this.updateRightResults(node);
945
+ }
946
+
947
+ if (toReplace.length > 0) {
948
+ // Store the state of all tracked editors before another editor widget might be created for text replacing.
949
+ const trackedEditors: EditorWidget[] = this.editorManager.all;
950
+ // Open the file only if the function is called to replace all matches under a specific node.
951
+ const widget: EditorWidget = replaceOne ? await this.doOpen(toReplace[0]) : await this.doGetWidget(toReplace[0]);
952
+ const source: string = widget.editor.document.getText();
953
+
954
+ const replaceOperations = toReplace.map(resultLineNode => ({
955
+ text: replacementText,
956
+ range: {
957
+ start: {
958
+ line: resultLineNode.line - 1,
959
+ character: resultLineNode.character - 1
960
+ },
961
+ end: this.findEndCharacterPosition(resultLineNode),
962
+ }
963
+ }));
964
+
965
+ // Replace the text.
966
+ await widget.editor.replaceText({
967
+ source,
968
+ replaceOperations
969
+ });
970
+ // Save the text replacement changes in the editor.
971
+ await widget.saveable.save();
972
+ // Dispose the widget if it is not opened but created for `replaceAll`.
973
+ if (!replaceOne) {
974
+ if (trackedEditors.indexOf(widget) === -1) {
975
+ widget.dispose();
976
+ }
977
+ }
978
+ }
979
+ }
980
+
981
+ protected readonly remove = (node: TreeNode, e: React.MouseEvent<HTMLElement>) => this.doRemove(node, e);
982
+ protected doRemove(node: TreeNode, e: React.MouseEvent<HTMLElement>): void {
983
+ const selection = SelectableTreeNode.isSelected(node) ? (this.selectionService.selection as SelectableTreeNode[]) : [node];
984
+ selection.forEach(n => this.removeNode(n));
985
+ e.stopPropagation();
986
+ }
987
+
988
+ protected renderRemoveButton(node: TreeNode): React.ReactNode {
989
+ return <span className={codicon('close')} onClick={e => this.remove(node, e)} title='Dismiss'></span>;
990
+ }
991
+
992
+ removeNode(node: TreeNode): void {
993
+ if (SearchInWorkspaceRootFolderNode.is(node)) {
994
+ this.removeRootFolderNode(node);
995
+ } else if (SearchInWorkspaceFileNode.is(node)) {
996
+ this.removeFileNode(node);
997
+ } else if (SearchInWorkspaceResultLineNode.is(node)) {
998
+ this.removeResultLineNode(node);
999
+ }
1000
+ this.refreshModelChildren();
1001
+ }
1002
+
1003
+ private removeRootFolderNode(node: SearchInWorkspaceRootFolderNode): void {
1004
+ for (const rootUri of this.resultTree.keys()) {
1005
+ if (rootUri === node.folderUri) {
1006
+ this.resultTree.delete(rootUri);
1007
+ break;
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ private removeFileNode(node: SearchInWorkspaceFileNode): void {
1013
+ const rootFolderNode = node.parent;
1014
+ const index = rootFolderNode.children.findIndex(fileNode => fileNode.id === node.id);
1015
+ if (index > -1) {
1016
+ rootFolderNode.children.splice(index, 1);
1017
+ }
1018
+ if (this.getFileCount(rootFolderNode) === 0) {
1019
+ this.removeRootFolderNode(rootFolderNode);
1020
+ }
1021
+ }
1022
+
1023
+ private removeResultLineNode(node: SearchInWorkspaceResultLineNode): void {
1024
+ const fileNode = node.parent;
1025
+ const index = fileNode.children.findIndex(n => n.fileUri === node.fileUri && n.line === node.line && n.character === node.character);
1026
+ if (index > -1) {
1027
+ fileNode.children.splice(index, 1);
1028
+ if (this.getResultCount(fileNode) === 0) {
1029
+ this.removeFileNode(fileNode);
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ private findEndCharacterPosition(node: SearchInWorkspaceResultLineNode): Position {
1035
+ const lineText = typeof node.lineText === 'string' ? node.lineText : node.lineText.text;
1036
+ const lines = lineText.split('\n');
1037
+ const line = node.line + lines.length - 2;
1038
+ let character = node.character - 1 + node.length;
1039
+ if (lines.length > 1) {
1040
+ character = node.length - lines[0].length + node.character - lines.length;
1041
+ if (lines.length > 2) {
1042
+ for (const lineNum of Array(lines.length - 2).keys()) {
1043
+ character -= lines[lineNum + 1].length;
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ return { line, character };
1049
+ }
1050
+
1051
+ protected renderRootFolderNode(node: SearchInWorkspaceRootFolderNode): React.ReactNode {
1052
+ return <div className='result'>
1053
+ <div className='result-head'>
1054
+ <div className={`result-head-info noWrapInfo noselect ${node.selected ? 'selected' : ''}`}>
1055
+ <span className={`file-icon ${this.toNodeIcon(node) || ''}`}></span>
1056
+ <div className='noWrapInfo'>
1057
+ <span className={'file-name'}>
1058
+ {this.toNodeName(node)}
1059
+ </span>
1060
+ {node.path !== '/' + this.defaultRootName &&
1061
+ <span className={'file-path ' + TREE_NODE_INFO_CLASS}>
1062
+ {node.path}
1063
+ </span>
1064
+ }
1065
+ </div>
1066
+ </div>
1067
+ <span className='notification-count-container highlighted-count-container'>
1068
+ <span className='notification-count'>
1069
+ {this.getFileCount(node)}
1070
+ </span>
1071
+ </span>
1072
+ </div>
1073
+ </div>;
1074
+ }
1075
+
1076
+ protected renderFileNode(node: SearchInWorkspaceFileNode): React.ReactNode {
1077
+ return <div className='result'>
1078
+ <div className='result-head'>
1079
+ <div className={`result-head-info noWrapInfo noselect ${node.selected ? 'selected' : ''}`}
1080
+ title={new URI(node.fileUri).path.fsPath()}>
1081
+ <span className={`file-icon ${this.toNodeIcon(node)}`}></span>
1082
+ <div className='noWrapInfo'>
1083
+ <span className={'file-name'}>
1084
+ {this.toNodeName(node)}
1085
+ </span>
1086
+ <span className={'file-path ' + TREE_NODE_INFO_CLASS}>
1087
+ {node.path}
1088
+ </span>
1089
+ </div>
1090
+ </div>
1091
+ <span className='notification-count-container'>
1092
+ <span className='notification-count'>
1093
+ {this.getResultCount(node)}
1094
+ </span>
1095
+ </span>
1096
+ </div>
1097
+ </div>;
1098
+ }
1099
+
1100
+ protected renderResultLineNode(node: SearchInWorkspaceResultLineNode): React.ReactNode {
1101
+ const character = typeof node.lineText === 'string' ? node.character : node.lineText.character;
1102
+ const lineText = typeof node.lineText === 'string' ? node.lineText : node.lineText.text;
1103
+ let start = Math.max(0, character - 26);
1104
+ const wordBreak = /\b/g;
1105
+ while (start > 0 && wordBreak.test(lineText) && wordBreak.lastIndex < character) {
1106
+ if (character - wordBreak.lastIndex < 26) {
1107
+ break;
1108
+ }
1109
+ start = wordBreak.lastIndex;
1110
+ wordBreak.lastIndex++;
1111
+ }
1112
+
1113
+ const before = lineText.slice(start, character - 1).trimStart();
1114
+ const lineCount = lineText.split('\n').length;
1115
+
1116
+ return <>
1117
+ <div className={`resultLine noWrapInfo noselect ${node.selected ? 'selected' : ''}`} title={lineText.trim()}>
1118
+ {this.searchInWorkspacePreferences['search.lineNumbers'] && <span className='theia-siw-lineNumber'>{node.line}</span>}
1119
+ <span>
1120
+ {before}
1121
+ </span>
1122
+ {this.renderMatchLinePart(node)}
1123
+ {lineCount > 1 || <span>
1124
+ {lineText.slice(node.character + node.length - 1, 250 - before.length + node.length)}
1125
+ </span>}
1126
+ </div>
1127
+ {lineCount > 1 && <div className='match-line-num'>+{lineCount - 1}</div>}
1128
+ </>;
1129
+ }
1130
+
1131
+ protected renderMatchLinePart(node: SearchInWorkspaceResultLineNode): React.ReactNode {
1132
+ const replaceTermLines = this._replaceTerm.split('\n');
1133
+ const replaceTerm = this.isReplacing ? <span className='replace-term'>{replaceTermLines[0]}</span> : '';
1134
+ const className = `match${this.isReplacing ? ' strike-through' : ''}`;
1135
+ const text = typeof node.lineText === 'string' ? node.lineText : node.lineText.text;
1136
+ const match = text.substring(node.character - 1, node.character + node.length - 1);
1137
+ const matchLines = match.split('\n');
1138
+ return <React.Fragment>
1139
+ <span className={className}>{matchLines[0]}</span>
1140
+ {replaceTerm}
1141
+ </React.Fragment>;
1142
+ }
1143
+
1144
+ /**
1145
+ * Get the editor widget by the node.
1146
+ * @param {SearchInWorkspaceResultLineNode} node - the node representing a match in the search results.
1147
+ * @returns The editor widget to which the text replace will be done.
1148
+ */
1149
+ protected async doGetWidget(node: SearchInWorkspaceResultLineNode): Promise<EditorWidget> {
1150
+ const fileUri = new URI(node.fileUri);
1151
+ const editorWidget = await this.editorManager.getOrCreateByUri(fileUri);
1152
+ return editorWidget;
1153
+ }
1154
+
1155
+ protected async doOpen(node: SearchInWorkspaceResultLineNode, asDiffWidget = false, preview = false): Promise<EditorWidget> {
1156
+ let fileUri: URI;
1157
+ const resultNode = node.parent;
1158
+ if (resultNode && this.isReplacing && asDiffWidget) {
1159
+ const leftUri = new URI(node.fileUri);
1160
+ const rightUri = await this.createReplacePreview(resultNode);
1161
+ fileUri = DiffUris.encode(leftUri, rightUri);
1162
+ } else {
1163
+ fileUri = new URI(node.fileUri);
1164
+ }
1165
+
1166
+ const opts: EditorOpenerOptions = {
1167
+ selection: {
1168
+ start: {
1169
+ line: node.line - 1,
1170
+ character: node.character - 1
1171
+ },
1172
+ end: this.findEndCharacterPosition(node),
1173
+ },
1174
+ mode: preview ? 'reveal' : 'activate',
1175
+ preview,
1176
+ };
1177
+
1178
+ const editorWidget = await this.editorManager.open(fileUri, opts);
1179
+
1180
+ if (!DiffUris.isDiffUri(fileUri)) {
1181
+ this.decorateEditor(resultNode, editorWidget);
1182
+ }
1183
+
1184
+ return editorWidget;
1185
+ }
1186
+
1187
+ protected async createReplacePreview(node: SearchInWorkspaceFileNode): Promise<URI> {
1188
+ const fileUri = new URI(node.fileUri).withScheme('file');
1189
+ const openedEditor = this.editorManager.all.find(({ editor }) => editor.uri.toString() === fileUri.toString());
1190
+ let content: string;
1191
+ if (openedEditor) {
1192
+ content = openedEditor.editor.document.getText();
1193
+ } else {
1194
+ const resource = await this.fileResourceResolver.resolve(fileUri);
1195
+ content = await resource.readContents();
1196
+ }
1197
+
1198
+ const searchTermRegExp = new RegExp(this.searchTerm, 'g');
1199
+ return fileUri.withScheme(MEMORY_TEXT).withQuery(content.replace(searchTermRegExp, this._replaceTerm));
1200
+ }
1201
+
1202
+ protected decorateEditor(node: SearchInWorkspaceFileNode | undefined, editorWidget: EditorWidget): void {
1203
+ if (!DiffUris.isDiffUri(editorWidget.editor.uri)) {
1204
+ const key = `${editorWidget.editor.uri.toString()}#search-in-workspace-matches`;
1205
+ const oldDecorations = this.appliedDecorations.get(key) || [];
1206
+ const newDecorations = this.createEditorDecorations(node);
1207
+ const appliedDecorations = editorWidget.editor.deltaDecorations({
1208
+ newDecorations,
1209
+ oldDecorations,
1210
+ });
1211
+ this.appliedDecorations.set(key, appliedDecorations);
1212
+ }
1213
+ }
1214
+
1215
+ protected createEditorDecorations(resultNode: SearchInWorkspaceFileNode | undefined): EditorDecoration[] {
1216
+ const decorations: EditorDecoration[] = [];
1217
+ if (resultNode) {
1218
+ resultNode.children.forEach(res => {
1219
+ decorations.push({
1220
+ range: {
1221
+ start: {
1222
+ line: res.line - 1,
1223
+ character: res.character - 1
1224
+ },
1225
+ end: {
1226
+ line: res.line - 1,
1227
+ character: res.character - 1 + res.length
1228
+ }
1229
+ },
1230
+ options: {
1231
+ overviewRuler: {
1232
+ color: {
1233
+ id: 'editor.findMatchHighlightBackground'
1234
+ },
1235
+ position: OverviewRulerLane.Center
1236
+ },
1237
+ className: res.selected ? 'current-search-in-workspace-editor-match' : 'search-in-workspace-editor-match',
1238
+ stickiness: TrackedRangeStickiness.GrowsOnlyWhenTypingBefore
1239
+ }
1240
+ });
1241
+ });
1242
+ }
1243
+ return decorations;
1244
+ }
1245
+
1246
+ /**
1247
+ * Get the list of exclude globs.
1248
+ * @param excludeOptions the exclude search option.
1249
+ *
1250
+ * @returns the list of exclude globs.
1251
+ */
1252
+ protected getExcludeGlobs(excludeOptions?: string[]): string[] {
1253
+ const excludePreferences = this.filesystemPreferences['files.exclude'];
1254
+ const excludePreferencesGlobs = Object.keys(excludePreferences).filter(key => !!excludePreferences[key]);
1255
+ return [...new Set([...excludePreferencesGlobs, ...excludeOptions || []])];
1256
+ }
1257
+
1258
+ /**
1259
+ * Compare two normalized strings.
1260
+ *
1261
+ * @param a {string} the first string.
1262
+ * @param b {string} the second string.
1263
+ */
1264
+ private compare(a: string, b: string): number {
1265
+ const itemA: string = a.toLowerCase().trim();
1266
+ const itemB: string = b.toLowerCase().trim();
1267
+ return itemA.localeCompare(itemB);
1268
+ }
1269
+
1270
+ /**
1271
+ * @param recursive if true, all child nodes will be included in the stringified result.
1272
+ */
1273
+ nodeToString(node: TreeNode, recursive: boolean): string {
1274
+ if (SearchInWorkspaceFileNode.is(node) || SearchInWorkspaceRootFolderNode.is(node)) {
1275
+ if (recursive) {
1276
+ return this.nodeIteratorToString(new TopDownTreeIterator(node, { pruneSiblings: true }));
1277
+ }
1278
+ return this.labelProvider.getLongName(node.uri);
1279
+ }
1280
+ if (SearchInWorkspaceResultLineNode.is(node)) {
1281
+ return ` ${node.line}:${node.character}: ${node.lineText}`;
1282
+ }
1283
+ return '';
1284
+ }
1285
+
1286
+ treeToString(): string {
1287
+ return this.nodeIteratorToString(this.getVisibleNodes());
1288
+ }
1289
+
1290
+ protected *getVisibleNodes(): IterableIterator<TreeNode> {
1291
+ for (const { node } of this.rows.values()) {
1292
+ yield node;
1293
+ }
1294
+ }
1295
+
1296
+ protected nodeIteratorToString(nodes: Iterable<TreeNode>): string {
1297
+ const strings = [];
1298
+ for (const node of nodes) {
1299
+ const string = this.nodeToString(node, false);
1300
+ if (string.length !== 0) {
1301
+ strings.push(string);
1302
+ }
1303
+ }
1304
+ return strings.join(EOL);
1305
+ }
1306
+ }
1307
+
1308
+ export namespace SearchInWorkspaceResultTreeWidget {
1309
+ export namespace Menus {
1310
+ export const BASE = ['siw-tree-context-menu'];
1311
+ /** Dismiss command, or others that only affect the widget itself */
1312
+ export const INTERNAL = [...BASE, '1_internal'];
1313
+ /** Copy a stringified representation of content */
1314
+ export const COPY = [...BASE, '2_copy'];
1315
+ /** Commands that lead out of the widget, like revealing a file in the navigator */
1316
+ export const EXTERNAL = [...BASE, '3_external'];
1317
+ }
1318
+ }