@theia/filesystem 1.70.0 → 1.71.0-next.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.
@@ -0,0 +1,807 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 Safi Seid-Ahmad, K2view 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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ let disableJSDOM = enableJSDOM();
19
+
20
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
21
+ FrontendApplicationConfigProvider.set({});
22
+
23
+ import { Disposable, Emitter, URI } from '@theia/core';
24
+ import { expect } from 'chai';
25
+ import * as sinon from 'sinon';
26
+ import { FileChange, FileChangeType, FileSystemProvider, FileSystemProviderCapabilities, WatchOptions } from '../common/files';
27
+ import { FileService } from './file-service';
28
+
29
+ disableJSDOM();
30
+
31
+ interface MockWatcher {
32
+ resource: URI;
33
+ options: WatchOptions;
34
+ disposed: boolean;
35
+ disposable: Disposable;
36
+ }
37
+
38
+ function createMockProvider(caseSensitive: boolean = true): FileSystemProvider & { watchers: MockWatcher[] } {
39
+ const watchers: MockWatcher[] = [];
40
+ return {
41
+ watchers,
42
+ capabilities: caseSensitive ? FileSystemProviderCapabilities.PathCaseSensitive : 0,
43
+ onDidChangeCapabilities: () => Disposable.NULL,
44
+ onDidChangeFile: () => Disposable.NULL,
45
+ onFileWatchError: () => Disposable.NULL,
46
+ watch(resource: URI, options: WatchOptions): Disposable {
47
+ const watcher: MockWatcher = {
48
+ resource,
49
+ options,
50
+ disposed: false,
51
+ disposable: Disposable.create(() => { watcher.disposed = true; }),
52
+ };
53
+ watchers.push(watcher);
54
+ return watcher.disposable;
55
+ },
56
+ stat: () => { throw new Error('not implemented'); },
57
+ readdir: () => { throw new Error('not implemented'); },
58
+ readFile: () => { throw new Error('not implemented'); },
59
+ writeFile: () => { throw new Error('not implemented'); },
60
+ delete: () => { throw new Error('not implemented'); },
61
+ mkdir: () => { throw new Error('not implemented'); },
62
+ rename: () => { throw new Error('not implemented'); },
63
+ } as FileSystemProvider & { watchers: MockWatcher[] };
64
+ }
65
+
66
+ /** Return the number of watchers that are still alive (not disposed). */
67
+ function liveWatcherCount(provider: { watchers: MockWatcher[] }): number {
68
+ return provider.watchers.filter(w => !w.disposed).length;
69
+ }
70
+
71
+ describe('FileService watcher deduplication', () => {
72
+ const sandbox = sinon.createSandbox();
73
+ let fileService: FileService;
74
+ let mockProvider: FileSystemProvider & { watchers: MockWatcher[] };
75
+
76
+ before(() => {
77
+ disableJSDOM = enableJSDOM();
78
+ });
79
+
80
+ after(() => {
81
+ disableJSDOM();
82
+ });
83
+
84
+ beforeEach(() => {
85
+ sandbox.restore();
86
+ fileService = new FileService();
87
+ mockProvider = createMockProvider();
88
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
89
+ sandbox.stub(fileService as any, 'withProvider').resolves(mockProvider);
90
+ });
91
+
92
+ afterEach(() => {
93
+ sandbox.restore();
94
+ });
95
+
96
+ // ── (A) Exact-match dedup ──────────────────────────────────────────
97
+
98
+ describe('exact-match dedup', () => {
99
+ it('should reuse an existing watcher for the same resource and options', async () => {
100
+ const uri = new URI('file:///project/src');
101
+ const opts: WatchOptions = { recursive: true, excludes: [] };
102
+
103
+ const d1 = await fileService.doWatch(uri, opts);
104
+ const d2 = await fileService.doWatch(uri, opts);
105
+
106
+ // Only one real OS watcher should have been created
107
+ expect(mockProvider.watchers).to.have.lengthOf(1);
108
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
109
+
110
+ // Disposing one handle should NOT dispose the real watcher
111
+ d2.dispose();
112
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
113
+
114
+ // Disposing the last handle should dispose it
115
+ d1.dispose();
116
+ expect(liveWatcherCount(mockProvider)).to.equal(0);
117
+ });
118
+
119
+ it('should not let a restrictive parent subsume a permissive child at the same URI', async () => {
120
+ const uri = new URI('file:///project/src');
121
+ const opts1: WatchOptions = { recursive: true, excludes: ['**/node_modules'] };
122
+ const opts2: WatchOptions = { recursive: true, excludes: [] };
123
+
124
+ const d1 = await fileService.doWatch(uri, opts1);
125
+ const d2 = await fileService.doWatch(uri, opts2);
126
+
127
+ // The first watcher (with excludes) cannot subsume the second (no excludes),
128
+ // but the second (watching everything) CAN subsume the first via subsumeExistingChildren.
129
+ // Two real watchers were created, but only the permissive one (d2) remains live.
130
+ expect(mockProvider.watchers).to.have.lengthOf(2);
131
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
132
+
133
+ // Disposing d1 (already subsumed, no real watcher) should have no OS-level effect
134
+ d1.dispose();
135
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
136
+
137
+ d2.dispose();
138
+ expect(liveWatcherCount(mockProvider)).to.equal(0);
139
+ });
140
+
141
+ it('should subsume a watcher with matching excludes on the same URI', async () => {
142
+ const uri = new URI('file:///project/src');
143
+ const opts1: WatchOptions = { recursive: true, excludes: ['**/node_modules'] };
144
+ const opts2: WatchOptions = { recursive: true, excludes: ['**/node_modules'] };
145
+
146
+ const d1 = await fileService.doWatch(uri, opts1);
147
+ const d2 = await fileService.doWatch(uri, opts2);
148
+
149
+ // Same excludes — exact-match dedup applies
150
+ expect(mockProvider.watchers).to.have.lengthOf(1);
151
+
152
+ d2.dispose();
153
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
154
+
155
+ d1.dispose();
156
+ expect(liveWatcherCount(mockProvider)).to.equal(0);
157
+ });
158
+
159
+ it('should subsume a watcher with more excludes (superset) on the same URI', async () => {
160
+ const uri = new URI('file:///project/src');
161
+ const opts1: WatchOptions = { recursive: true, excludes: [] };
162
+ const opts2: WatchOptions = { recursive: true, excludes: ['**/node_modules'] };
163
+
164
+ const d1 = await fileService.doWatch(uri, opts1);
165
+ const d2 = await fileService.doWatch(uri, opts2);
166
+
167
+ // Parent has no excludes, so it watches everything the child needs — subsumption is safe
168
+ expect(mockProvider.watchers).to.have.lengthOf(1);
169
+
170
+ d2.dispose();
171
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
172
+
173
+ d1.dispose();
174
+ expect(liveWatcherCount(mockProvider)).to.equal(0);
175
+ });
176
+
177
+ it('should subsume a non-recursive watcher on the same URI as a recursive watcher', async () => {
178
+ const uri = new URI('file:///project/src');
179
+ const opts1: WatchOptions = { recursive: true, excludes: [] };
180
+ const opts2: WatchOptions = { recursive: false, excludes: [] };
181
+
182
+ const d1 = await fileService.doWatch(uri, opts1);
183
+ const d2 = await fileService.doWatch(uri, opts2);
184
+
185
+ // The recursive watcher subsumes the non-recursive one on the same URI
186
+ expect(mockProvider.watchers).to.have.lengthOf(1);
187
+
188
+ d2.dispose();
189
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
190
+
191
+ d1.dispose();
192
+ expect(liveWatcherCount(mockProvider)).to.equal(0);
193
+ });
194
+
195
+ it('should support three refs and only dispose when all are gone', async () => {
196
+ const uri = new URI('file:///project/src');
197
+ const opts: WatchOptions = { recursive: false, excludes: [] };
198
+
199
+ const d1 = await fileService.doWatch(uri, opts);
200
+ const d2 = await fileService.doWatch(uri, opts);
201
+ const d3 = await fileService.doWatch(uri, opts);
202
+
203
+ expect(mockProvider.watchers).to.have.lengthOf(1);
204
+
205
+ d1.dispose();
206
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
207
+
208
+ d2.dispose();
209
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
210
+
211
+ d3.dispose();
212
+ expect(liveWatcherCount(mockProvider)).to.equal(0);
213
+ });
214
+ });
215
+
216
+ // ── (B) Parent subsumption of new child ────────────────────────────
217
+
218
+ describe('parent subsumption', () => {
219
+ it('should not create an OS watcher when a recursive parent already covers the child', async () => {
220
+ const parentUri = new URI('file:///project');
221
+ const childUri = new URI('file:///project/src');
222
+
223
+ await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
224
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
225
+
226
+ // Only the parent's OS watcher should exist
227
+ expect(mockProvider.watchers).to.have.lengthOf(1);
228
+ expect(mockProvider.watchers[0].resource.toString()).to.equal(parentUri.toString());
229
+ });
230
+
231
+ it('should not subsume a child that is excluded by the parent', async () => {
232
+ const parentUri = new URI('file:///project');
233
+ const childUri = new URI('file:///project/node_modules/foo');
234
+
235
+ await fileService.doWatch(parentUri, { recursive: true, excludes: ['**/node_modules'] });
236
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
237
+
238
+ // The child should get its own real watcher
239
+ expect(mockProvider.watchers).to.have.lengthOf(2);
240
+ });
241
+
242
+ it('should not subsume when the parent is non-recursive', async () => {
243
+ const parentUri = new URI('file:///project');
244
+ const childUri = new URI('file:///project/src');
245
+
246
+ await fileService.doWatch(parentUri, { recursive: false, excludes: [] });
247
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
248
+
249
+ expect(mockProvider.watchers).to.have.lengthOf(2);
250
+ });
251
+
252
+ it('should subsume a deeply nested child', async () => {
253
+ const parentUri = new URI('file:///project');
254
+ const childUri = new URI('file:///project/src/main/java/com/example');
255
+
256
+ await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
257
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
258
+
259
+ expect(mockProvider.watchers).to.have.lengthOf(1);
260
+ });
261
+
262
+ it('should not subsume a sibling directory', async () => {
263
+ const parentUri = new URI('file:///project/src');
264
+ const siblingUri = new URI('file:///project/test');
265
+
266
+ await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
267
+ await fileService.doWatch(siblingUri, { recursive: false, excludes: [] });
268
+
269
+ expect(mockProvider.watchers).to.have.lengthOf(2);
270
+ });
271
+
272
+ it('should not subsume a child when it is under an excluded ancestor directory', async () => {
273
+ const parentUri = new URI('file:///project');
274
+ // node_modules is excluded, so anything under it should not be subsumed
275
+ const childUri = new URI('file:///project/node_modules/pkg/lib');
276
+
277
+ await fileService.doWatch(parentUri, { recursive: true, excludes: ['**/node_modules'] });
278
+ await fileService.doWatch(childUri, { recursive: true, excludes: [] });
279
+
280
+ // Child needs its own watcher since it's under an excluded directory
281
+ expect(mockProvider.watchers).to.have.lengthOf(2);
282
+ });
283
+ });
284
+
285
+ // ── (D) New recursive parent subsumes existing children ────────────
286
+
287
+ describe('child subsumption by new parent', () => {
288
+ it('should subsume existing non-recursive children when a new recursive parent is created', async () => {
289
+ const childUri1 = new URI('file:///project/src');
290
+ const childUri2 = new URI('file:///project/test');
291
+ const parentUri = new URI('file:///project');
292
+
293
+ await fileService.doWatch(childUri1, { recursive: false, excludes: [] });
294
+ await fileService.doWatch(childUri2, { recursive: false, excludes: [] });
295
+
296
+ expect(mockProvider.watchers).to.have.lengthOf(2);
297
+ expect(liveWatcherCount(mockProvider)).to.equal(2);
298
+
299
+ // Adding a recursive parent should subsume both children
300
+ await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
301
+
302
+ // Three watchers created total, but only the parent should remain live
303
+ expect(mockProvider.watchers).to.have.lengthOf(3);
304
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
305
+ expect(mockProvider.watchers[0].disposed).to.be.true; // child1 real watcher disposed
306
+ expect(mockProvider.watchers[1].disposed).to.be.true; // child2 real watcher disposed
307
+ expect(mockProvider.watchers[2].disposed).to.be.false; // parent alive
308
+ });
309
+
310
+ it('should subsume existing recursive children and re-parent grandchildren', async () => {
311
+ const grandchildUri = new URI('file:///project/src/deep');
312
+ const childUri = new URI('file:///project/src');
313
+ const parentUri = new URI('file:///project');
314
+
315
+ // Create a recursive child first, then a grandchild under it
316
+ await fileService.doWatch(childUri, { recursive: true, excludes: [] });
317
+ await fileService.doWatch(grandchildUri, { recursive: false, excludes: [] });
318
+
319
+ // Grandchild should already be subsumed by the child
320
+ expect(mockProvider.watchers).to.have.lengthOf(1); // only child has a real watcher
321
+
322
+ // Now create a parent that covers everything
323
+ await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
324
+
325
+ // Child's real watcher gets disposed, parent takes over
326
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
327
+ expect(mockProvider.watchers[0].disposed).to.be.true; // child disposed
328
+ expect(mockProvider.watchers[1].disposed).to.be.false; // parent alive
329
+ });
330
+
331
+ it('should promote and re-index a recursive grandchild excluded by the new parent', async () => {
332
+ const childUri = new URI('file:///project/src');
333
+ const grandchildUri = new URI('file:///project/src/vendor');
334
+ const parentUri = new URI('file:///project');
335
+
336
+ // Child and grandchild share the same excludes — grandchild is compatible with child.
337
+ // The pattern '**/src/vendor' does NOT match the relative path from child to grandchild
338
+ // ('vendor'), so isExcludedByParent(child, grandchild) is false — grandchild is subsumed.
339
+ await fileService.doWatch(childUri, { recursive: true, excludes: ['**/src/vendor'] });
340
+ await fileService.doWatch(grandchildUri, { recursive: true, excludes: ['**/src/vendor'] });
341
+
342
+ expect(mockProvider.watchers).to.have.lengthOf(1); // only child has a real watcher
343
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
344
+
345
+ // Create a parent with the same excludes. The parent subsumes the child
346
+ // (areExcludesCompatible passes). But the grandchild at /project/src/vendor has
347
+ // relative path 'src/vendor' from /project, which DOES match '**/src/vendor'.
348
+ // So isExcludedByParent(parent, grandchild) returns true — grandchild is promoted
349
+ // with its own real watcher and re-indexed.
350
+ await fileService.doWatch(parentUri, { recursive: true, excludes: ['**/src/vendor'] });
351
+
352
+ // Parent is live (subsumes child). Grandchild is promoted with its own real watcher.
353
+ expect(liveWatcherCount(mockProvider)).to.equal(2);
354
+
355
+ // A new watcher under the promoted grandchild should be subsumed by it,
356
+ // which only works if the grandchild was re-indexed after promotion.
357
+ const deepUri = new URI('file:///project/src/vendor/utils');
358
+ await fileService.doWatch(deepUri, { recursive: false, excludes: [] });
359
+
360
+ // No new OS watcher — the promoted grandchild covers it
361
+ expect(liveWatcherCount(mockProvider)).to.equal(2);
362
+ });
363
+
364
+ it('should not delete parent index when subsuming a recursive child at the same URI', async () => {
365
+ const uri = new URI('file:///project');
366
+ const childUri = new URI('file:///project/src');
367
+
368
+ // Create a recursive watcher with excludes
369
+ await fileService.doWatch(uri, { recursive: true, excludes: ['**/node_modules'] });
370
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
371
+
372
+ // Create a new recursive watcher at the SAME URI with no excludes.
373
+ // Different key (different excludes), so it's a new entry. The new watcher
374
+ // subsumes the old one via subsumeExistingChildren. The old watcher is recursive,
375
+ // so removeFromRecursiveIndex is called — but it must NOT delete the new parent's
376
+ // index entry (they share the same URI).
377
+ await fileService.doWatch(uri, { recursive: true, excludes: [] });
378
+
379
+ // Old watcher's OS watcher is disposed; new parent is live
380
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
381
+
382
+ // A child watcher should be subsumed by the new parent. Without the fix,
383
+ // the parent's index entry was deleted, causing unnecessary OS watchers.
384
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
385
+
386
+ // No new OS watcher — parent's index entry is intact
387
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
388
+ });
389
+
390
+ it('should not subsume excluded children when creating a new parent', async () => {
391
+ const childUri = new URI('file:///project/node_modules/pkg');
392
+ const parentUri = new URI('file:///project');
393
+
394
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
395
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
396
+
397
+ // Parent excludes node_modules
398
+ await fileService.doWatch(parentUri, { recursive: true, excludes: ['**/node_modules'] });
399
+
400
+ // Child should NOT be subsumed — both watchers should remain live
401
+ expect(liveWatcherCount(mockProvider)).to.equal(2);
402
+ });
403
+ });
404
+
405
+ // ── Disposal and promotion ─────────────────────────────────────────
406
+
407
+ describe('disposal and promotion', () => {
408
+ it('should promote subsumed children by creating real watchers when the parent is disposed', async () => {
409
+ const parentUri = new URI('file:///project');
410
+ const childUri = new URI('file:///project/src');
411
+
412
+ const parentDisposable = await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
413
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
414
+
415
+ // Only parent watcher is real
416
+ expect(mockProvider.watchers).to.have.lengthOf(1);
417
+
418
+ // Dispose the parent
419
+ parentDisposable.dispose();
420
+
421
+ // Child should now have its own real watcher
422
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
423
+ // The new watcher (index 1) should be for the child
424
+ expect(mockProvider.watchers).to.have.lengthOf(2);
425
+ expect(mockProvider.watchers[0].disposed).to.be.true; // parent
426
+ expect(mockProvider.watchers[1].disposed).to.be.false; // child promoted
427
+ expect(mockProvider.watchers[1].resource.toString()).to.equal(childUri.toString());
428
+ });
429
+
430
+ it('should re-parent children to another existing parent on disposal', async () => {
431
+ const grandparentUri = new URI('file:///project');
432
+ const parentUri = new URI('file:///project/src');
433
+ const childUri = new URI('file:///project/src/components');
434
+
435
+ // Create grandparent, then parent, then child
436
+ await fileService.doWatch(grandparentUri, { recursive: true, excludes: [] });
437
+ const parentDisposable = await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
438
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
439
+
440
+ // Grandparent subsumed parent; parent did not get a real watcher.
441
+ // Child was also subsumed (by grandparent after re-parenting).
442
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
443
+
444
+ // Dispose the parent
445
+ parentDisposable.dispose();
446
+
447
+ // Grandparent still covers the child — no new OS watcher needed
448
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
449
+ });
450
+
451
+ it('should promote multiple children when a parent is disposed', async () => {
452
+ const parentUri = new URI('file:///project');
453
+ const child1Uri = new URI('file:///project/src');
454
+ const child2Uri = new URI('file:///project/test');
455
+ const child3Uri = new URI('file:///project/docs');
456
+
457
+ const parentDisposable = await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
458
+ await fileService.doWatch(child1Uri, { recursive: false, excludes: [] });
459
+ await fileService.doWatch(child2Uri, { recursive: false, excludes: [] });
460
+ await fileService.doWatch(child3Uri, { recursive: false, excludes: [] });
461
+
462
+ expect(mockProvider.watchers).to.have.lengthOf(1);
463
+
464
+ parentDisposable.dispose();
465
+
466
+ // All three children should now have their own real watchers
467
+ expect(liveWatcherCount(mockProvider)).to.equal(3);
468
+ });
469
+
470
+ it('should not corrupt index when disposing a subsumed recursive watcher at the same URI', async () => {
471
+ const uri = new URI('file:///project');
472
+ const childUri = new URI('file:///project/src');
473
+
474
+ // Watcher A: recursive, no excludes — gets indexed
475
+ await fileService.doWatch(uri, { recursive: true, excludes: [] });
476
+
477
+ // Watcher B: recursive, with excludes at the same URI — subsumed by A (not indexed)
478
+ const bDisposable = await fileService.doWatch(uri, { recursive: true, excludes: ['**/node_modules'] });
479
+
480
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
481
+
482
+ // Disposing B must NOT remove A's index entry.
483
+ // Without the fix, removeFromRecursiveIndex would delete /project from
484
+ // the tree, causing subsequent child watchers to miss A as a parent.
485
+ bDisposable.dispose();
486
+
487
+ // A child watcher should still be subsumed by A
488
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
489
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
490
+ });
491
+
492
+ it('should handle disposing a subsumed child cleanly', async () => {
493
+ const parentUri = new URI('file:///project');
494
+ const childUri = new URI('file:///project/src');
495
+
496
+ await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
497
+ const childDisposable = await fileService.doWatch(childUri, { recursive: false, excludes: [] });
498
+
499
+ expect(mockProvider.watchers).to.have.lengthOf(1);
500
+
501
+ // Disposing the subsumed child should not affect the parent's watcher
502
+ childDisposable.dispose();
503
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
504
+ });
505
+
506
+ it('should handle dispose-then-rewatch correctly', async () => {
507
+ const parentUri = new URI('file:///project');
508
+ const childUri = new URI('file:///project/src');
509
+
510
+ const parentDisposable = await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
511
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
512
+
513
+ parentDisposable.dispose();
514
+
515
+ // Child is promoted, now re-create the parent
516
+ const parentDisposable2 = await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
517
+
518
+ // The child should be subsumed again — its promoted watcher should be disposed
519
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
520
+
521
+ parentDisposable2.dispose();
522
+
523
+ // Child promoted again
524
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
525
+ });
526
+ });
527
+
528
+ // ── Exclude pattern matching ───────────────────────────────────────
529
+
530
+ describe('exclude pattern matching', () => {
531
+ it('should exclude with glob patterns using **', async () => {
532
+ const parentUri = new URI('file:///project');
533
+ const excludedChild = new URI('file:///project/dist/bundle.js');
534
+
535
+ await fileService.doWatch(parentUri, { recursive: true, excludes: ['**/dist'] });
536
+ await fileService.doWatch(excludedChild, { recursive: false, excludes: [] });
537
+
538
+ // Should not be subsumed because dist is excluded
539
+ expect(mockProvider.watchers).to.have.lengthOf(2);
540
+ });
541
+
542
+ it('should handle multiple exclude patterns', async () => {
543
+ const parentUri = new URI('file:///project');
544
+ const nmChild = new URI('file:///project/node_modules/pkg');
545
+ const distChild = new URI('file:///project/dist/out');
546
+ const srcChild = new URI('file:///project/src/main');
547
+
548
+ await fileService.doWatch(parentUri, { recursive: true, excludes: ['**/node_modules', '**/dist'] });
549
+ await fileService.doWatch(nmChild, { recursive: false, excludes: [] });
550
+ await fileService.doWatch(distChild, { recursive: false, excludes: [] });
551
+ await fileService.doWatch(srcChild, { recursive: false, excludes: [] });
552
+
553
+ // nm and dist children need their own watchers; src is subsumed
554
+ expect(mockProvider.watchers).to.have.lengthOf(3); // parent + nm + dist
555
+ });
556
+
557
+ it('should exclude based on ancestor directory matching', async () => {
558
+ const parentUri = new URI('file:///project');
559
+ // The exclude is for node_modules — anything under it should also be excluded
560
+ const deepChild = new URI('file:///project/node_modules/pkg/lib/index.js');
561
+
562
+ await fileService.doWatch(parentUri, { recursive: true, excludes: ['**/node_modules'] });
563
+ await fileService.doWatch(deepChild, { recursive: false, excludes: [] });
564
+
565
+ expect(mockProvider.watchers).to.have.lengthOf(2);
566
+ });
567
+
568
+ it('should not exclude a non-matching path', async () => {
569
+ const parentUri = new URI('file:///project');
570
+ const childUri = new URI('file:///project/src/node_modules_backup/file.ts');
571
+
572
+ await fileService.doWatch(parentUri, { recursive: true, excludes: ['**/node_modules'] });
573
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
574
+
575
+ // 'node_modules_backup' does not match '**/node_modules', so child should be subsumed
576
+ expect(mockProvider.watchers).to.have.lengthOf(1);
577
+ });
578
+ });
579
+
580
+ // ── Edge cases ─────────────────────────────────────────────────────
581
+
582
+ describe('edge cases', () => {
583
+ it('should promote a non-recursive watcher when its recursive same-URI subsumer is disposed', async () => {
584
+ const uri = new URI('file:///project/src');
585
+ const recursiveOpts: WatchOptions = { recursive: true, excludes: [] };
586
+ const nonRecursiveOpts: WatchOptions = { recursive: false, excludes: [] };
587
+
588
+ const d1 = await fileService.doWatch(uri, recursiveOpts);
589
+ const d2 = await fileService.doWatch(uri, nonRecursiveOpts);
590
+
591
+ // findSubstr matches exact URIs, so the recursive watcher subsumes the non-recursive one
592
+ expect(mockProvider.watchers).to.have.lengthOf(1);
593
+
594
+ // Disposing the recursive watcher promotes the non-recursive one
595
+ d1.dispose();
596
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
597
+
598
+ d2.dispose();
599
+ expect(liveWatcherCount(mockProvider)).to.equal(0);
600
+ });
601
+
602
+ it('should handle double dispose gracefully', async () => {
603
+ const uri = new URI('file:///project/src');
604
+ const d1 = await fileService.doWatch(uri, { recursive: false, excludes: [] });
605
+
606
+ d1.dispose();
607
+ // Double dispose should not throw
608
+ d1.dispose();
609
+ expect(liveWatcherCount(mockProvider)).to.equal(0);
610
+ });
611
+
612
+ it('should handle many watchers under one parent', async () => {
613
+ const parentUri = new URI('file:///project');
614
+ await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
615
+
616
+ const childDisposables: Disposable[] = [];
617
+ for (let i = 0; i < 20; i++) {
618
+ const d = await fileService.doWatch(
619
+ new URI(`file:///project/dir${i}`),
620
+ { recursive: false, excludes: [] }
621
+ );
622
+ childDisposables.push(d);
623
+ }
624
+
625
+ // Only the parent's real watcher exists
626
+ expect(mockProvider.watchers).to.have.lengthOf(1);
627
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
628
+
629
+ // Dispose all children — parent still alive
630
+ for (const d of childDisposables) {
631
+ d.dispose();
632
+ }
633
+ expect(liveWatcherCount(mockProvider)).to.equal(1);
634
+ });
635
+ });
636
+ });
637
+
638
+ /**
639
+ * Creates a mock provider with a real onDidChangeFile emitter, suitable for
640
+ * registering with FileService.registerProvider and testing event delivery.
641
+ */
642
+ function createEventMockProvider(caseSensitive: boolean = true): FileSystemProvider & {
643
+ watchers: MockWatcher[];
644
+ fileChangeEmitter: Emitter<readonly FileChange[]>;
645
+ /** Simulate a file change event delivered by active OS watchers. */
646
+ simulateChange(uri: URI, type?: FileChangeType): void;
647
+ } {
648
+ const watchers: MockWatcher[] = [];
649
+ const fileChangeEmitter = new Emitter<readonly FileChange[]>();
650
+ const provider = {
651
+ watchers,
652
+ fileChangeEmitter,
653
+ capabilities: caseSensitive ? FileSystemProviderCapabilities.PathCaseSensitive : 0,
654
+ onDidChangeCapabilities: () => Disposable.NULL,
655
+ onDidChangeFile: fileChangeEmitter.event,
656
+ onFileWatchError: () => Disposable.NULL,
657
+ watch(resource: URI, options: WatchOptions): Disposable {
658
+ const watcher: MockWatcher = {
659
+ resource, options, disposed: false,
660
+ disposable: Disposable.create(() => { watcher.disposed = true; }),
661
+ };
662
+ watchers.push(watcher);
663
+ return watcher.disposable;
664
+ },
665
+ simulateChange(uri: URI, type: FileChangeType = FileChangeType.UPDATED): void {
666
+ fileChangeEmitter.fire([{ resource: uri, type }]);
667
+ },
668
+ stat: () => { throw new Error('not implemented'); },
669
+ readdir: () => { throw new Error('not implemented'); },
670
+ readFile: () => { throw new Error('not implemented'); },
671
+ writeFile: () => { throw new Error('not implemented'); },
672
+ delete: () => { throw new Error('not implemented'); },
673
+ mkdir: () => { throw new Error('not implemented'); },
674
+ rename: () => { throw new Error('not implemented'); },
675
+ };
676
+ return provider as FileSystemProvider & typeof provider;
677
+ }
678
+
679
+ describe('FileService watcher event delivery', () => {
680
+ let fileService: FileService;
681
+ let provider: ReturnType<typeof createEventMockProvider>;
682
+ let disJSDOM: () => void;
683
+
684
+ before(() => {
685
+ disJSDOM = enableJSDOM();
686
+ });
687
+
688
+ after(() => {
689
+ disJSDOM();
690
+ });
691
+
692
+ beforeEach(() => {
693
+ fileService = new FileService();
694
+ provider = createEventMockProvider();
695
+ fileService.registerProvider('file', provider);
696
+ });
697
+
698
+ it('should deliver events for a subsumed child through the parent watcher', async () => {
699
+ const received: URI[] = [];
700
+ fileService.onDidFilesChange(e => {
701
+ for (const change of e.changes) {
702
+ received.push(change.resource);
703
+ }
704
+ });
705
+
706
+ const parentUri = new URI('file:///project');
707
+ const childUri = new URI('file:///project/src');
708
+
709
+ await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
710
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
711
+
712
+ // Child is subsumed — only parent has a real watcher
713
+ expect(liveWatcherCount(provider)).to.equal(1);
714
+
715
+ // Simulate a change under the child path — should be delivered through the parent
716
+ const changedFile = new URI('file:///project/src/index.ts');
717
+ provider.simulateChange(changedFile);
718
+
719
+ expect(received).to.have.lengthOf(1);
720
+ expect(received[0].toString()).to.equal(changedFile.toString());
721
+ });
722
+
723
+ it('should deliver events after a child is promoted due to parent disposal', async () => {
724
+ const received: URI[] = [];
725
+ fileService.onDidFilesChange(e => {
726
+ for (const change of e.changes) {
727
+ received.push(change.resource);
728
+ }
729
+ });
730
+
731
+ const parentUri = new URI('file:///project');
732
+ const childUri = new URI('file:///project/src');
733
+
734
+ const parentDisposable = await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
735
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
736
+
737
+ // Dispose parent — child gets promoted with its own real watcher
738
+ parentDisposable.dispose();
739
+ expect(liveWatcherCount(provider)).to.equal(1);
740
+
741
+ // Simulate a change — should still be delivered
742
+ const changedFile = new URI('file:///project/src/index.ts');
743
+ provider.simulateChange(changedFile);
744
+
745
+ expect(received).to.have.lengthOf(1);
746
+ expect(received[0].toString()).to.equal(changedFile.toString());
747
+ });
748
+
749
+ it('should not let a parent with excludes suppress events for a child that needs them', async () => {
750
+ const received: URI[] = [];
751
+ fileService.onDidFilesChange(e => {
752
+ for (const change of e.changes) {
753
+ received.push(change.resource);
754
+ }
755
+ });
756
+
757
+ const parentUri = new URI('file:///project');
758
+ const childUri = new URI('file:///project/src');
759
+
760
+ await fileService.doWatch(parentUri, { recursive: true, excludes: ['**/node_modules'] });
761
+ await fileService.doWatch(childUri, { recursive: true, excludes: [] });
762
+
763
+ // Child is recursive with no excludes, parent has excludes — child should NOT be subsumed
764
+ // Both should have their own real watchers
765
+ expect(liveWatcherCount(provider)).to.equal(2);
766
+
767
+ // Simulate a change inside node_modules under the child — only the child's watcher would catch it
768
+ const changedFile = new URI('file:///project/src/node_modules/pkg/index.js');
769
+ provider.simulateChange(changedFile);
770
+
771
+ expect(received).to.have.lengthOf(1);
772
+ expect(received[0].toString()).to.equal(changedFile.toString());
773
+ });
774
+
775
+ it('should preserve recursive watcher state through subsumption and promotion', async () => {
776
+ const received: URI[] = [];
777
+ fileService.onDidFilesChange(e => {
778
+ for (const change of e.changes) {
779
+ received.push(change.resource);
780
+ }
781
+ });
782
+
783
+ const grandparentUri = new URI('file:///project');
784
+ const parentUri = new URI('file:///project/src');
785
+ const childUri = new URI('file:///project/src/lib');
786
+
787
+ // Create parent (recursive), then child under it
788
+ await fileService.doWatch(parentUri, { recursive: true, excludes: [] });
789
+ await fileService.doWatch(childUri, { recursive: false, excludes: [] });
790
+ expect(liveWatcherCount(provider)).to.equal(1); // only parent has real watcher
791
+
792
+ // Now create a grandparent that subsumes the parent
793
+ const gpDisposable = await fileService.doWatch(grandparentUri, { recursive: true, excludes: [] });
794
+ expect(liveWatcherCount(provider)).to.equal(1); // only grandparent is live
795
+
796
+ // Dispose the grandparent — parent should be promoted, child should re-parent to parent
797
+ gpDisposable.dispose();
798
+ expect(liveWatcherCount(provider)).to.equal(1); // promoted parent is live
799
+
800
+ // Simulate a change under the child — should still be delivered
801
+ const changedFile = new URI('file:///project/src/lib/utils.ts');
802
+ provider.simulateChange(changedFile);
803
+
804
+ expect(received).to.have.lengthOf(1);
805
+ expect(received[0].toString()).to.equal(changedFile.toString());
806
+ });
807
+ });