From 33c149f3ad49db462cb451f4db2e01d64b15e40b Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 22 Sep 2021 13:43:26 +0200 Subject: [PATCH] watcher - introduce `files.watcherInclude` for explicit watching of folders (#132483) --- src/vs/platform/files/common/files.ts | 1 + .../files/node/diskFileSystemProvider.ts | 14 +- .../node/watcher/nsfw/nsfwWatcherService.ts | 340 ++++++++++-------- .../nsfw/test/nsfwWatcherService.test.ts | 73 ++-- .../files/node/watcher/nsfw/watcher.ts | 30 +- .../files/node/watcher/nsfw/watcherService.ts | 29 +- .../watcher/unix/chokidarWatcherService.ts | 20 +- .../unix/test/chockidarWatcherService.test.ts | 6 +- .../files/node/watcher/unix/watcher.ts | 9 +- .../files/node/watcher/unix/watcherService.ts | 15 +- src/vs/platform/files/node/watcher/watcher.ts | 13 + .../node/watcher/win32/watcherService.ts | 9 +- .../files/browser/files.contribution.ts | 11 +- .../contrib/files/common/workspaceWatcher.ts | 69 +++- 14 files changed, 378 insertions(+), 261 deletions(-) diff --git a/src/vs/platform/files/common/files.ts b/src/vs/platform/files/common/files.ts index 7451fd3a6c6..3b60bb7d6b8 100644 --- a/src/vs/platform/files/common/files.ts +++ b/src/vs/platform/files/common/files.ts @@ -1069,6 +1069,7 @@ export interface IFilesConfiguration { associations: { [filepattern: string]: string }; exclude: IExpression; watcherExclude: { [filepattern: string]: boolean }; + watcherInclude: string[]; encoding: string; autoGuessEncoding: boolean; defaultLanguage: string; diff --git a/src/vs/platform/files/node/diskFileSystemProvider.ts b/src/vs/platform/files/node/diskFileSystemProvider.ts index 89b9c46f729..eaed354f24c 100644 --- a/src/vs/platform/files/node/diskFileSystemProvider.ts +++ b/src/vs/platform/files/node/diskFileSystemProvider.ts @@ -23,7 +23,7 @@ import { readFileIntoStream } from 'vs/platform/files/common/io'; import { FileWatcher as NodeJSWatcherService } from 'vs/platform/files/node/watcher/nodejs/watcherService'; import { FileWatcher as NsfwWatcherService } from 'vs/platform/files/node/watcher/nsfw/watcherService'; import { FileWatcher as UnixWatcherService } from 'vs/platform/files/node/watcher/unix/watcherService'; -import { IDiskFileChange, ILogMessage, toFileChanges } from 'vs/platform/files/node/watcher/watcher'; +import { IDiskFileChange, ILogMessage, IWatchRequest, toFileChanges } from 'vs/platform/files/node/watcher/watcher'; import { FileWatcher as WindowsWatcherService } from 'vs/platform/files/node/watcher/win32/watcherService'; import { ILogService, LogLevel } from 'vs/platform/log/common/log'; import product from 'vs/platform/product/common/product'; @@ -533,23 +533,23 @@ export class DiskFileSystemProvider extends Disposable implements readonly onDidChangeFile = this._onDidChangeFile.event; private recursiveWatcher: WindowsWatcherService | UnixWatcherService | NsfwWatcherService | undefined; - private readonly recursiveFoldersToWatch: { path: string, excludes: string[] }[] = []; + private readonly recursiveFoldersToWatch: IWatchRequest[] = []; private recursiveWatchRequestDelayer = this._register(new ThrottledDelayer(0)); private recursiveWatcherLogLevelListener: IDisposable | undefined; watch(resource: URI, opts: IWatchOptions): IDisposable { if (opts.recursive) { - return this.watchRecursive(resource, opts.excludes); + return this.watchRecursive(resource, opts); } return this.watchNonRecursive(resource); } - private watchRecursive(resource: URI, excludes: string[]): IDisposable { + private watchRecursive(resource: URI, opts: IWatchOptions): IDisposable { // Add to list of folders to watch recursively - const folderToWatch = { path: this.toFilePath(resource), excludes }; + const folderToWatch: IWatchRequest = { path: this.toFilePath(resource), excludes: opts.excludes }; const remove = insert(this.recursiveFoldersToWatch, folderToWatch); // Trigger update @@ -578,7 +578,7 @@ export class DiskFileSystemProvider extends Disposable implements // Reuse existing if (this.recursiveWatcher instanceof NsfwWatcherService) { - this.recursiveWatcher.setFolders(this.recursiveFoldersToWatch); + this.recursiveWatcher.watch(this.recursiveFoldersToWatch); } // Create new @@ -592,7 +592,7 @@ export class DiskFileSystemProvider extends Disposable implements if (this.recursiveFoldersToWatch.length > 0) { let watcherImpl: { new( - folders: { path: string, excludes: string[] }[], + folders: IWatchRequest[], onChange: (changes: IDiskFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean, diff --git a/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts b/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts index 53e98df2019..1821ad02d4b 100644 --- a/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts +++ b/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts @@ -7,30 +7,27 @@ import * as nsfw from 'nsfw'; import { ThrottledDelayer } from 'vs/base/common/async'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { Emitter } from 'vs/base/common/event'; -import { isEqualOrParent } from 'vs/base/common/extpath'; import { parse, ParsedPattern } from 'vs/base/common/glob'; import { Disposable } from 'vs/base/common/lifecycle'; +import { TernarySearchTree } from 'vs/base/common/map'; import { normalizeNFC } from 'vs/base/common/normalization'; import { join } from 'vs/base/common/path'; import { isMacintosh } from 'vs/base/common/platform'; import { realcaseSync, realpathSync } from 'vs/base/node/extpath'; import { FileChangeType } from 'vs/platform/files/common/files'; -import { IWatcherRequest, IWatcherService } from 'vs/platform/files/node/watcher/nsfw/watcher'; -import { IDiskFileChange, ILogMessage, normalizeFileChanges } from 'vs/platform/files/node/watcher/watcher'; - -const nsfwActionToRawChangeType: { [key: number]: number } = []; -nsfwActionToRawChangeType[nsfw.actions.CREATED] = FileChangeType.ADDED; -nsfwActionToRawChangeType[nsfw.actions.MODIFIED] = FileChangeType.UPDATED; -nsfwActionToRawChangeType[nsfw.actions.DELETED] = FileChangeType.DELETED; +import { IWatcherService } from 'vs/platform/files/node/watcher/nsfw/watcher'; +import { IDiskFileChange, ILogMessage, normalizeFileChanges, IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; interface IWatcher { - start(): void; - stop(): void; -} -interface IPathWatcher { - readonly ready: Promise; - watcher?: IWatcher; + /** + * The NSFW instance is resolved when the watching has started. + */ + readonly instance: Promise; + + /** + * Associated ignored patterns for the watcher that can be updated. + */ ignored: ParsedPattern[]; } @@ -38,15 +35,24 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { private static readonly FS_EVENT_DELAY = 50; // aggregate and only emit events when changes have stopped for this duration (in ms) + private static readonly MAP_NSFW_ACTION_TO_FILE_CHANGE = new Map( + [ + [nsfw.actions.CREATED, FileChangeType.ADDED], + [nsfw.actions.MODIFIED, FileChangeType.UPDATED], + [nsfw.actions.DELETED, FileChangeType.DELETED], + ] + ); + private readonly _onDidChangeFile = this._register(new Emitter()); readonly onDidChangeFile = this._onDidChangeFile.event; private readonly _onDidLogMessage = this._register(new Emitter()); readonly onDidLogMessage = this._onDidLogMessage.event; - private pathWatchers: { [watchPath: string]: IPathWatcher } = {}; - private verboseLogging: boolean | undefined; - private enospcErrorLogged: boolean | undefined; + private readonly watchers = new Map(); + + private verboseLogging = false; + private enospcErrorLogged = false; constructor() { super(); @@ -54,56 +60,135 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { process.on('uncaughtException', (error: Error | string) => this.onError(error)); } - async setRoots(roots: IWatcherRequest[]): Promise { - const normalizedRoots = this.normalizeRoots(roots); + async watch(requests: IWatchRequest[]): Promise { + + // Figure out duplicates to remove from the requests + const normalizedRequests = this.normalizeRequests(requests); - // Gather roots that are not currently being watched - const rootsToStartWatching = normalizedRoots.filter(root => { - return !(root.path in this.pathWatchers); + // Gather paths that we should start watching + const requestsToStartWatching = normalizedRequests.filter(request => { + return !this.watchers.has(request.path); }); - // Gather current roots that don't exist in the new roots array - const rootsToStopWatching = Object.keys(this.pathWatchers).filter(root => { - return normalizedRoots.every(normalizedRoot => normalizedRoot.path !== root); + // Gather paths that we should stop watching + const pathsToStopWatching = Array.from(this.watchers.keys()).filter(watchedPath => { + return !normalizedRequests.find(normalizedRequest => normalizedRequest.path === watchedPath); }); // Logging - this.debug(`Start watching: ${rootsToStartWatching.map(root => `${root.path} (excludes: ${root.excludes})`).join(',')}`); - this.debug(`Stop watching: ${rootsToStopWatching.join(',')}`); + this.debug(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes})`).join(',')}`); + this.debug(`Request to stop watching: ${pathsToStopWatching.join(',')}`); - // Stop watching some roots - for (const root of rootsToStopWatching) { - this.pathWatchers[root].ready.then(watcher => watcher.stop()); - delete this.pathWatchers[root]; + // Stop watching as instructed + for (const pathToStopWatching of pathsToStopWatching) { + this.stopWatching(pathToStopWatching); } - // Start watching some roots - for (const root of rootsToStartWatching) { - this.doWatch(root); + // Start watching as instructed + for (const request of requestsToStartWatching) { + this.startWatching(request); } - // Refresh ignored arrays in case they changed - for (const root of roots) { - if (root.path in this.pathWatchers) { - this.pathWatchers[root.path].ignored = Array.isArray(root.excludes) ? root.excludes.map(ignored => parse(ignored)) : []; + // Update ignore rules for all watchers + for (const request of normalizedRequests) { + const watcher = this.watchers.get(request.path); + if (watcher) { + watcher.ignored = this.toExcludePatterns(request.excludes); } } } - private doWatch(request: IWatcherRequest): void { - let readyPromiseResolve: (watcher: IWatcher) => void; - this.pathWatchers[request.path] = { - ready: new Promise(resolve => readyPromiseResolve = resolve), - ignored: Array.isArray(request.excludes) ? request.excludes.map(ignored => parse(ignored)) : [] + private toExcludePatterns(excludes: string[] | undefined): ParsedPattern[] { + return Array.isArray(excludes) ? excludes.map(exclude => parse(exclude)) : []; + } + + private startWatching(request: IWatchRequest): void { + + // Remember as watcher instance + let nsfwPromiseResolve: (watcher: nsfw.NSFW) => void; + const watcher: IWatcher = { + instance: new Promise(resolve => nsfwPromiseResolve = resolve), + ignored: this.toExcludePatterns(request.excludes) }; + this.watchers.set(request.path, watcher); + + // Path checks for symbolic links / wrong casing + const { realBasePathDiffers, realBasePathLength } = this.checkRequest(request); + + let undeliveredFileEvents: IDiskFileChange[] = []; + const fileEventDelayer = new ThrottledDelayer(NsfwWatcherService.FS_EVENT_DELAY); + + const onFileEvent = (path: string, type: FileChangeType) => { + if (!this.isPathIgnored(path, watcher.ignored)) { + undeliveredFileEvents.push({ type, path }); + } else if (this.verboseLogging) { + this.log(` >> ignored ${path}`); + } + }; + + nsfw(request.path, events => { + for (const event of events) { + + // Logging + if (this.verboseLogging) { + const logPath = event.action === nsfw.actions.RENAMED ? `${join(event.directory, event.oldFile || '')} -> ${event.newFile}` : join(event.directory, event.file || ''); + this.log(`${event.action === nsfw.actions.CREATED ? '[CREATED]' : event.action === nsfw.actions.DELETED ? '[DELETED]' : event.action === nsfw.actions.MODIFIED ? '[CHANGED]' : '[RENAMED]'} ${logPath}`); + } + + // Rename: convert into DELETE & ADD + if (event.action === nsfw.actions.RENAMED) { + onFileEvent(join(event.directory, event.oldFile || ''), FileChangeType.DELETED); // Rename fires when a file's name changes within a single directory + onFileEvent(join(event.newDirectory || event.directory, event.newFile || ''), FileChangeType.ADDED); + } + + // Created, modified, deleted: taks as is + else { + onFileEvent(join(event.directory, event.file || ''), NsfwWatcherService.MAP_NSFW_ACTION_TO_FILE_CHANGE.get(event.action)!); + } + } + + // Send events delayed and normalized + fileEventDelayer.trigger(async () => { + + // Remember as delivered + const events = undeliveredFileEvents; + undeliveredFileEvents = []; + + // Broadcast to clients normalized + const normalizedEvents = normalizeFileChanges(this.normalizeEvents(events, request, realBasePathDiffers, realBasePathLength)); + this._onDidChangeFile.fire(normalizedEvents); + + // Logging + if (this.verboseLogging) { + for (const event of normalizedEvents) { + this.log(` >> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.path}`); + } + } + }); + }, { + errorCallback: error => this.onError(error) + }).then(async nsfwWatcher => { + + // Begin watching + await nsfwWatcher.start(); + + return nsfwWatcher; + }).then(nsfwWatcher => { + this.debug(`Started watching: ${request.path}`); + + nsfwPromiseResolve(nsfwWatcher); + }); + } + + private checkRequest(request: IWatchRequest): { realBasePathDiffers: boolean, realBasePathLength: number } { + let realBasePathDiffers = false; + let realBasePathLength = request.path.length; // NSFW does not report file changes in the path provided on macOS if // - the path uses wrong casing // - the path is a symbolic link // We have to detect this case and massage the events to correct this. // Note: Other platforms do not seem to have these path issues. - let realBasePathDiffers = false; - let realBasePathLength = request.path.length; if (isMacintosh) { try { @@ -119,152 +204,117 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { realBasePathLength = realBasePath.length; realBasePathDiffers = true; - this.warn(`Watcher basePath does not match version on disk and will be corrected (original: ${request.path}, real: ${realBasePath})`); + this.warn(`correcting a path to watch that seems to be a symbolic link (original: ${request.path}, real: ${realBasePath})`); } } catch (error) { // ignore } } - this.debug(`Start watching with nsfw: ${request.path}`); - - let undeliveredFileEvents: IDiskFileChange[] = []; - const fileEventDelayer = new ThrottledDelayer(NsfwWatcherService.FS_EVENT_DELAY); - - nsfw(request.path, events => { - for (const e of events) { - - // Logging - if (this.verboseLogging) { - const logPath = e.action === nsfw.actions.RENAMED ? join(e.directory, e.oldFile || '') + ' -> ' + e.newFile : join(e.directory, e.file || ''); - this.log(`${e.action === nsfw.actions.CREATED ? '[CREATED]' : e.action === nsfw.actions.DELETED ? '[DELETED]' : e.action === nsfw.actions.MODIFIED ? '[CHANGED]' : '[RENAMED]'} ${logPath}`); - } - - // Convert nsfw event to `IRawFileChange` and add to queue - let absolutePath: string; - if (e.action === nsfw.actions.RENAMED) { - absolutePath = join(e.directory, e.oldFile || ''); // Rename fires when a file's name changes within a single directory + return { realBasePathDiffers, realBasePathLength }; + } - if (!this.isPathIgnored(absolutePath, this.pathWatchers[request.path].ignored)) { - undeliveredFileEvents.push({ type: FileChangeType.DELETED, path: absolutePath }); - } else if (this.verboseLogging) { - this.log(` >> ignored ${absolutePath}`); - } + private normalizeEvents(events: IDiskFileChange[], request: IWatchRequest, realBasePathDiffers: boolean, realBasePathLength: number): IDiskFileChange[] { + if (isMacintosh) { + for (const event of events) { - absolutePath = join(e.newDirectory || e.directory, e.newFile || ''); + // Mac uses NFD unicode form on disk, but we want NFC + event.path = normalizeNFC(event.path); - if (!this.isPathIgnored(absolutePath, this.pathWatchers[request.path].ignored)) { - undeliveredFileEvents.push({ type: FileChangeType.ADDED, path: absolutePath }); - } else if (this.verboseLogging) { - this.log(` >> ignored ${absolutePath}`); - } - } else { - absolutePath = join(e.directory, e.file || ''); - - if (!this.isPathIgnored(absolutePath, this.pathWatchers[request.path].ignored)) { - undeliveredFileEvents.push({ - type: nsfwActionToRawChangeType[e.action], - path: absolutePath - }); - } else if (this.verboseLogging) { - this.log(` >> ignored ${absolutePath}`); - } + // Convert paths back to original form in case it differs + if (realBasePathDiffers) { + event.path = request.path + event.path.substr(realBasePathLength); } } + } - // Delay and send buffer - fileEventDelayer.trigger(async () => { - const events = undeliveredFileEvents; - undeliveredFileEvents = []; - - if (isMacintosh) { - for (const e of events) { - - // Mac uses NFD unicode form on disk, but we want NFC - e.path = normalizeNFC(e.path); - - // Convert paths back to original form in case it differs - if (realBasePathDiffers) { - e.path = request.path + e.path.substr(realBasePathLength); - } - } - } - - // Broadcast to clients normalized - const normalizedEvents = normalizeFileChanges(events); - this._onDidChangeFile.fire(normalizedEvents); - - // Logging - if (this.verboseLogging) { - for (const e of normalizedEvents) { - this.log(` >> normalized ${e.type === FileChangeType.ADDED ? '[ADDED]' : e.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${e.path}`); - } - } - }); - }, { - errorCallback: error => this.onError(error) - }).then(watcher => { - this.pathWatchers[request.path].watcher = watcher; - const startPromise = watcher.start(); - startPromise.then(() => readyPromiseResolve(watcher)); - - return startPromise; - }); + return events; } private onError(error: unknown): void { + const msg = toErrorMessage(error); + // Specially handle ENOSPC errors that can happen when // the watcher consumes so many file descriptors that // we are running into a limit. We only want to warn // once in this case to avoid log spam. // See https://github.com/microsoft/vscode/issues/7950 - const msg = toErrorMessage(error); if (msg.indexOf('Inotify limit reached') !== -1 && !this.enospcErrorLogged) { this.enospcErrorLogged = true; this.error('Inotify limit reached (ENOSPC)'); } } - async setVerboseLogging(enabled: boolean): Promise { - this.verboseLogging = enabled; + async stop(): Promise { + for (const [path] of this.watchers) { + this.stopWatching(path); + } + + this.watchers.clear(); } - async stop(): Promise { - for (let path in this.pathWatchers) { - let watcher = this.pathWatchers[path]; - watcher.ready.then(watcher => watcher.stop()); + private stopWatching(path: string): void { + const watcher = this.watchers.get(path); + if (watcher) { + watcher.instance.then(watcher => watcher.stop()); + this.watchers.delete(path); + } + } - delete this.pathWatchers[path]; + protected normalizeRequests(requests: IWatchRequest[]): IWatchRequest[] { + const requestTrie = TernarySearchTree.forPaths(); + + // Sort requests by path length to have shortest first + // to have a way to prevent children to be watched if + // parents exist. + requests.sort((requestA, requestB) => requestA.path.length - requestB.path.length); + + // Only consider requests for watching that are not + // a child of an existing request path to prevent + // duplication. + // + // However, allow explicit requests to watch folders + // that are symbolic links because the NSFW watcher + // does not allow to recursively watch symbolic links. + for (const request of requests) { + if (requestTrie.findSubstr(request.path)) { + try { + const realpath = realpathSync(request.path); + if (realpath === request.path) { + continue; // path is not a symbolic link or similar + } + } catch (error) { + continue; // invalid path - ignore from watching + } + } + + requestTrie.set(request.path, request); } - this.pathWatchers = Object.create(null); + return Array.from(requestTrie).map(([, request]) => request); } - protected normalizeRoots(roots: IWatcherRequest[]): IWatcherRequest[] { - // Normalizes a set of root paths by removing any root paths that are - // sub-paths of other roots. - return roots.filter(root => roots.every(otherRoot => { - return !(root.path.length > otherRoot.path.length && isEqualOrParent(root.path, otherRoot.path)); - })); + private isPathIgnored(absolutePath: string, ignored: ParsedPattern[] | undefined): boolean { + return Array.isArray(ignored) && ignored.some(ignore => ignore(absolutePath)); } - private isPathIgnored(absolutePath: string, ignored: ParsedPattern[]): boolean { - return ignored && ignored.some(ignore => ignore(absolutePath)); + async setVerboseLogging(enabled: boolean): Promise { + this.verboseLogging = enabled; } private log(message: string) { - this._onDidLogMessage.fire({ type: 'trace', message: `[File Watcher (nsfw)] ` + message }); + this._onDidLogMessage.fire({ type: 'trace', message: `[File Watcher (nsfw)] ${message}` }); } private warn(message: string) { - this._onDidLogMessage.fire({ type: 'warn', message: `[File Watcher (nsfw)] ` + message }); + this._onDidLogMessage.fire({ type: 'warn', message: `[File Watcher (nsfw)] ${message}` }); } private error(message: string) { - this._onDidLogMessage.fire({ type: 'error', message: `[File Watcher (nsfw)] ` + message }); + this._onDidLogMessage.fire({ type: 'error', message: `[File Watcher (nsfw)] ${message}` }); } private debug(message: string) { - this._onDidLogMessage.fire({ type: 'debug', message: `[File Watcher (nsfw)] ` + message }); + this._onDidLogMessage.fire({ type: 'debug', message: `[File Watcher (nsfw)] ${message}` }); } } diff --git a/src/vs/platform/files/node/watcher/nsfw/test/nsfwWatcherService.test.ts b/src/vs/platform/files/node/watcher/nsfw/test/nsfwWatcherService.test.ts index 99792bfa19d..1fcbb48ff53 100644 --- a/src/vs/platform/files/node/watcher/nsfw/test/nsfwWatcherService.test.ts +++ b/src/vs/platform/files/node/watcher/nsfw/test/nsfwWatcherService.test.ts @@ -4,55 +4,50 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import * as platform from 'vs/base/common/platform'; -import { IWatcherRequest } from 'vs/platform/files/node/watcher/nsfw/watcher'; +import { isWindows } from 'vs/base/common/platform'; +import { NsfwWatcherService } from 'vs/platform/files/node/watcher/nsfw/nsfwWatcherService'; +import { IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; -suite('NSFW Watcher Service', async () => { - - // Load `nsfwWatcherService` within the suite to prevent all tests - // from failing to start if `nsfw` was not properly installed - const { NsfwWatcherService } = await import('vs/platform/files/node/watcher/nsfw/nsfwWatcherService'); +suite('NSFW Watcher Service', () => { class TestNsfwWatcherService extends NsfwWatcherService { - testNormalizeRoots(roots: string[]): string[] { + testNormalizePaths(paths: string[]): string[] { // Work with strings as paths to simplify testing - const requests: IWatcherRequest[] = roots.map(r => { - return { path: r, excludes: [] }; + const requests: IWatchRequest[] = paths.map(path => { + return { path, excludes: [] }; }); - return this.normalizeRoots(requests).map(r => r.path); + return this.normalizeRequests(requests).map(request => request.path); } } - suite('_normalizeRoots', () => { - test('should not impacts roots that don\'t overlap', () => { - const service = new TestNsfwWatcherService(); - if (platform.isWindows) { - assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a']), ['C:\\a']); - assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a', 'C:\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a', 'C:\\b', 'C:\\c\\d\\e']), ['C:\\a', 'C:\\b', 'C:\\c\\d\\e']); - } else { - assert.deepStrictEqual(service.testNormalizeRoots(['/a']), ['/a']); - assert.deepStrictEqual(service.testNormalizeRoots(['/a', '/b']), ['/a', '/b']); - assert.deepStrictEqual(service.testNormalizeRoots(['/a', '/b', '/c/d/e']), ['/a', '/b', '/c/d/e']); - } - }); - - test('should remove sub-folders of other roots', () => { - const service = new TestNsfwWatcherService(); - if (platform.isWindows) { - assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a', 'C:\\a\\b']), ['C:\\a']); - assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(service.testNormalizeRoots(['C:\\b\\a', 'C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a', 'C:\\a\\b', 'C:\\a\\c\\d']), ['C:\\a']); - } else { - assert.deepStrictEqual(service.testNormalizeRoots(['/a', '/a/b']), ['/a']); - assert.deepStrictEqual(service.testNormalizeRoots(['/a', '/b', '/a/b']), ['/a', '/b']); - assert.deepStrictEqual(service.testNormalizeRoots(['/b/a', '/a', '/b', '/a/b']), ['/a', '/b']); - assert.deepStrictEqual(service.testNormalizeRoots(['/a', '/a/b', '/a/c/d']), ['/a']); - } - }); + test('should not impacts roots that do not overlap', () => { + const service = new TestNsfwWatcherService(); + if (isWindows) { + assert.deepStrictEqual(service.testNormalizePaths(['C:\\a']), ['C:\\a']); + assert.deepStrictEqual(service.testNormalizePaths(['C:\\a', 'C:\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(service.testNormalizePaths(['C:\\a', 'C:\\b', 'C:\\c\\d\\e']), ['C:\\a', 'C:\\b', 'C:\\c\\d\\e']); + } else { + assert.deepStrictEqual(service.testNormalizePaths(['/a']), ['/a']); + assert.deepStrictEqual(service.testNormalizePaths(['/a', '/b']), ['/a', '/b']); + assert.deepStrictEqual(service.testNormalizePaths(['/a', '/b', '/c/d/e']), ['/a', '/b', '/c/d/e']); + } + }); + + test('should remove sub-folders of other roots', () => { + const service = new TestNsfwWatcherService(); + if (isWindows) { + assert.deepStrictEqual(service.testNormalizePaths(['C:\\a', 'C:\\a\\b']), ['C:\\a']); + assert.deepStrictEqual(service.testNormalizePaths(['C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(service.testNormalizePaths(['C:\\b\\a', 'C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(service.testNormalizePaths(['C:\\a', 'C:\\a\\b', 'C:\\a\\c\\d']), ['C:\\a']); + } else { + assert.deepStrictEqual(service.testNormalizePaths(['/a', '/a/b']), ['/a']); + assert.deepStrictEqual(service.testNormalizePaths(['/a', '/b', '/a/b']), ['/a', '/b']); + assert.deepStrictEqual(service.testNormalizePaths(['/b/a', '/a', '/b', '/a/b']), ['/a', '/b']); + assert.deepStrictEqual(service.testNormalizePaths(['/a', '/a/b', '/a/c/d']), ['/a']); + } }); }); diff --git a/src/vs/platform/files/node/watcher/nsfw/watcher.ts b/src/vs/platform/files/node/watcher/nsfw/watcher.ts index 3fa779444fe..2f91b6c97bc 100644 --- a/src/vs/platform/files/node/watcher/nsfw/watcher.ts +++ b/src/vs/platform/files/node/watcher/nsfw/watcher.ts @@ -4,20 +4,36 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from 'vs/base/common/event'; -import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher'; - -export interface IWatcherRequest { - path: string; - excludes: string[]; -} +import { IDiskFileChange, ILogMessage, IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; export interface IWatcherService { + /** + * A normalized file change event from the raw events + * the watcher emits. + */ readonly onDidChangeFile: Event; + + /** + * An event to indicate a message that should get logged. + */ readonly onDidLogMessage: Event; - setRoots(roots: IWatcherRequest[]): Promise; + /** + * Configures the watcher service to watch according + * to the requests. Any existing watched path that + * is not in the array, will be removed from watching + * and any new path will be added to watching. + */ + watch(requests: IWatchRequest[]): Promise; + + /** + * Enable verbose logging in the watcher. + */ setVerboseLogging(enabled: boolean): Promise; + /** + * Stop all watchers. + */ stop(): Promise; } diff --git a/src/vs/platform/files/node/watcher/nsfw/watcherService.ts b/src/vs/platform/files/node/watcher/nsfw/watcherService.ts index 4cb71c5dfa9..3e8ce6c4d62 100644 --- a/src/vs/platform/files/node/watcher/nsfw/watcherService.ts +++ b/src/vs/platform/files/node/watcher/nsfw/watcherService.ts @@ -7,28 +7,26 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { FileAccess } from 'vs/base/common/network'; import { getNextTickChannel, ProxyChannel } from 'vs/base/parts/ipc/common/ipc'; import { Client } from 'vs/base/parts/ipc/node/ipc.cp'; -import { IWatcherRequest, IWatcherService } from 'vs/platform/files/node/watcher/nsfw/watcher'; -import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher'; +import { IWatcherService } from 'vs/platform/files/node/watcher/nsfw/watcher'; +import { IDiskFileChange, ILogMessage, IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; export class FileWatcher extends Disposable { private static readonly MAX_RESTARTS = 5; private service: IWatcherService | undefined; - private isDisposed: boolean; - private restartCounter: number; + + private isDisposed = false; + private restartCounter = 0; constructor( - private folders: IWatcherRequest[], + private requests: IWatchRequest[], private readonly onDidFilesChange: (changes: IDiskFileChange[]) => void, private readonly onLogMessage: (msg: ILogMessage) => void, private verboseLogging: boolean, ) { super(); - this.isDisposed = false; - this.restartCounter = 0; - this.startWatching(); } @@ -69,13 +67,14 @@ export class FileWatcher extends Disposable { this._register(this.service.onDidLogMessage(e => this.onLogMessage(e))); // Start watching - this.setFolders(this.folders); + this.watch(this.requests); } setVerboseLogging(verboseLogging: boolean): void { this.verboseLogging = verboseLogging; - if (!this.isDisposed && this.service) { - this.service.setVerboseLogging(verboseLogging); + + if (!this.isDisposed) { + this.service?.setVerboseLogging(verboseLogging); } } @@ -83,12 +82,10 @@ export class FileWatcher extends Disposable { this.onLogMessage({ type: 'error', message: `[File Watcher (nsfw)] ${message}` }); } - setFolders(folders: IWatcherRequest[]): void { - this.folders = folders; + watch(requests: IWatchRequest[]): void { + this.requests = requests; - if (this.service) { - this.service.setRoots(folders); - } + this.service?.watch(requests); } override dispose(): void { diff --git a/src/vs/platform/files/node/watcher/unix/chokidarWatcherService.ts b/src/vs/platform/files/node/watcher/unix/chokidarWatcherService.ts index 3f488271db0..b48b8fc76d0 100644 --- a/src/vs/platform/files/node/watcher/unix/chokidarWatcherService.ts +++ b/src/vs/platform/files/node/watcher/unix/chokidarWatcherService.ts @@ -16,8 +16,8 @@ import { normalizeNFC } from 'vs/base/common/normalization'; import { isLinux, isMacintosh } from 'vs/base/common/platform'; import { realcaseSync } from 'vs/base/node/extpath'; import { FileChangeType } from 'vs/platform/files/common/files'; -import { IWatcherOptions, IWatcherRequest, IWatcherService } from 'vs/platform/files/node/watcher/unix/watcher'; -import { IDiskFileChange, ILogMessage, normalizeFileChanges } from 'vs/platform/files/node/watcher/watcher'; +import { IWatcherOptions, IWatcherService } from 'vs/platform/files/node/watcher/unix/watcher'; +import { IDiskFileChange, ILogMessage, IWatchRequest, normalizeFileChanges } from 'vs/platform/files/node/watcher/watcher'; gracefulFs.gracefulify(fs); // enable gracefulFs @@ -28,7 +28,7 @@ interface IWatcher { stop(): Promise; } -interface ExtendedWatcherRequest extends IWatcherRequest { +interface ExtendedWatcherRequest extends IWatchRequest { parsedPattern?: ParsedPattern; } @@ -68,7 +68,7 @@ export class ChokidarWatcherService extends Disposable implements IWatcherServic this.verboseLogging = enabled; } - async setRoots(requests: IWatcherRequest[]): Promise { + async watch(requests: IWatchRequest[]): Promise { const watchers = new Map(); const newRequests: string[] = []; @@ -93,13 +93,13 @@ export class ChokidarWatcherService extends Disposable implements IWatcherServic // start all new watchers for (const basePath of newRequests) { const requests = requestsByBasePath[basePath]; - watchers.set(basePath, this.watch(basePath, requests)); + watchers.set(basePath, this.doWatch(basePath, requests)); } this.watchers = watchers; } - private watch(basePath: string, requests: IWatcherRequest[]): IWatcher { + private doWatch(basePath: string, requests: IWatchRequest[]): IWatcher { const pollingInterval = this.pollingInterval || 5000; let usePolling = this.usePolling; // boolean or a list of path patterns if (Array.isArray(usePolling)) { @@ -344,11 +344,11 @@ function isIgnored(path: string, requests: ExtendedWatcherRequest[]): boolean { * Normalizes a set of root paths by grouping by the most parent root path. * equests with Sub paths are skipped if they have the same ignored set as the parent. */ -export function normalizeRoots(requests: IWatcherRequest[]): { [basePath: string]: IWatcherRequest[] } { +export function normalizeRoots(requests: IWatchRequest[]): { [basePath: string]: IWatchRequest[] } { requests = requests.sort((r1, r2) => r1.path.localeCompare(r2.path)); - let prevRequest: IWatcherRequest | null = null; - const result: { [basePath: string]: IWatcherRequest[] } = Object.create(null); + let prevRequest: IWatchRequest | null = null; + const result: { [basePath: string]: IWatchRequest[] } = Object.create(null); for (const request of requests) { const basePath = request.path; const ignored = (request.excludes || []).sort(); @@ -365,7 +365,7 @@ export function normalizeRoots(requests: IWatcherRequest[]): { [basePath: string return result; } -function isEqualRequests(r1: readonly IWatcherRequest[], r2: readonly IWatcherRequest[]) { +function isEqualRequests(r1: readonly IWatchRequest[], r2: readonly IWatchRequest[]) { return equals(r1, r2, (a, b) => a.path === b.path && isEqualIgnore(a.excludes, b.excludes)); } diff --git a/src/vs/platform/files/node/watcher/unix/test/chockidarWatcherService.test.ts b/src/vs/platform/files/node/watcher/unix/test/chockidarWatcherService.test.ts index 8b475de9a0b..1131a8a450f 100644 --- a/src/vs/platform/files/node/watcher/unix/test/chockidarWatcherService.test.ts +++ b/src/vs/platform/files/node/watcher/unix/test/chockidarWatcherService.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import * as platform from 'vs/base/common/platform'; -import { IWatcherRequest } from 'vs/platform/files/node/watcher/unix/watcher'; +import { IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; suite('Chokidar normalizeRoots', async () => { @@ -13,7 +13,7 @@ suite('Chokidar normalizeRoots', async () => { // from failing to start if `chokidar` was not properly installed const { normalizeRoots } = await import('vs/platform/files/node/watcher/unix/chokidarWatcherService'); - function newRequest(basePath: string, ignored: string[] = []): IWatcherRequest { + function newRequest(basePath: string, ignored: string[] = []): IWatchRequest { return { path: basePath, excludes: ignored }; } @@ -23,7 +23,7 @@ suite('Chokidar normalizeRoots', async () => { assert.deepStrictEqual(Object.keys(actual).sort(), expectedPaths); } - function assertNormalizedRequests(inputRequests: IWatcherRequest[], expectedRequests: { [path: string]: IWatcherRequest[] }) { + function assertNormalizedRequests(inputRequests: IWatchRequest[], expectedRequests: { [path: string]: IWatchRequest[] }) { const actual = normalizeRoots(inputRequests); const actualPath = Object.keys(actual).sort(); const expectedPaths = Object.keys(expectedRequests).sort(); diff --git a/src/vs/platform/files/node/watcher/unix/watcher.ts b/src/vs/platform/files/node/watcher/unix/watcher.ts index 1e4ec9eccaa..7c67406f1c7 100644 --- a/src/vs/platform/files/node/watcher/unix/watcher.ts +++ b/src/vs/platform/files/node/watcher/unix/watcher.ts @@ -4,12 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from 'vs/base/common/event'; -import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher'; - -export interface IWatcherRequest { - path: string; - excludes: string[]; -} +import { IDiskFileChange, ILogMessage, IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; export interface IWatcherOptions { pollingInterval?: number; @@ -24,7 +19,7 @@ export interface IWatcherService { init(options: IWatcherOptions): Promise; - setRoots(roots: IWatcherRequest[]): Promise; + watch(paths: IWatchRequest[]): Promise; setVerboseLogging(enabled: boolean): Promise; stop(): Promise; diff --git a/src/vs/platform/files/node/watcher/unix/watcherService.ts b/src/vs/platform/files/node/watcher/unix/watcherService.ts index b7bd86d8423..ddc8858ddef 100644 --- a/src/vs/platform/files/node/watcher/unix/watcherService.ts +++ b/src/vs/platform/files/node/watcher/unix/watcherService.ts @@ -7,9 +7,12 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { FileAccess } from 'vs/base/common/network'; import { getNextTickChannel, ProxyChannel } from 'vs/base/parts/ipc/common/ipc'; import { Client } from 'vs/base/parts/ipc/node/ipc.cp'; -import { IWatcherOptions, IWatcherRequest, IWatcherService } from 'vs/platform/files/node/watcher/unix/watcher'; -import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher'; +import { IWatcherOptions, IWatcherService } from 'vs/platform/files/node/watcher/unix/watcher'; +import { IDiskFileChange, ILogMessage, IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; +/** + * @deprecated + */ export class FileWatcher extends Disposable { private static readonly MAX_RESTARTS = 5; @@ -19,7 +22,7 @@ export class FileWatcher extends Disposable { private service: IWatcherService | undefined; constructor( - private folders: IWatcherRequest[], + private folders: IWatchRequest[], private readonly onDidFilesChange: (changes: IDiskFileChange[]) => void, private readonly onLogMessage: (msg: ILogMessage) => void, private verboseLogging: boolean, @@ -69,7 +72,7 @@ export class FileWatcher extends Disposable { this._register(this.service.onDidLogMessage(e => this.onLogMessage(e))); // Start watching - this.service.setRoots(this.folders); + this.service.watch(this.folders); } error(message: string) { @@ -84,11 +87,11 @@ export class FileWatcher extends Disposable { } } - setFolders(folders: IWatcherRequest[]): void { + watch(folders: IWatchRequest[]): void { this.folders = folders; if (this.service) { - this.service.setRoots(folders); + this.service.watch(folders); } } diff --git a/src/vs/platform/files/node/watcher/watcher.ts b/src/vs/platform/files/node/watcher/watcher.ts index c25040869b6..7e99c9f59e8 100644 --- a/src/vs/platform/files/node/watcher/watcher.ts +++ b/src/vs/platform/files/node/watcher/watcher.ts @@ -7,6 +7,19 @@ import { isLinux } from 'vs/base/common/platform'; import { URI as uri } from 'vs/base/common/uri'; import { FileChangeType, IFileChange, isParent } from 'vs/platform/files/common/files'; +export interface IWatchRequest { + + /** + * The path to watch. + */ + path: string; + + /** + * A set of glob patterns or paths to exclude from watching. + */ + excludes: string[]; +} + export interface IDiskFileChange { type: FileChangeType; path: string; diff --git a/src/vs/platform/files/node/watcher/win32/watcherService.ts b/src/vs/platform/files/node/watcher/win32/watcherService.ts index 589a78a7241..9cb11a41bba 100644 --- a/src/vs/platform/files/node/watcher/win32/watcherService.ts +++ b/src/vs/platform/files/node/watcher/win32/watcherService.ts @@ -6,16 +6,19 @@ import { IDisposable } from 'vs/base/common/lifecycle'; import { posix } from 'vs/base/common/path'; import { rtrim } from 'vs/base/common/strings'; -import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher'; +import { IDiskFileChange, ILogMessage, IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; import { OutOfProcessWin32FolderWatcher } from 'vs/platform/files/node/watcher/win32/csharpWatcherService'; +/** + * @deprecated + */ export class FileWatcher implements IDisposable { - private folder: { path: string, excludes: string[] }; + private folder: IWatchRequest; private service: OutOfProcessWin32FolderWatcher | undefined = undefined; constructor( - folders: { path: string, excludes: string[] }[], + folders: IWatchRequest[], private readonly onDidFilesChange: (changes: IDiskFileChange[]) => void, private readonly onLogMessage: (msg: ILogMessage) => void, private verboseLogging: boolean diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index 9ecdee4acc4..6f0e3a864c7 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -245,7 +245,16 @@ configurationRegistry.registerConfiguration({ 'files.watcherExclude': { 'type': 'object', 'default': { '**/.git/objects/**': true, '**/.git/subtree-cache/**': true, '**/node_modules/*/**': true, '**/.hg/store/**': true }, - 'markdownDescription': nls.localize('watcherExclude', "Configure glob patterns of file paths to exclude from file watching. Patterns must match on absolute paths, i.e. prefix with `**/` or the full path to match properly and suffix with `/**` to match files within a path (for example `**/build/output/**` or `/Users/name/workspaces/project/build/output/**`). Changing this setting requires a restart. When you experience Code consuming lots of CPU time on startup, you can exclude large folders to reduce the initial load."), + 'markdownDescription': nls.localize('watcherExclude', "Configure glob patterns of file paths to exclude from file watching. Patterns must match on absolute paths, i.e. prefix with `**/` or the full path to match properly and suffix with `/**` to match files within a path (for example `**/build/output/**` or `/Users/name/workspaces/project/build/output/**`). When you experience Code consuming lots of CPU time on startup, you can exclude large folders to reduce the initial load."), + 'scope': ConfigurationScope.RESOURCE + }, + 'files.watcherInclude': { + 'type': 'array', + 'items': { + 'type': 'string' + }, + 'default': [], + 'description': nls.localize('watcherInclude', "Configure extra paths to watch for changes inside the workspace. By default, all workspace folders will be watched recursively, except for folders that are symbolic links. You can explicitly add absolute or relative paths to support watching folders that are symbolic links. Relative paths will be resolved against the workspace folder to form an absolute path."), 'scope': ConfigurationScope.RESOURCE }, 'files.legacyWatcher': { diff --git a/src/vs/workbench/contrib/files/common/workspaceWatcher.ts b/src/vs/workbench/contrib/files/common/workspaceWatcher.ts index 7f6ec9c6384..abfe74819c0 100644 --- a/src/vs/workbench/contrib/files/common/workspaceWatcher.ts +++ b/src/vs/workbench/contrib/files/common/workspaceWatcher.ts @@ -3,17 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IDisposable, Disposable, dispose } from 'vs/base/common/lifecycle'; +import { IDisposable, Disposable, dispose, DisposableStore } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration'; import { IFilesConfiguration, IFileService } from 'vs/platform/files/common/files'; -import { IWorkspaceContextService, IWorkspaceFoldersChangeEvent } from 'vs/platform/workspace/common/workspace'; +import { IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent } from 'vs/platform/workspace/common/workspace'; import { ResourceMap } from 'vs/base/common/map'; import { onUnexpectedError } from 'vs/base/common/errors'; import { INotificationService, Severity, NeverShowAgainScope } from 'vs/platform/notification/common/notification'; import { localize } from 'vs/nls'; import { FileService } from 'vs/platform/files/common/fileService'; import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { isAbsolute } from 'vs/base/common/path'; +import { isEqualOrParent } from 'vs/base/common/resources'; +import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; export class WorkspaceWatcher extends Disposable { @@ -24,7 +27,8 @@ export class WorkspaceWatcher extends Disposable { @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @INotificationService private readonly notificationService: INotificationService, - @IOpenerService private readonly openerService: IOpenerService + @IOpenerService private readonly openerService: IOpenerService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService ) { super(); @@ -44,12 +48,12 @@ export class WorkspaceWatcher extends Disposable { // Removed workspace: Unwatch for (const removed of e.removed) { - this.unwatchWorkspace(removed.uri); + this.unwatchWorkspace(removed); } // Added workspace: Watch for (const added of e.added) { - this.watchWorkspace(added.uri); + this.watchWorkspace(added); } } @@ -58,7 +62,7 @@ export class WorkspaceWatcher extends Disposable { } private onDidChangeConfiguration(e: IConfigurationChangeEvent): void { - if (e.affectsConfiguration('files.watcherExclude')) { + if (e.affectsConfiguration('files.watcherExclude') || e.affectsConfiguration('files.watcherInclude')) { this.refresh(); } } @@ -102,11 +106,11 @@ export class WorkspaceWatcher extends Disposable { } } - private watchWorkspace(resource: URI) { + private watchWorkspace(workspace: IWorkspaceFolder): void { // Compute the watcher exclude rules from configuration const excludes: string[] = []; - const config = this.configurationService.getValue({ resource }); + const config = this.configurationService.getValue({ resource: workspace.uri }); if (config.files?.watcherExclude) { for (const key in config.files.watcherExclude) { if (config.files.watcherExclude[key] === true) { @@ -115,15 +119,46 @@ export class WorkspaceWatcher extends Disposable { } } - // Watch workspace - const disposable = this.fileService.watch(resource, { recursive: true, excludes }); - this.watches.set(resource, disposable); + const pathsToWatch = new ResourceMap(uri => this.uriIdentityService.extUri.getComparisonKey(uri)); + + // Add the workspace as path to watch + pathsToWatch.set(workspace.uri, workspace.uri); + + // Compute additional includes from configuration + if (config.files?.watcherInclude) { + for (const includePath of config.files.watcherInclude) { + if (!includePath) { + continue; + } + + // Absolute: verify a child of the workspace + if (isAbsolute(includePath)) { + const candidate = URI.file(includePath).with({ scheme: workspace.uri.scheme }); + if (isEqualOrParent(candidate, workspace.uri)) { + pathsToWatch.set(candidate, candidate); + } + } + + // Relative: join against workspace folder + else { + const candidate = workspace.toResource(includePath); + pathsToWatch.set(candidate, candidate); + } + } + } + + // Watch all paths as instructed + const disposables = new DisposableStore(); + for (const [, pathToWatch] of pathsToWatch) { + disposables.add(this.fileService.watch(pathToWatch, { recursive: true, excludes })); + } + this.watches.set(workspace.uri, disposables); } - private unwatchWorkspace(resource: URI) { - if (this.watches.has(resource)) { - dispose(this.watches.get(resource)); - this.watches.delete(resource); + private unwatchWorkspace(workspace: IWorkspaceFolder): void { + if (this.watches.has(workspace.uri)) { + dispose(this.watches.get(workspace.uri)); + this.watches.delete(workspace.uri); } } @@ -134,11 +169,11 @@ export class WorkspaceWatcher extends Disposable { // Watch each workspace folder for (const folder of this.contextService.getWorkspace().folders) { - this.watchWorkspace(folder.uri); + this.watchWorkspace(folder); } } - private unwatchWorkspaces() { + private unwatchWorkspaces(): void { this.watches.forEach(disposable => dispose(disposable)); this.watches.clear(); } -- GitLab