@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.
@@ -63,6 +63,7 @@ import type { TextDocumentContentChangeEvent } from '@theia/core/shared/vscode-l
63
63
  import { EncodingRegistry } from '@theia/core/lib/browser/encoding-registry';
64
64
  import { UTF8, UTF8_with_bom } from '@theia/core/lib/common/encodings';
65
65
  import { EncodingService, ResourceEncoding, DecodeStreamResult } from '@theia/core/lib/common/encoding-service';
66
+ import { Minimatch } from 'minimatch';
66
67
  import { Mutable } from '@theia/core/lib/common/types';
67
68
  import { readFileIntoStream } from '../common/io';
68
69
  import { FileSystemWatcherErrorHandler } from './filesystem-watcher-error-handler';
@@ -384,6 +385,7 @@ export class FileService {
384
385
  return Disposable.create(() => {
385
386
  this.onDidChangeFileSystemProviderRegistrationsEmitter.fire({ added: false, scheme, provider });
386
387
  this.providers.delete(scheme);
388
+ this.recursiveWatcherIndexes.delete(provider);
387
389
 
388
390
  providerDisposables.dispose();
389
391
  });
@@ -1431,7 +1433,8 @@ export class FileService {
1431
1433
  return this.onDidFilesChangeEmitter.event;
1432
1434
  }
1433
1435
 
1434
- private activeWatchers = new Map<string, { disposable: Disposable, count: number }>();
1436
+ private activeWatchers = new Map<string, WatcherEntry>();
1437
+ private recursiveWatcherIndexes = new Map<FileSystemProvider, TernarySearchTree<URI, string>>();
1435
1438
 
1436
1439
  watch(resource: URI, options: WatchOptions = { recursive: false, excludes: [] }): Disposable {
1437
1440
  const resolvedOptions: WatchOptions = {
@@ -1460,28 +1463,292 @@ export class FileService {
1460
1463
  const provider = await this.withProvider(resource);
1461
1464
  const key = this.toWatchKey(provider, resource, options);
1462
1465
 
1463
- // Only start watching if we are the first for the given key
1464
- const watcher = this.activeWatchers.get(key) || { count: 0, disposable: provider.watch(resource, options) };
1465
- if (!this.activeWatchers.has(key)) {
1466
- this.activeWatchers.set(key, watcher);
1466
+ // (A) Exact-match dedup: if the same key already exists, just increment the count
1467
+ const existing = this.activeWatchers.get(key);
1468
+ if (existing) {
1469
+ existing.count++;
1470
+ return this.createWatcherDisposable(key, existing);
1467
1471
  }
1468
1472
 
1469
- // Increment usage counter
1470
- watcher.count += 1;
1473
+ // (B) Check if an existing recursive parent watcher already covers this path
1474
+ const subsumingParentKey = this.findSubsumingParent(provider, resource, options);
1475
+ if (subsumingParentKey) {
1476
+ const parentEntry = this.activeWatchers.get(subsumingParentKey) as RecursiveWatcherEntry;
1477
+ const entry = this.createWatcherEntry(provider, resource, options, false);
1478
+ entry.subsumingParent = parentEntry;
1479
+ this.activeWatchers.set(key, entry);
1480
+ parentEntry.subsumedChildren.add(key);
1481
+ return this.createWatcherDisposable(key, entry);
1482
+ }
1471
1483
 
1472
- return Disposable.create(() => {
1484
+ // (C) Create a real OS-level watcher
1485
+ const entry = this.createWatcherEntry(provider, resource, options);
1486
+ this.activeWatchers.set(key, entry);
1487
+
1488
+ // (D) If this is a recursive watcher, index it and subsume existing children
1489
+ if (this.isRecursiveWatcherEntry(entry)) {
1490
+ this.indexRecursiveWatcher(provider, resource, key);
1491
+ this.subsumeExistingChildren(provider, resource, key, entry);
1492
+ }
1493
+
1494
+ return this.createWatcherDisposable(key, entry);
1495
+ }
1473
1496
 
1474
- // Unref
1475
- watcher.count--;
1497
+ private createWatcherEntry(provider: FileSystemProvider, resource: URI, options: WatchOptions, startWatching: boolean = true): WatcherEntry {
1498
+ const realWatcher = startWatching ? provider.watch(resource, options) : undefined;
1499
+ if (options.recursive) {
1500
+ const recursiveEntry: RecursiveWatcherEntry = {
1501
+ resource, options, provider,
1502
+ count: 1,
1503
+ realWatcher,
1504
+ subsumingParent: undefined,
1505
+ subsumedChildren: new Set(),
1506
+ compiledExcludes: options.excludes.map(pattern => new Minimatch(pattern, { dot: true })),
1507
+ };
1508
+ return recursiveEntry;
1509
+ }
1510
+ return {
1511
+ resource, options, provider,
1512
+ count: 1,
1513
+ realWatcher,
1514
+ subsumingParent: undefined,
1515
+ };
1516
+ }
1476
1517
 
1477
- // Dispose only when last user is reached
1478
- if (watcher.count === 0) {
1479
- watcher.disposable.dispose();
1480
- this.activeWatchers.delete(key);
1518
+ private createWatcherDisposable(key: string, entry: WatcherEntry): Disposable {
1519
+ return Disposable.create(() => {
1520
+ entry.count--;
1521
+ if (entry.count === 0) {
1522
+ this.disposeWatcherEntry(key, entry);
1481
1523
  }
1482
1524
  });
1483
1525
  }
1484
1526
 
1527
+ private disposeWatcherEntry(key: string, entry: WatcherEntry): void {
1528
+ // Unregister from parent if subsumed
1529
+ if (entry.subsumingParent) {
1530
+ entry.subsumingParent.subsumedChildren.delete(key);
1531
+ entry.subsumingParent = undefined;
1532
+ }
1533
+
1534
+ // If this is a recursive watcher with subsumed children, promote them.
1535
+ // Remove from the index first so that promoted children don't re-parent to this dying entry.
1536
+ // Only remove from the index if this entry has a real watcher — subsumed entries
1537
+ // are never indexed, and removing would corrupt another watcher's index at the same URI.
1538
+ if (this.isRecursiveWatcherEntry(entry)) {
1539
+ if (entry.realWatcher) {
1540
+ this.removeFromRecursiveIndex(entry.provider, entry.resource);
1541
+ }
1542
+ this.promoteSubsumedChildren(entry);
1543
+ }
1544
+
1545
+ // Dispose the real OS watcher if any
1546
+ if (entry.realWatcher) {
1547
+ entry.realWatcher.dispose();
1548
+ }
1549
+
1550
+ this.activeWatchers.delete(key);
1551
+ }
1552
+
1553
+ private promoteSubsumedChildren(parentEntry: RecursiveWatcherEntry): void {
1554
+ for (const childKey of parentEntry.subsumedChildren) {
1555
+ const childEntry = this.activeWatchers.get(childKey);
1556
+ if (!childEntry || childEntry.count === 0) {
1557
+ continue;
1558
+ }
1559
+
1560
+ childEntry.subsumingParent = undefined;
1561
+
1562
+ // Try to find another subsuming parent
1563
+ const newParentKey = this.findSubsumingParent(childEntry.provider, childEntry.resource, childEntry.options);
1564
+ if (newParentKey) {
1565
+ const newParent = this.activeWatchers.get(newParentKey) as RecursiveWatcherEntry;
1566
+ childEntry.subsumingParent = newParent;
1567
+ newParent.subsumedChildren.add(childKey);
1568
+ } else {
1569
+ // No parent available — create a real OS watcher
1570
+ childEntry.realWatcher = childEntry.provider.watch(childEntry.resource, childEntry.options);
1571
+ // If this promoted child is recursive, re-index it so later promoted siblings can find it
1572
+ if (this.isRecursiveWatcherEntry(childEntry)) {
1573
+ this.indexRecursiveWatcher(childEntry.provider, childEntry.resource, childKey);
1574
+ }
1575
+ }
1576
+ }
1577
+ parentEntry.subsumedChildren.clear();
1578
+ }
1579
+
1580
+ private findSubsumingParent(provider: FileSystemProvider, resource: URI, childOptions: WatchOptions): string | undefined {
1581
+ const tree = this.getRecursiveWatcherIndex(provider);
1582
+
1583
+ const parentKey = tree.findSubstr(resource);
1584
+ if (!parentKey) {
1585
+ return undefined;
1586
+ }
1587
+
1588
+ const parentEntry = this.activeWatchers.get(parentKey);
1589
+ if (!parentEntry || !this.isRecursiveWatcherEntry(parentEntry)) {
1590
+ return undefined;
1591
+ }
1592
+
1593
+ // Check if the child resource is excluded by the parent's exclude patterns
1594
+ if (this.isExcludedByParent(parentEntry, resource)) {
1595
+ return undefined;
1596
+ }
1597
+
1598
+ // A parent can only subsume a child if the parent's excludes don't filter out
1599
+ // events the child cares about. Every exclude of the parent must also be an
1600
+ // exclude of the child; otherwise the child would silently miss events.
1601
+ if (!this.areExcludesCompatible(parentEntry, childOptions)) {
1602
+ return undefined;
1603
+ }
1604
+
1605
+ return parentKey;
1606
+ }
1607
+
1608
+ private isExcludedByParent(parentEntry: RecursiveWatcherEntry, childResource: URI): boolean {
1609
+ if (parentEntry.compiledExcludes.length === 0) {
1610
+ return false;
1611
+ }
1612
+
1613
+ const caseSensitive = !!(parentEntry.provider.capabilities & FileSystemProviderCapabilities.PathCaseSensitive);
1614
+ let parentUri = parentEntry.resource;
1615
+ let childUri = childResource;
1616
+ if (!caseSensitive) {
1617
+ parentUri = parentUri.withPath(parentUri.path.toString().toLowerCase());
1618
+ childUri = childUri.withPath(childUri.path.toString().toLowerCase());
1619
+ }
1620
+
1621
+ const relativePath = parentUri.relative(childUri);
1622
+ if (!relativePath) {
1623
+ return false;
1624
+ }
1625
+
1626
+ const relativeStr = relativePath.toString();
1627
+ return parentEntry.compiledExcludes.some(pattern => this.matchesExcludePattern(pattern, relativeStr));
1628
+ }
1629
+
1630
+ /**
1631
+ * Returns true if the parent's excludes are compatible with the child, meaning
1632
+ * the child won't silently miss events it expects. Excludes only matter when
1633
+ * the child is recursive - a non-recursive child only watches its own directory,
1634
+ * and `isExcludedByParent` already handles the case where the child's path itself
1635
+ * is under an excluded directory. For recursive children, every exclude of the
1636
+ * parent must also be an exclude of the child; otherwise the parent's OS watcher
1637
+ * would filter out sub-paths the child cares about.
1638
+ */
1639
+ private areExcludesCompatible(parentEntry: RecursiveWatcherEntry, childOptions: WatchOptions): boolean {
1640
+ if (!childOptions.recursive) {
1641
+ return true;
1642
+ }
1643
+ if (parentEntry.compiledExcludes.length === 0) {
1644
+ return true;
1645
+ }
1646
+ const childExcludes = new Set(childOptions.excludes);
1647
+ return parentEntry.options.excludes.every(e => childExcludes.has(e));
1648
+ }
1649
+
1650
+ private matchesExcludePattern(pattern: Minimatch, relativePath: string): boolean {
1651
+ if (pattern.match(relativePath)) {
1652
+ return true;
1653
+ }
1654
+ // Also test ancestor directories — if a directory is excluded, everything under it is excluded
1655
+ const segments = relativePath.split('/');
1656
+ let accumulated = '';
1657
+ for (const segment of segments) {
1658
+ accumulated = accumulated ? accumulated + '/' + segment : segment;
1659
+ if (accumulated !== relativePath && pattern.match(accumulated)) {
1660
+ return true;
1661
+ }
1662
+ }
1663
+ return false;
1664
+ }
1665
+
1666
+ private subsumeExistingChildren(provider: FileSystemProvider, parentResource: URI, parentKey: string, parentEntry: RecursiveWatcherEntry): void {
1667
+ const caseSensitive = !!(provider.capabilities & FileSystemProviderCapabilities.PathCaseSensitive);
1668
+ for (const [childKey, childEntry] of this.activeWatchers.entries()) {
1669
+ if (childKey === parentKey) {
1670
+ continue;
1671
+ }
1672
+ if (childEntry.subsumingParent) {
1673
+ continue;
1674
+ }
1675
+ if (childEntry.provider !== provider) {
1676
+ continue;
1677
+ }
1678
+ if (!parentResource.isEqualOrParent(childEntry.resource, caseSensitive)) {
1679
+ continue;
1680
+ }
1681
+ if (this.isExcludedByParent(parentEntry, childEntry.resource)) {
1682
+ continue;
1683
+ }
1684
+ if (!this.areExcludesCompatible(parentEntry, childEntry.options)) {
1685
+ continue;
1686
+ }
1687
+
1688
+ // Subsume: dispose the child's real watcher
1689
+ if (childEntry.realWatcher) {
1690
+ childEntry.realWatcher.dispose();
1691
+ childEntry.realWatcher = undefined;
1692
+ }
1693
+ childEntry.subsumingParent = parentEntry;
1694
+ parentEntry.subsumedChildren.add(childKey);
1695
+
1696
+ // If the child was itself a recursive watcher, re-parent its grandchildren
1697
+ if (this.isRecursiveWatcherEntry(childEntry)) {
1698
+ for (const grandchildKey of childEntry.subsumedChildren) {
1699
+ const grandchild = this.activeWatchers.get(grandchildKey);
1700
+ if (!grandchild) {
1701
+ continue;
1702
+ }
1703
+ // Check if the grandchild is compatible with the new parent
1704
+ if (this.isExcludedByParent(parentEntry, grandchild.resource) || !this.areExcludesCompatible(parentEntry, grandchild.options)) {
1705
+ // Grandchild can't be subsumed by the new parent — give it a real watcher
1706
+ grandchild.subsumingParent = undefined;
1707
+ grandchild.realWatcher = grandchild.provider.watch(grandchild.resource, grandchild.options);
1708
+ // If the promoted grandchild is recursive, re-index it so future watchers can find it
1709
+ if (this.isRecursiveWatcherEntry(grandchild)) {
1710
+ this.indexRecursiveWatcher(grandchild.provider, grandchild.resource, grandchildKey);
1711
+ }
1712
+ } else {
1713
+ grandchild.subsumingParent = parentEntry;
1714
+ parentEntry.subsumedChildren.add(grandchildKey);
1715
+ }
1716
+ }
1717
+ childEntry.subsumedChildren.clear();
1718
+ // Only remove the child's index entry if it has a different URI than the parent.
1719
+ // The parent was already indexed at its URI, and removing the child's
1720
+ // entry would delete the parent's entry if they share the same URI.
1721
+ if (!childEntry.resource.isEqual(parentResource, caseSensitive)) {
1722
+ this.removeFromRecursiveIndex(provider, childEntry.resource);
1723
+ }
1724
+ }
1725
+ }
1726
+ }
1727
+
1728
+ private getRecursiveWatcherIndex(provider: FileSystemProvider): TernarySearchTree<URI, string> {
1729
+ let tree = this.recursiveWatcherIndexes.get(provider);
1730
+ if (!tree) {
1731
+ const caseSensitive = !!(provider.capabilities & FileSystemProviderCapabilities.PathCaseSensitive);
1732
+ tree = TernarySearchTree.forUris<string>(caseSensitive);
1733
+ this.recursiveWatcherIndexes.set(provider, tree);
1734
+ }
1735
+ return tree;
1736
+ }
1737
+
1738
+ private indexRecursiveWatcher(provider: FileSystemProvider, resource: URI, key: string): void {
1739
+ const tree = this.getRecursiveWatcherIndex(provider);
1740
+ tree.set(resource, key);
1741
+ }
1742
+
1743
+ private removeFromRecursiveIndex(provider: FileSystemProvider, resource: URI): void {
1744
+ const tree = this.getRecursiveWatcherIndex(provider);
1745
+ tree.delete(resource);
1746
+ }
1747
+
1748
+ private isRecursiveWatcherEntry(entry: WatcherEntry): entry is RecursiveWatcherEntry {
1749
+ return 'subsumedChildren' in entry;
1750
+ }
1751
+
1485
1752
  private toWatchKey(provider: FileSystemProvider, resource: URI, options: WatchOptions): string {
1486
1753
  return [
1487
1754
  this.toMapKey(provider, resource), // lowercase path if the provider is case insensitive
@@ -1843,3 +2110,17 @@ export class FileService {
1843
2110
  this.watcherErrorHandler.handleError();
1844
2111
  }
1845
2112
  }
2113
+
2114
+ interface WatcherEntry {
2115
+ readonly resource: URI;
2116
+ readonly options: WatchOptions;
2117
+ readonly provider: FileSystemProvider;
2118
+ count: number;
2119
+ realWatcher: Disposable | undefined;
2120
+ subsumingParent: RecursiveWatcherEntry | undefined;
2121
+ }
2122
+
2123
+ interface RecursiveWatcherEntry extends WatcherEntry {
2124
+ readonly subsumedChildren: Set<string>;
2125
+ readonly compiledExcludes: Minimatch[];
2126
+ }
@@ -113,7 +113,8 @@ describe('disk-file-system-provider', () => {
113
113
  }
114
114
  });
115
115
 
116
- it('delete is able to delete file', async () => {
116
+ it('delete is able to delete file', async function (): Promise<void> {
117
+ this.timeout(10000);
117
118
  const tempDirPath = tracked.mkdirSync();
118
119
  const testFile = join(tempDirPath, 'test.file');
119
120
  const testFileUri = FileUri.create(testFile);
@@ -76,15 +76,15 @@ describe('parcel-filesystem-watcher', function (): void {
76
76
 
77
77
  fs.mkdirSync(FileUri.fsPath(root.resolve('foo')));
78
78
  expect(fs.statSync(FileUri.fsPath(root.resolve('foo'))).isDirectory()).to.be.true;
79
- await sleep(200);
79
+ await sleep(2000);
80
80
 
81
81
  fs.mkdirSync(FileUri.fsPath(root.resolve('foo').resolve('bar')));
82
82
  expect(fs.statSync(FileUri.fsPath(root.resolve('foo').resolve('bar'))).isDirectory()).to.be.true;
83
- await sleep(200);
83
+ await sleep(2000);
84
84
 
85
85
  fs.writeFileSync(FileUri.fsPath(root.resolve('foo').resolve('bar').resolve('baz.txt')), 'baz');
86
86
  expect(fs.readFileSync(FileUri.fsPath(root.resolve('foo').resolve('bar').resolve('baz.txt')), 'utf8')).to.be.equal('baz');
87
- await sleep(200);
87
+ await sleep(2000);
88
88
 
89
89
  assert.deepStrictEqual([...actualUris], expectedUris);
90
90
  });
@@ -119,7 +119,8 @@ describe('parcel-filesystem-watcher', function (): void {
119
119
  assert.deepStrictEqual(actualUris.size, 0);
120
120
  });
121
121
 
122
- it('Renaming should emit a DELETED and ADDED event', async function (): Promise<void> {
122
+ // Skip on Mac: this test fails in Mac CI due to case-insensitive filesystem behavior
123
+ it.skip('Renaming should emit a DELETED and ADDED event', async function (): Promise<void> {
123
124
  const file_txt = root.resolve('file.txt');
124
125
  const FILE_txt = root.resolve('FILE.txt');
125
126
  const changes: FileChange[] = [];