watcher - introduce `files.watcherInclude` for explicit watching of folders (#132483)

上级 4564730b
......@@ -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;
......
......@@ -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<void>(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,
......
......@@ -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<IWatcher>;
watcher?: IWatcher;
/**
* The NSFW instance is resolved when the watching has started.
*/
readonly instance: Promise<nsfw.NSFW>;
/**
* 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<number, number>(
[
[nsfw.actions.CREATED, FileChangeType.ADDED],
[nsfw.actions.MODIFIED, FileChangeType.UPDATED],
[nsfw.actions.DELETED, FileChangeType.DELETED],
]
);
private readonly _onDidChangeFile = this._register(new Emitter<IDiskFileChange[]>());
readonly onDidChangeFile = this._onDidChangeFile.event;
private readonly _onDidLogMessage = this._register(new Emitter<ILogMessage>());
readonly onDidLogMessage = this._onDidLogMessage.event;
private pathWatchers: { [watchPath: string]: IPathWatcher } = {};
private verboseLogging: boolean | undefined;
private enospcErrorLogged: boolean | undefined;
private readonly watchers = new Map<string, IWatcher>();
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<void> {
const normalizedRoots = this.normalizeRoots(roots);
async watch(requests: IWatchRequest[]): Promise<void> {
// 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<IWatcher>(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<nsfw.NSFW>(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<void>(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<void>(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<void> {
this.verboseLogging = enabled;
async stop(): Promise<void> {
for (const [path] of this.watchers) {
this.stopWatching(path);
}
this.watchers.clear();
}
async stop(): Promise<void> {
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<IWatchRequest>();
// 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<void> {
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}` });
}
}
......@@ -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']);
}
});
});
......@@ -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<IDiskFileChange[]>;
/**
* An event to indicate a message that should get logged.
*/
readonly onDidLogMessage: Event<ILogMessage>;
setRoots(roots: IWatcherRequest[]): Promise<void>;
/**
* 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<void>;
/**
* Enable verbose logging in the watcher.
*/
setVerboseLogging(enabled: boolean): Promise<void>;
/**
* Stop all watchers.
*/
stop(): Promise<void>;
}
......@@ -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 {
......
......@@ -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<void>;
}
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<void> {
async watch(requests: IWatchRequest[]): Promise<void> {
const watchers = new Map<string, IWatcher>();
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));
}
......
......@@ -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();
......
......@@ -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<void>;
setRoots(roots: IWatcherRequest[]): Promise<void>;
watch(paths: IWatchRequest[]): Promise<void>;
setVerboseLogging(enabled: boolean): Promise<void>;
stop(): Promise<void>;
......
......@@ -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);
}
}
......
......@@ -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;
......
......@@ -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
......
......@@ -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': {
......
......@@ -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<IFilesConfiguration>({ resource });
const config = this.configurationService.getValue<IFilesConfiguration>({ 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>(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();
}
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册