@theia/search-in-workspace 1.65.0-next.55 → 1.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/browser/search-in-workspace-frontend-module.d.ts.map +1 -1
- package/lib/browser/search-in-workspace-frontend-module.js +2 -1
- package/lib/browser/search-in-workspace-frontend-module.js.map +1 -1
- package/lib/browser/search-in-workspace-result-tree-widget.d.ts.map +1 -1
- package/lib/browser/search-in-workspace-result-tree-widget.js +15 -13
- package/lib/browser/search-in-workspace-result-tree-widget.js.map +1 -1
- package/lib/browser/search-in-workspace-service.d.ts +5 -5
- package/lib/browser/search-in-workspace-service.d.ts.map +1 -1
- package/lib/browser/search-in-workspace-service.js +4 -4
- package/lib/browser/search-in-workspace-service.js.map +1 -1
- package/lib/browser/search-in-workspace-widget.d.ts.map +1 -1
- package/lib/browser/search-in-workspace-widget.js +1 -3
- package/lib/browser/search-in-workspace-widget.js.map +1 -1
- package/lib/browser-only/browser-only-search-in-workspace-service.d.ts +5 -0
- package/lib/browser-only/browser-only-search-in-workspace-service.d.ts.map +1 -0
- package/lib/browser-only/browser-only-search-in-workspace-service.js +40 -0
- package/lib/browser-only/browser-only-search-in-workspace-service.js.map +1 -0
- package/lib/browser-only/browser-search-in-workspace-server.d.ts +80 -0
- package/lib/browser-only/browser-search-in-workspace-server.d.ts.map +1 -0
- package/lib/browser-only/browser-search-in-workspace-server.js +378 -0
- package/lib/browser-only/browser-search-in-workspace-server.js.map +1 -0
- package/lib/browser-only/search-in-workspace-frontend-only-module.d.ts +4 -0
- package/lib/browser-only/search-in-workspace-frontend-only-module.d.ts.map +1 -0
- package/lib/browser-only/search-in-workspace-frontend-only-module.js +37 -0
- package/lib/browser-only/search-in-workspace-frontend-only-module.js.map +1 -0
- package/package.json +13 -10
- package/src/browser/search-in-workspace-frontend-module.ts +4 -3
- package/src/browser/search-in-workspace-result-tree-widget.tsx +27 -15
- package/src/browser/search-in-workspace-service.ts +8 -7
- package/src/browser/search-in-workspace-widget.tsx +3 -2
- package/src/browser-only/browser-only-search-in-workspace-service.ts +30 -0
- package/src/browser-only/browser-search-in-workspace-server.ts +471 -0
- package/src/browser-only/search-in-workspace-frontend-only-module.ts +35 -0
|
@@ -48,7 +48,7 @@ import URI from '@theia/core/lib/common/uri';
|
|
|
48
48
|
import * as React from '@theia/core/shared/react';
|
|
49
49
|
import { SearchInWorkspacePreferences } from '../common/search-in-workspace-preferences';
|
|
50
50
|
import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
|
|
51
|
-
import
|
|
51
|
+
import { minimatch, type MinimatchOptions } from 'minimatch';
|
|
52
52
|
import { DisposableCollection } from '@theia/core/lib/common/disposable';
|
|
53
53
|
import debounce = require('@theia/core/shared/lodash.debounce');
|
|
54
54
|
import { nls } from '@theia/core/lib/common/nls';
|
|
@@ -367,6 +367,7 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
|
|
|
367
367
|
if (!widget.editor.document.findMatches) {
|
|
368
368
|
return [];
|
|
369
369
|
}
|
|
370
|
+
|
|
370
371
|
const results: FindMatch[] = widget.editor.document.findMatches({
|
|
371
372
|
searchString: searchTerm,
|
|
372
373
|
isRegex: !!searchOptions.useRegExp,
|
|
@@ -419,7 +420,7 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
|
|
|
419
420
|
* @param patterns the glob patterns to verify.
|
|
420
421
|
*/
|
|
421
422
|
protected inPatternList(uri: URI, patterns: string[]): boolean {
|
|
422
|
-
const opts:
|
|
423
|
+
const opts: MinimatchOptions = { dot: true, matchBase: true };
|
|
423
424
|
return patterns.some(pattern => minimatch(
|
|
424
425
|
uri.toString(),
|
|
425
426
|
this.convertPatternToGlob(this.workspaceService.getWorkspaceRootUri(uri), pattern),
|
|
@@ -529,6 +530,7 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
|
|
|
529
530
|
|
|
530
531
|
const fileUri = editorWidget.editor.uri.toString();
|
|
531
532
|
const root: string | undefined = this.workspaceService.getWorkspaceRootUri(editorWidget.editor.uri)?.toString();
|
|
533
|
+
|
|
532
534
|
return {
|
|
533
535
|
root: root ?? this.defaultRootName,
|
|
534
536
|
fileUri,
|
|
@@ -607,9 +609,11 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
|
|
|
607
609
|
};
|
|
608
610
|
this.resultTree.clear();
|
|
609
611
|
this.forceVisibleRootNode = false;
|
|
612
|
+
|
|
610
613
|
if (this.cancelIndicator) {
|
|
611
614
|
this.cancelIndicator.cancel();
|
|
612
615
|
}
|
|
616
|
+
|
|
613
617
|
if (searchTerm === '') {
|
|
614
618
|
this.refreshModelChildren();
|
|
615
619
|
return;
|
|
@@ -618,14 +622,6 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
|
|
|
618
622
|
const cancelIndicator = this.cancelIndicator;
|
|
619
623
|
const token = this.cancelIndicator.token;
|
|
620
624
|
const progress = await this.progressService.showProgress({ text: `search: ${searchTerm}`, options: { location: 'search' } });
|
|
621
|
-
token.onCancellationRequested(() => {
|
|
622
|
-
progress.cancel();
|
|
623
|
-
if (searchId) {
|
|
624
|
-
this.searchService.cancel(searchId);
|
|
625
|
-
}
|
|
626
|
-
this.cancelIndicator = undefined;
|
|
627
|
-
this.changeEmitter.fire(this.resultTree);
|
|
628
|
-
});
|
|
629
625
|
|
|
630
626
|
// Collect search results for opened editors which otherwise may not be found by ripgrep (ex: dirty editors).
|
|
631
627
|
const { numberOfResults, matches } = this.searchInOpenEditors(searchTerm, searchOptions);
|
|
@@ -646,18 +642,22 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
|
|
|
646
642
|
searchOptions.maxResults -= numberOfResults;
|
|
647
643
|
}
|
|
648
644
|
|
|
649
|
-
|
|
650
|
-
|
|
645
|
+
let pendingRefreshTimeout: number | undefined;
|
|
646
|
+
|
|
651
647
|
const searchId = await this.searchService.search(searchTerm, {
|
|
652
648
|
onResult: (aSearchId: number, result: SearchInWorkspaceResult) => {
|
|
653
649
|
if (token.isCancellationRequested || aSearchId !== searchId) {
|
|
654
650
|
return;
|
|
655
651
|
}
|
|
652
|
+
|
|
656
653
|
this.appendToResultTree(result);
|
|
654
|
+
|
|
657
655
|
if (pendingRefreshTimeout) {
|
|
658
656
|
clearTimeout(pendingRefreshTimeout);
|
|
659
657
|
}
|
|
660
|
-
|
|
658
|
+
|
|
659
|
+
// convert type as we are in browser context
|
|
660
|
+
pendingRefreshTimeout = setTimeout(() => this.refreshModelChildren(), 100) as unknown as number;
|
|
661
661
|
},
|
|
662
662
|
onDone: () => {
|
|
663
663
|
this.handleSearchCompleted(cancelIndicator);
|
|
@@ -665,6 +665,15 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
|
|
|
665
665
|
}, searchOptions).catch(() => {
|
|
666
666
|
this.handleSearchCompleted(cancelIndicator);
|
|
667
667
|
});
|
|
668
|
+
|
|
669
|
+
token.onCancellationRequested(() => {
|
|
670
|
+
progress.cancel();
|
|
671
|
+
if (typeof searchId === 'number') {
|
|
672
|
+
this.searchService.cancel(searchId);
|
|
673
|
+
}
|
|
674
|
+
this.cancelIndicator = undefined;
|
|
675
|
+
this.changeEmitter.fire(this.resultTree);
|
|
676
|
+
});
|
|
668
677
|
}
|
|
669
678
|
|
|
670
679
|
focusFirstResult(): void {
|
|
@@ -1050,15 +1059,18 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget {
|
|
|
1050
1059
|
}
|
|
1051
1060
|
|
|
1052
1061
|
protected renderRootFolderNode(node: SearchInWorkspaceRootFolderNode): React.ReactNode {
|
|
1062
|
+
const isRoot = node.path === '/' || node.path === `/${this.defaultRootName}`;
|
|
1063
|
+
const name = this.toNodeName(node);
|
|
1064
|
+
|
|
1053
1065
|
return <div className='result'>
|
|
1054
1066
|
<div className='result-head'>
|
|
1055
1067
|
<div className={`result-head-info noWrapInfo noselect ${node.selected ? 'selected' : ''}`}>
|
|
1056
1068
|
<span className={`file-icon ${this.toNodeIcon(node) || ''}`}></span>
|
|
1057
1069
|
<div className='noWrapInfo'>
|
|
1058
1070
|
<span className={'file-name'}>
|
|
1059
|
-
{
|
|
1071
|
+
{name}
|
|
1060
1072
|
</span>
|
|
1061
|
-
{
|
|
1073
|
+
{!isRoot &&
|
|
1062
1074
|
<span className={'file-path ' + TREE_NODE_INFO_CLASS}>
|
|
1063
1075
|
{node.path}
|
|
1064
1076
|
</span>
|
|
@@ -56,7 +56,7 @@ export class SearchInWorkspaceService implements SearchInWorkspaceClient {
|
|
|
56
56
|
|
|
57
57
|
// All the searches that we have started, that are not done yet (onDone
|
|
58
58
|
// with that searchId has not been called).
|
|
59
|
-
|
|
59
|
+
protected pendingSearches = new Map<number, SearchInWorkspaceCallbacks>();
|
|
60
60
|
|
|
61
61
|
// Due to the asynchronicity of the node backend, it's possible that we
|
|
62
62
|
// start a search, receive an event for that search, and then receive
|
|
@@ -64,9 +64,9 @@ export class SearchInWorkspaceService implements SearchInWorkspaceClient {
|
|
|
64
64
|
// events until we get the search id and return it to the caller.
|
|
65
65
|
// Otherwise the caller would discard the event because it doesn't know
|
|
66
66
|
// the search id yet.
|
|
67
|
-
|
|
67
|
+
protected pendingOnDones: Map<number, string | undefined> = new Map();
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
protected lastKnownSearchId: number = -1;
|
|
70
70
|
|
|
71
71
|
@inject(SearchInWorkspaceServer) protected readonly searchServer: SearchInWorkspaceServer;
|
|
72
72
|
@inject(SearchInWorkspaceClientImpl) protected readonly client: SearchInWorkspaceClientImpl;
|
|
@@ -117,8 +117,9 @@ export class SearchInWorkspaceService implements SearchInWorkspaceClient {
|
|
|
117
117
|
return this.doSearch(what, roots.map(r => r.resource.toString()), callbacks, opts);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
protected async doSearch(what: string,
|
|
121
|
-
const searchId = await this.searchServer.search(what,
|
|
120
|
+
protected async doSearch(what: string, rootUris: string[], callbacks: SearchInWorkspaceCallbacks, opts?: SearchInWorkspaceOptions): Promise<number> {
|
|
121
|
+
const searchId = await this.searchServer.search(what, rootUris, opts);
|
|
122
|
+
|
|
122
123
|
this.pendingSearches.set(searchId, callbacks);
|
|
123
124
|
this.lastKnownSearchId = searchId;
|
|
124
125
|
|
|
@@ -140,8 +141,8 @@ export class SearchInWorkspaceService implements SearchInWorkspaceClient {
|
|
|
140
141
|
return searchId;
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
async searchWithCallback(what: string,
|
|
144
|
-
return this.doSearch(what,
|
|
144
|
+
async searchWithCallback(what: string, rootUris: string[], callbacks: SearchInWorkspaceClient, opts?: SearchInWorkspaceOptions | undefined): Promise<number> {
|
|
145
|
+
return this.doSearch(what, rootUris, callbacks, opts);
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
// Cancel an ongoing search.
|
|
@@ -427,6 +427,7 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge
|
|
|
427
427
|
const searchOnType = this.searchInWorkspacePreferences['search.searchOnType'];
|
|
428
428
|
if (searchOnType) {
|
|
429
429
|
const delay = this.searchInWorkspacePreferences['search.searchOnTypeDebouncePeriod'] || 0;
|
|
430
|
+
|
|
430
431
|
window.clearTimeout(this._searchTimeout);
|
|
431
432
|
this._searchTimeout = window.setTimeout(() => this.doSearch(e), delay);
|
|
432
433
|
}
|
|
@@ -434,7 +435,6 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge
|
|
|
434
435
|
|
|
435
436
|
protected readonly onKeyDownSearch = (e: React.KeyboardEvent) => {
|
|
436
437
|
if (Key.ENTER.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode) {
|
|
437
|
-
this.searchTerm = (e.target as HTMLInputElement).value;
|
|
438
438
|
this.performSearch();
|
|
439
439
|
}
|
|
440
440
|
};
|
|
@@ -442,7 +442,8 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge
|
|
|
442
442
|
protected doSearch(e: React.KeyboardEvent): void {
|
|
443
443
|
if (e.target) {
|
|
444
444
|
const searchValue = (e.target as HTMLInputElement).value;
|
|
445
|
-
|
|
445
|
+
|
|
446
|
+
if (this.searchTerm === searchValue) {
|
|
446
447
|
return;
|
|
447
448
|
} else {
|
|
448
449
|
this.searchTerm = searchValue;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2025 Maksim Kachurin 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 { injectable, postConstruct } from '@theia/core/shared/inversify';
|
|
18
|
+
import { SearchInWorkspaceService } from '../browser/search-in-workspace-service';
|
|
19
|
+
|
|
20
|
+
@injectable()
|
|
21
|
+
export class BrowserOnlySearchInWorkspaceService extends SearchInWorkspaceService {
|
|
22
|
+
@postConstruct()
|
|
23
|
+
protected override init(): void {
|
|
24
|
+
super.init();
|
|
25
|
+
|
|
26
|
+
if (this.searchServer && typeof this.searchServer.setClient === 'function') {
|
|
27
|
+
this.searchServer.setClient(this);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2025 Maksim Kachurin 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
|
+
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
|
17
|
+
import type {
|
|
18
|
+
SearchInWorkspaceClient,
|
|
19
|
+
SearchInWorkspaceOptions,
|
|
20
|
+
SearchInWorkspaceResult,
|
|
21
|
+
SearchInWorkspaceServer,
|
|
22
|
+
SearchMatch
|
|
23
|
+
} from '../common/search-in-workspace-interface';
|
|
24
|
+
import { FileUri } from '@theia/core/lib/common/file-uri';
|
|
25
|
+
import { URI, ILogger } from '@theia/core';
|
|
26
|
+
import { FileService, TextFileOperationError, TextFileOperationResult } from '@theia/filesystem/lib/browser/file-service';
|
|
27
|
+
import { normalizeGlob, matchesPattern, createIgnoreMatcher, getIgnorePatterns } from '@theia/filesystem/lib/browser-only/file-search';
|
|
28
|
+
import { escapeRegExpCharacters } from '@theia/core/lib/common/strings';
|
|
29
|
+
import { BinarySize, type FileStatWithMetadata } from '@theia/filesystem/lib/common/files';
|
|
30
|
+
|
|
31
|
+
interface SearchController {
|
|
32
|
+
regex: RegExp;
|
|
33
|
+
searchPaths: URI[];
|
|
34
|
+
options: SearchInWorkspaceOptions;
|
|
35
|
+
isAborted: () => boolean;
|
|
36
|
+
abort: () => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const minimatchOpts = {
|
|
40
|
+
dot: true,
|
|
41
|
+
matchBase: true,
|
|
42
|
+
nocase: true
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
@injectable()
|
|
46
|
+
export class BrowserSearchInWorkspaceServer implements SearchInWorkspaceServer {
|
|
47
|
+
@inject(ILogger) @named('search-in-workspace')
|
|
48
|
+
protected readonly logger: ILogger;
|
|
49
|
+
|
|
50
|
+
@inject(FileService)
|
|
51
|
+
protected readonly fs: FileService;
|
|
52
|
+
|
|
53
|
+
private client: SearchInWorkspaceClient | undefined;
|
|
54
|
+
private ongoingSearches: Map<number, SearchController> = new Map();
|
|
55
|
+
private nextSearchId: number = 1;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sets the client for receiving search results
|
|
59
|
+
*/
|
|
60
|
+
setClient(client: SearchInWorkspaceClient | undefined): void {
|
|
61
|
+
this.client = client;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Initiates a search operation and returns a search ID.
|
|
66
|
+
* @param what - The search term or pattern
|
|
67
|
+
* @param rootUris - Array of root URIs to search in
|
|
68
|
+
* @param opts - Search options including filters and limits
|
|
69
|
+
* @returns Promise resolving to the search ID
|
|
70
|
+
*/
|
|
71
|
+
async search(what: string, rootUris: string[], opts: SearchInWorkspaceOptions = {}): Promise<number> {
|
|
72
|
+
const searchId = this.nextSearchId++;
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
|
|
75
|
+
const { regex, searchPaths, options } = await this.processSearchOptions(
|
|
76
|
+
what,
|
|
77
|
+
rootUris,
|
|
78
|
+
opts,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
this.ongoingSearches.set(searchId, {
|
|
82
|
+
regex,
|
|
83
|
+
searchPaths,
|
|
84
|
+
options,
|
|
85
|
+
isAborted: () => controller.signal.aborted,
|
|
86
|
+
abort: () => controller.abort()
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Start search asynchronously and return searchId immediately
|
|
90
|
+
this.doSearch(searchId).catch((error: Error) => {
|
|
91
|
+
const errorStr = `An error happened while searching (${error.message}).`;
|
|
92
|
+
|
|
93
|
+
this.client?.onDone(searchId, errorStr);
|
|
94
|
+
}).finally(() => {
|
|
95
|
+
this.ongoingSearches.delete(searchId);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return searchId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Cancels an ongoing search operation.
|
|
103
|
+
* @param searchId - The ID of the search to cancel
|
|
104
|
+
*/
|
|
105
|
+
cancel(searchId: number): Promise<void> {
|
|
106
|
+
const controller = this.ongoingSearches.get(searchId);
|
|
107
|
+
|
|
108
|
+
if (controller) {
|
|
109
|
+
this.ongoingSearches.delete(searchId);
|
|
110
|
+
|
|
111
|
+
controller.abort();
|
|
112
|
+
this.client?.onDone(searchId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return Promise.resolve();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Disposes the service by aborting all ongoing searches.
|
|
120
|
+
*/
|
|
121
|
+
dispose(): void {
|
|
122
|
+
this.ongoingSearches.forEach(controller => controller.abort());
|
|
123
|
+
this.ongoingSearches.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Internal method to perform the search.
|
|
128
|
+
* @param searchId - The ID of the search to perform.
|
|
129
|
+
* @returns A promise that resolves when the search is complete.
|
|
130
|
+
*/
|
|
131
|
+
private async doSearch(searchId: number): Promise<void> {
|
|
132
|
+
const ctx = this.ongoingSearches.get(searchId);
|
|
133
|
+
|
|
134
|
+
if (!ctx) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { regex, searchPaths, options, isAborted } = ctx;
|
|
139
|
+
|
|
140
|
+
const maxFileSize = options.maxFileSize ? BinarySize.parseSize(options.maxFileSize) : 20 * BinarySize.MB;
|
|
141
|
+
const matcher = createIgnoreMatcher();
|
|
142
|
+
|
|
143
|
+
let remaining = options.maxResults ?? Number.POSITIVE_INFINITY;
|
|
144
|
+
|
|
145
|
+
for (const root of searchPaths) {
|
|
146
|
+
if (isAborted()) {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const pathsStack: URI[] = [root];
|
|
151
|
+
let index = 0;
|
|
152
|
+
|
|
153
|
+
while (index < pathsStack.length && !isAborted() && remaining > 0) {
|
|
154
|
+
const current = pathsStack[index++];
|
|
155
|
+
const relPath = current.path.toString().replace(/^\/|^\.\//, '');
|
|
156
|
+
|
|
157
|
+
// Skip excluded paths
|
|
158
|
+
if (this.shouldExcludePath(current, options.exclude)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Skip ignored files unless explicitly included
|
|
163
|
+
if (!options.includeIgnored && relPath && matcher.ignores(relPath)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let stat: FileStatWithMetadata;
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
stat = await this.fs.resolve(current, { resolveMetadata: true });
|
|
171
|
+
} catch {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Skip files not matching include patterns
|
|
176
|
+
if (stat.isFile && !this.shouldIncludePath(current, options.include)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Skip files exceeding size limit
|
|
181
|
+
if (stat.isFile && stat.size > maxFileSize) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Process directory contents
|
|
186
|
+
if (stat.isDirectory) {
|
|
187
|
+
if (Array.isArray(stat.children)) {
|
|
188
|
+
// Load ignore patterns from files
|
|
189
|
+
if (!options.includeIgnored) {
|
|
190
|
+
const patterns = await getIgnorePatterns(
|
|
191
|
+
current,
|
|
192
|
+
uri => this.fs.read(uri).then(content => content.value)
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
matcher.add(patterns);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const child of stat.children) {
|
|
199
|
+
pathsStack.push(child.resource);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const matches = await this.searchFileByLines(current, regex, isAborted, {
|
|
208
|
+
autoGuessEncoding: true,
|
|
209
|
+
acceptTextOnly: true
|
|
210
|
+
}, remaining);
|
|
211
|
+
|
|
212
|
+
if (matches.length > 0) {
|
|
213
|
+
const result: SearchInWorkspaceResult = {
|
|
214
|
+
root: root.path.toString(),
|
|
215
|
+
fileUri: current.path.toString(),
|
|
216
|
+
matches
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
this.client?.onResult(searchId, result);
|
|
220
|
+
|
|
221
|
+
remaining -= matches.length;
|
|
222
|
+
if (remaining <= 0) {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
if (err instanceof TextFileOperationError && err.textFileOperationResult === TextFileOperationResult.FILE_IS_BINARY) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.logger.error(`Error reading file ${current.path.toString()}: ${err.message}`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (remaining <= 0 || isAborted()) {
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
this.client?.onDone(searchId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Searches for matches within a file by processing it line by line.
|
|
246
|
+
* @param uri - The file URI to search
|
|
247
|
+
* @param re - The regex pattern to match
|
|
248
|
+
* @param isAborted - Function to check if search was aborted
|
|
249
|
+
* @param opts - File reading options
|
|
250
|
+
* @param limit - Maximum number of matches to return
|
|
251
|
+
* @returns Array of search matches found in the file
|
|
252
|
+
*/
|
|
253
|
+
private async searchFileByLines(
|
|
254
|
+
uri: URI,
|
|
255
|
+
re: RegExp,
|
|
256
|
+
isAborted: () => boolean,
|
|
257
|
+
opts: { autoGuessEncoding: boolean; acceptTextOnly: boolean },
|
|
258
|
+
limit: number
|
|
259
|
+
): Promise<SearchMatch[]> {
|
|
260
|
+
const { value: stream } = await this.fs.readStream(uri, opts);
|
|
261
|
+
|
|
262
|
+
let leftover = '';
|
|
263
|
+
let lineNo = 0;
|
|
264
|
+
const matches: SearchMatch[] = [];
|
|
265
|
+
|
|
266
|
+
await new Promise<void>((resolve, reject) => {
|
|
267
|
+
stream.on('data', chunk => {
|
|
268
|
+
if (isAborted()) {
|
|
269
|
+
stream.pause();
|
|
270
|
+
resolve();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const data = leftover + chunk;
|
|
275
|
+
const lines = data.split(/\r?\n/);
|
|
276
|
+
leftover = lines.pop() ?? '';
|
|
277
|
+
|
|
278
|
+
for (const line of lines) {
|
|
279
|
+
lineNo += 1; // 1-based
|
|
280
|
+
|
|
281
|
+
if (!line) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Reset regex lastIndex for global patterns
|
|
286
|
+
if (re.global) {
|
|
287
|
+
re.lastIndex = 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let m: RegExpExecArray | null;
|
|
291
|
+
|
|
292
|
+
while ((m = re.exec(line))) {
|
|
293
|
+
matches.push({
|
|
294
|
+
line: lineNo,
|
|
295
|
+
character: m.index + 1, // 1-based
|
|
296
|
+
length: m[0].length,
|
|
297
|
+
lineText: line
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
if (matches.length >= limit) {
|
|
301
|
+
resolve();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
stream.on('error', err => reject(err));
|
|
309
|
+
|
|
310
|
+
stream.on('end', () => {
|
|
311
|
+
if (leftover.length && matches.length < limit) {
|
|
312
|
+
lineNo += 1;
|
|
313
|
+
const line = leftover;
|
|
314
|
+
|
|
315
|
+
// Reset regex lastIndex for global patterns
|
|
316
|
+
if (re.global) {
|
|
317
|
+
re.lastIndex = 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let m: RegExpExecArray | null;
|
|
321
|
+
|
|
322
|
+
while ((m = re.exec(line))) {
|
|
323
|
+
matches.push({
|
|
324
|
+
line: lineNo,
|
|
325
|
+
character: m.index + 1,
|
|
326
|
+
length: m[0].length,
|
|
327
|
+
lineText: line
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (matches.length >= limit) {
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
resolve();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return matches;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Processes search options and returns clean paths and processed options.
|
|
345
|
+
* This method consolidates the path processing logic and matchWholeWord handling for better readability.
|
|
346
|
+
*/
|
|
347
|
+
private async processSearchOptions(_searchTerm: string, _searchPaths: string[], _options: SearchInWorkspaceOptions): Promise<{
|
|
348
|
+
regex: RegExp,
|
|
349
|
+
searchPaths: URI[],
|
|
350
|
+
options: SearchInWorkspaceOptions,
|
|
351
|
+
}> {
|
|
352
|
+
const options = { ..._options };
|
|
353
|
+
|
|
354
|
+
options.maxResults = typeof options.maxResults === 'number' && options.maxResults > 0 ? options.maxResults : Number.POSITIVE_INFINITY;
|
|
355
|
+
options.include = (options.include ?? []).map(glob => normalizeGlob(glob));
|
|
356
|
+
options.exclude = (options.exclude ?? []).map(glob => normalizeGlob(glob));
|
|
357
|
+
|
|
358
|
+
// If there are absolute paths in `include` we will remove them and use
|
|
359
|
+
// those as paths to search from
|
|
360
|
+
const paths = await this.extractSearchPathsFromIncludes(
|
|
361
|
+
_searchPaths.map(p => FileUri.fsPath(p)),
|
|
362
|
+
options.include
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
// Build regex with consideration of useRegExp/matchCase/matchWholeWord
|
|
366
|
+
const useRegExp = !!options.useRegExp;
|
|
367
|
+
const matchCase = !!options.matchCase;
|
|
368
|
+
const matchWholeWord = !!options.matchWholeWord;
|
|
369
|
+
|
|
370
|
+
const flags = 'g' + (matchCase ? '' : 'i') + 'u';
|
|
371
|
+
let source = useRegExp ? _searchTerm : escapeRegExpCharacters(_searchTerm);
|
|
372
|
+
|
|
373
|
+
// Unicode word boundaries: letters/numbers/underscore
|
|
374
|
+
if (matchWholeWord) {
|
|
375
|
+
const wbL = '(?<![\\p{L}\\p{N}_])';
|
|
376
|
+
const wbR = '(?![\\p{L}\\p{N}_])';
|
|
377
|
+
source = `${wbL}${source}${wbR}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const regex = new RegExp(source, flags);
|
|
381
|
+
|
|
382
|
+
const searchPaths = paths.map(p => URI.fromFilePath(p));
|
|
383
|
+
|
|
384
|
+
return { regex, searchPaths, options };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Checks if a path should be excluded based on exclude patterns.
|
|
389
|
+
* @param uri - The URI to check
|
|
390
|
+
* @param exclude - Array of exclude patterns
|
|
391
|
+
* @returns True if the path should be excluded
|
|
392
|
+
*/
|
|
393
|
+
protected shouldExcludePath(uri: URI, exclude: string[] | undefined): boolean {
|
|
394
|
+
if (!exclude?.length) {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return matchesPattern(uri.path.toString(), exclude, minimatchOpts);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Checks if a path should be included based on include patterns.
|
|
403
|
+
* @param uri - The URI to check
|
|
404
|
+
* @param include - Array of include patterns
|
|
405
|
+
* @returns True if the path should be included
|
|
406
|
+
*/
|
|
407
|
+
private shouldIncludePath(uri: URI, include: string[] | undefined): boolean {
|
|
408
|
+
if (!include?.length) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return matchesPattern(uri.path.toString(), include, minimatchOpts);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The default search paths are set to be the root paths associated to a workspace
|
|
417
|
+
* however the search scope can be further refined with the include paths available in the search options.
|
|
418
|
+
* This method will replace the searching paths to the ones specified in the 'include' options but as long
|
|
419
|
+
* as the 'include' paths can be successfully validated as existing.
|
|
420
|
+
*
|
|
421
|
+
* Therefore the returned array of paths can be either the workspace root paths or a set of validated paths
|
|
422
|
+
* derived from the include options which can be used to perform the search.
|
|
423
|
+
*
|
|
424
|
+
* Any pattern that resulted in a valid search path will be removed from the 'include' list as it is
|
|
425
|
+
* provided as an equivalent search path instead.
|
|
426
|
+
*/
|
|
427
|
+
protected async extractSearchPathsFromIncludes(searchPaths: string[], include: string[]): Promise<string[]> {
|
|
428
|
+
if (!include) {
|
|
429
|
+
return searchPaths;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const resolvedPaths = new Set<string>();
|
|
433
|
+
const searchPathsUris = searchPaths.map(p => new URI(p));
|
|
434
|
+
|
|
435
|
+
for (const pattern of include) {
|
|
436
|
+
const [base, _] = getGlobBase(pattern);
|
|
437
|
+
const baseUri = new URI(base);
|
|
438
|
+
|
|
439
|
+
for (const rootUri of searchPathsUris) {
|
|
440
|
+
if (rootUri.isEqualOrParent(baseUri) && await this.fs.exists(baseUri)) {
|
|
441
|
+
resolvedPaths.add(baseUri.path.toString());
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return resolvedPaths.size ? Array.from(resolvedPaths) : searchPaths;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Get the base + rest of a glob pattern.
|
|
452
|
+
*
|
|
453
|
+
* @param pattern - The glob pattern to get the base of (like 'workspace2/foo/*.md')
|
|
454
|
+
* @returns The base + rest of the glob pattern. (like ['workspace2/foo/', '*.md'])
|
|
455
|
+
*/
|
|
456
|
+
function getGlobBase(pattern: string): [string, string] {
|
|
457
|
+
const isAbsolute = pattern.startsWith('/');
|
|
458
|
+
const parts = pattern.replace(/^\//, '').split('/');
|
|
459
|
+
const magic = /[*?[\]{}]/;
|
|
460
|
+
|
|
461
|
+
const staticParts: string[] = [];
|
|
462
|
+
|
|
463
|
+
for (const part of parts) {
|
|
464
|
+
if (magic.test(part)) { break; }
|
|
465
|
+
staticParts.push(part);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const base = (isAbsolute ? '/' : '') + staticParts.join('/');
|
|
469
|
+
|
|
470
|
+
return [base, pattern.substring(base.length)];
|
|
471
|
+
}
|