@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.
Files changed (33) hide show
  1. package/lib/browser/search-in-workspace-frontend-module.d.ts.map +1 -1
  2. package/lib/browser/search-in-workspace-frontend-module.js +2 -1
  3. package/lib/browser/search-in-workspace-frontend-module.js.map +1 -1
  4. package/lib/browser/search-in-workspace-result-tree-widget.d.ts.map +1 -1
  5. package/lib/browser/search-in-workspace-result-tree-widget.js +15 -13
  6. package/lib/browser/search-in-workspace-result-tree-widget.js.map +1 -1
  7. package/lib/browser/search-in-workspace-service.d.ts +5 -5
  8. package/lib/browser/search-in-workspace-service.d.ts.map +1 -1
  9. package/lib/browser/search-in-workspace-service.js +4 -4
  10. package/lib/browser/search-in-workspace-service.js.map +1 -1
  11. package/lib/browser/search-in-workspace-widget.d.ts.map +1 -1
  12. package/lib/browser/search-in-workspace-widget.js +1 -3
  13. package/lib/browser/search-in-workspace-widget.js.map +1 -1
  14. package/lib/browser-only/browser-only-search-in-workspace-service.d.ts +5 -0
  15. package/lib/browser-only/browser-only-search-in-workspace-service.d.ts.map +1 -0
  16. package/lib/browser-only/browser-only-search-in-workspace-service.js +40 -0
  17. package/lib/browser-only/browser-only-search-in-workspace-service.js.map +1 -0
  18. package/lib/browser-only/browser-search-in-workspace-server.d.ts +80 -0
  19. package/lib/browser-only/browser-search-in-workspace-server.d.ts.map +1 -0
  20. package/lib/browser-only/browser-search-in-workspace-server.js +378 -0
  21. package/lib/browser-only/browser-search-in-workspace-server.js.map +1 -0
  22. package/lib/browser-only/search-in-workspace-frontend-only-module.d.ts +4 -0
  23. package/lib/browser-only/search-in-workspace-frontend-only-module.d.ts.map +1 -0
  24. package/lib/browser-only/search-in-workspace-frontend-only-module.js +37 -0
  25. package/lib/browser-only/search-in-workspace-frontend-only-module.js.map +1 -0
  26. package/package.json +13 -10
  27. package/src/browser/search-in-workspace-frontend-module.ts +4 -3
  28. package/src/browser/search-in-workspace-result-tree-widget.tsx +27 -15
  29. package/src/browser/search-in-workspace-service.ts +8 -7
  30. package/src/browser/search-in-workspace-widget.tsx +3 -2
  31. package/src/browser-only/browser-only-search-in-workspace-service.ts +30 -0
  32. package/src/browser-only/browser-search-in-workspace-server.ts +471 -0
  33. 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 * as minimatch from 'minimatch';
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: minimatch.IOptions = { dot: true, matchBase: true };
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
650
- let pendingRefreshTimeout: any;
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
- pendingRefreshTimeout = setTimeout(() => this.refreshModelChildren(), 100);
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
- {this.toNodeName(node)}
1071
+ {name}
1060
1072
  </span>
1061
- {node.path !== '/' + this.defaultRootName &&
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
- private pendingSearches = new Map<number, SearchInWorkspaceCallbacks>();
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
- private pendingOnDones: Map<number, string | undefined> = new Map();
67
+ protected pendingOnDones: Map<number, string | undefined> = new Map();
68
68
 
69
- private lastKnownSearchId: number = -1;
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, rootsUris: string[], callbacks: SearchInWorkspaceCallbacks, opts?: SearchInWorkspaceOptions): Promise<number> {
121
- const searchId = await this.searchServer.search(what, rootsUris, opts);
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, rootsUris: string[], callbacks: SearchInWorkspaceClient, opts?: SearchInWorkspaceOptions | undefined): Promise<number> {
144
- return this.doSearch(what, rootsUris, callbacks, opts);
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
- if (this.searchTerm === searchValue && Key.ENTER.keyCode !== KeyCode.createKeyCode(e.nativeEvent).key?.keyCode) {
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
+ }