diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 1f96c1e533c5d..94f8e3ac5db6c 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -8,6 +8,7 @@ "authSession", "contribViewsRemote", "contribStatusBarItems", + "createFileSystemWatcher", "customEditorMove", "diffCommand", "documentFiltersExclusive", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/workspace.watcher.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/workspace.watcher.test.ts index 13041ce380b3c..7859ae406820f 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/workspace.watcher.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/workspace.watcher.test.ts @@ -27,19 +27,28 @@ suite('vscode API - workspace-watcher', () => { } } - teardown(assertNoRpc); + let fs: WatcherTestFs; + let disposable: vscode.Disposable; - test('createFileSystemWatcher', async function () { - const fs = new WatcherTestFs('watcherTest', false); - vscode.workspace.registerFileSystemProvider('watcherTest', fs); + function onDidWatchPromise() { + const onDidWatchPromise = new Promise(resolve => { + fs.onDidWatch(request => resolve(request)); + }); - function onDidWatchPromise() { - const onDidWatchPromise = new Promise(resolve => { - fs.onDidWatch(request => resolve(request)); - }); + return onDidWatchPromise; + } - return onDidWatchPromise; - } + setup(() => { + fs = new WatcherTestFs('watcherTest', false); + disposable = vscode.workspace.registerFileSystemProvider('watcherTest', fs); + }); + + teardown(() => { + disposable.dispose(); + assertNoRpc(); + }); + + test('createFileSystemWatcher (old style)', async function () { // Non-recursive let watchUri = vscode.Uri.from({ scheme: 'watcherTest', path: '/somePath/folder' }); @@ -59,4 +68,29 @@ suite('vscode API - workspace-watcher', () => { assert.strictEqual(request.uri.toString(), watchUri.toString()); assert.strictEqual(request.options.recursive, true); }); + + test('createFileSystemWatcher (new style)', async function () { + + // Non-recursive + let watchUri = vscode.Uri.from({ scheme: 'watcherTest', path: '/somePath/folder' }); + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(watchUri, '*.txt'), { excludes: ['testing'], ignoreChangeEvents: true }); + let request = await onDidWatchPromise(); + + assert.strictEqual(request.uri.toString(), watchUri.toString()); + assert.strictEqual(request.options.recursive, false); + assert.strictEqual(request.options.excludes.length, 1); + assert.strictEqual(request.options.excludes[0], 'testing'); + + watcher.dispose(); + + // Recursive + watchUri = vscode.Uri.from({ scheme: 'watcherTest', path: '/somePath/folder' }); + vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(watchUri, '**/*.txt'), { excludes: ['testing'], ignoreCreateEvents: true }); + request = await onDidWatchPromise(); + + assert.strictEqual(request.uri.toString(), watchUri.toString()); + assert.strictEqual(request.options.recursive, true); + assert.strictEqual(request.options.excludes.length, 1); + assert.strictEqual(request.options.excludes[0], 'testing'); + }); }); diff --git a/src/vs/platform/files/common/diskFileSystemProvider.ts b/src/vs/platform/files/common/diskFileSystemProvider.ts index 374f7c9bcf57c..5f5b623201b0d 100644 --- a/src/vs/platform/files/common/diskFileSystemProvider.ts +++ b/src/vs/platform/files/common/diskFileSystemProvider.ts @@ -11,7 +11,7 @@ import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle' import { normalize } from 'vs/base/common/path'; import { URI } from 'vs/base/common/uri'; import { IFileChange, IFileSystemProvider, IWatchOptions } from 'vs/platform/files/common/files'; -import { AbstractNonRecursiveWatcherClient, AbstractUniversalWatcherClient, IDiskFileChange, ILogMessage, INonRecursiveWatchRequest, IRecursiveWatcherOptions, isRecursiveWatchRequest, IUniversalWatchRequest, toFileChanges } from 'vs/platform/files/common/watcher'; +import { AbstractNonRecursiveWatcherClient, AbstractUniversalWatcherClient, ILogMessage, INonRecursiveWatchRequest, IRecursiveWatcherOptions, isRecursiveWatchRequest, IUniversalWatchRequest, reviveFileChanges } from 'vs/platform/files/common/watcher'; import { ILogService, LogLevel } from 'vs/platform/log/common/log'; export interface IDiskFileSystemProviderOptions { @@ -72,7 +72,7 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen private watchUniversal(resource: URI, opts: IWatchOptions): IDisposable { // Add to list of paths to watch universally - const pathToWatch: IUniversalWatchRequest = { path: this.toFilePath(resource), excludes: opts.excludes, includes: opts.includes, recursive: opts.recursive }; + const pathToWatch: IUniversalWatchRequest = { path: this.toFilePath(resource), excludes: opts.excludes, includes: opts.includes, recursive: opts.recursive, correlationId: opts.correlationId }; const remove = insert(this.universalPathsToWatch, pathToWatch); // Trigger update @@ -102,7 +102,7 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen // Create watcher if this is the first time if (!this.universalWatcher) { this.universalWatcher = this._register(this.createUniversalWatcher( - changes => this._onDidChangeFile.fire(toFileChanges(changes)), + changes => this._onDidChangeFile.fire(reviveFileChanges(changes)), msg => this.onWatcherLogMessage(msg), this.logService.getLevel() === LogLevel.Trace )); @@ -136,7 +136,7 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen } protected abstract createUniversalWatcher( - onChange: (changes: IDiskFileChange[]) => void, + onChange: (changes: IFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean ): AbstractUniversalWatcherClient; @@ -153,7 +153,7 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen private watchNonRecursive(resource: URI, opts: IWatchOptions): IDisposable { // Add to list of paths to watch non-recursively - const pathToWatch: INonRecursiveWatchRequest = { path: this.toFilePath(resource), excludes: opts.excludes, includes: opts.includes, recursive: false }; + const pathToWatch: INonRecursiveWatchRequest = { path: this.toFilePath(resource), excludes: opts.excludes, includes: opts.includes, recursive: false, correlationId: opts.correlationId }; const remove = insert(this.nonRecursivePathsToWatch, pathToWatch); // Trigger update @@ -183,7 +183,7 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen // Create watcher if this is the first time if (!this.nonRecursiveWatcher) { this.nonRecursiveWatcher = this._register(this.createNonRecursiveWatcher( - changes => this._onDidChangeFile.fire(toFileChanges(changes)), + changes => this._onDidChangeFile.fire(reviveFileChanges(changes)), msg => this.onWatcherLogMessage(msg), this.logService.getLevel() === LogLevel.Trace )); @@ -199,7 +199,7 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen } protected abstract createNonRecursiveWatcher( - onChange: (changes: IDiskFileChange[]) => void, + onChange: (changes: IFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean ): AbstractNonRecursiveWatcherClient; diff --git a/src/vs/platform/files/common/diskFileSystemProviderClient.ts b/src/vs/platform/files/common/diskFileSystemProviderClient.ts index d3719ddd0e784..d7f38517446a9 100644 --- a/src/vs/platform/files/common/diskFileSystemProviderClient.ts +++ b/src/vs/platform/files/common/diskFileSystemProviderClient.ts @@ -10,10 +10,11 @@ import { canceled } from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { newWriteableStream, ReadableStreamEventPayload, ReadableStreamEvents } from 'vs/base/common/stream'; -import { URI, UriComponents } from 'vs/base/common/uri'; +import { URI } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; import { IChannel } from 'vs/base/parts/ipc/common/ipc'; -import { createFileSystemProviderError, IFileAtomicReadOptions, FileChangeType, IFileDeleteOptions, IFileOpenOptions, IFileOverwriteOptions, IFileReadStreamOptions, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileChange, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileCloneCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IStat, IWatchOptions, IFileSystemProviderError } from 'vs/platform/files/common/files'; +import { createFileSystemProviderError, IFileAtomicReadOptions, IFileDeleteOptions, IFileOpenOptions, IFileOverwriteOptions, IFileReadStreamOptions, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileChange, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileCloneCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IStat, IWatchOptions, IFileSystemProviderError } from 'vs/platform/files/common/files'; +import { reviveFileChanges } from 'vs/platform/files/common/watcher'; export const LOCAL_FILE_SYSTEM_CHANNEL_NAME = 'localFilesystem'; @@ -229,10 +230,10 @@ export class DiskFileSystemProviderClient extends Disposable implements // for both events and errors from the watcher. So we need to // unwrap the event from the remote and emit through the proper // emitter. - this._register(this.channel.listen<{ resource: UriComponents; type: FileChangeType }[] | string>('fileChange', [this.sessionId])(eventsOrError => { + this._register(this.channel.listen('fileChange', [this.sessionId])(eventsOrError => { if (Array.isArray(eventsOrError)) { const events = eventsOrError; - this._onDidChange.fire(events.map(event => ({ resource: URI.revive(event.resource), type: event.type }))); + this._onDidChange.fire(reviveFileChanges(events)); } else { const error = eventsOrError; this._onDidWatchError.fire(error); diff --git a/src/vs/platform/files/common/fileService.ts b/src/vs/platform/files/common/fileService.ts index e17e76ee5fb5c..791185e9a881a 100644 --- a/src/vs/platform/files/common/fileService.ts +++ b/src/vs/platform/files/common/fileService.ts @@ -18,7 +18,7 @@ import { extUri, extUriIgnorePathCase, IExtUri, isAbsolutePath } from 'vs/base/c import { consumeStream, isReadableBufferedStream, isReadableStream, listenStream, newWriteableStream, peekReadable, peekStream, transform } from 'vs/base/common/stream'; import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; -import { ensureFileSystemProviderError, etag, ETAG_DISABLED, FileChangesEvent, IFileDeleteOptions, FileOperation, FileOperationError, FileOperationEvent, FileOperationResult, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, hasFileAtomicReadCapability, hasFileFolderCopyCapability, hasFileReadStreamCapability, hasOpenReadWriteCloseCapability, hasReadWriteCapability, ICreateFileOptions, IFileContent, IFileService, IFileStat, IFileStatWithMetadata, IFileStreamContent, IFileSystemProvider, IFileSystemProviderActivationEvent, IFileSystemProviderCapabilitiesChangeEvent, IFileSystemProviderRegistrationEvent, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IReadFileOptions, IReadFileStreamOptions, IResolveFileOptions, IFileStatResult, IFileStatResultWithMetadata, IResolveMetadataFileOptions, IStat, IFileStatWithPartialMetadata, IWatchOptions, IWriteFileOptions, NotModifiedSinceFileOperationError, toFileOperationResult, toFileSystemProviderErrorCode, hasFileCloneCapability, TooLargeFileOperationError, hasFileAtomicDeleteCapability, hasFileAtomicWriteCapability } from 'vs/platform/files/common/files'; +import { ensureFileSystemProviderError, etag, ETAG_DISABLED, FileChangesEvent, IFileDeleteOptions, FileOperation, FileOperationError, FileOperationEvent, FileOperationResult, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, hasFileAtomicReadCapability, hasFileFolderCopyCapability, hasFileReadStreamCapability, hasOpenReadWriteCloseCapability, hasReadWriteCapability, ICreateFileOptions, IFileContent, IFileService, IFileStat, IFileStatWithMetadata, IFileStreamContent, IFileSystemProvider, IFileSystemProviderActivationEvent, IFileSystemProviderCapabilitiesChangeEvent, IFileSystemProviderRegistrationEvent, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IReadFileOptions, IReadFileStreamOptions, IResolveFileOptions, IFileStatResult, IFileStatResultWithMetadata, IResolveMetadataFileOptions, IStat, IFileStatWithPartialMetadata, IWatchOptions, IWriteFileOptions, NotModifiedSinceFileOperationError, toFileOperationResult, toFileSystemProviderErrorCode, hasFileCloneCapability, TooLargeFileOperationError, hasFileAtomicDeleteCapability, hasFileAtomicWriteCapability, IWatchOptionsWithCorrelation, IFileSystemWatcher, IWatchOptionsWithoutCorrelation } from 'vs/platform/files/common/files'; import { readFileIntoStream } from 'vs/platform/files/common/io'; import { ILogService } from 'vs/platform/log/common/log'; import { ErrorNoTelemetry } from 'vs/base/common/errors'; @@ -63,7 +63,17 @@ export class FileService extends Disposable implements IFileService { this._onDidChangeFileSystemProviderRegistrations.fire({ added: true, scheme, provider }); // Forward events from provider - providerDisposables.add(provider.onDidChangeFile(changes => this._onDidFilesChange.fire(new FileChangesEvent(changes, !this.isPathCaseSensitive(provider))))); + providerDisposables.add(provider.onDidChangeFile(changes => { + const event = new FileChangesEvent(changes, !this.isPathCaseSensitive(provider)); + + // Always emit any event internally + this.internalOnDidFilesChange.fire(event); + + // Only emit uncorrelated events in the global `onDidFilesChange` event + if (!event.hasCorrelation()) { + this._onDidUncorrelatedFilesChange.fire(event); + } + })); if (typeof provider.onDidWatchError === 'function') { providerDisposables.add(provider.onDidWatchError(error => this._onDidWatchError.fire(new Error(error)))); } @@ -1094,15 +1104,31 @@ export class FileService extends Disposable implements IFileService { //#region File Watching - private readonly _onDidFilesChange = this._register(new Emitter()); - readonly onDidFilesChange = this._onDidFilesChange.event; + private readonly internalOnDidFilesChange = this._register(new Emitter()); + + private readonly _onDidUncorrelatedFilesChange = this._register(new Emitter()); + readonly onDidFilesChange = this._onDidUncorrelatedFilesChange.event; // global `onDidFilesChange` skips correlated events private readonly _onDidWatchError = this._register(new Emitter()); readonly onDidWatchError = this._onDidWatchError.event; private readonly activeWatchers = new Map(); - watch(resource: URI, options: IWatchOptions = { recursive: false, excludes: [] }): IDisposable { + private static WATCHER_CORRELATION_IDS = 0; + + createWatcher(resource: URI, options: IWatchOptionsWithoutCorrelation): IFileSystemWatcher { + return this.watch(resource, { + ...options, + // Explicitly set a correlation id so that file events that originate + // from requests from extensions are exclusively routed back to the + // extension host and not into the workbench. + correlationId: FileService.WATCHER_CORRELATION_IDS++ + }); + } + + watch(resource: URI, options: IWatchOptionsWithCorrelation): IFileSystemWatcher; + watch(resource: URI, options?: IWatchOptionsWithoutCorrelation): IDisposable; + watch(resource: URI, options: IWatchOptions = { recursive: false, excludes: [] }): IFileSystemWatcher | IDisposable { const disposables = new DisposableStore(); // Forward watch request to provider and wire in disposables @@ -1125,6 +1151,25 @@ export class FileService extends Disposable implements IFileService { } })(); + // When a correlation identifier is set, return a specific + // watcher that only emits events matching that correalation. + const correlationId = options.correlationId; + if (typeof correlationId === 'number') { + const fileChangeEmitter = disposables.add(new Emitter()); + disposables.add(this.internalOnDidFilesChange.event(e => { + if (e.correlates(correlationId)) { + fileChangeEmitter.fire(e); + } + })); + + const watcher: IFileSystemWatcher = { + onDidChange: fileChangeEmitter.event, + dispose: () => disposables.dispose() + }; + + return watcher; + } + return disposables; } diff --git a/src/vs/platform/files/common/files.ts b/src/vs/platform/files/common/files.ts index 0913cb6eaa85b..ef1f5896b6413 100644 --- a/src/vs/platform/files/common/files.ts +++ b/src/vs/platform/files/common/files.ts @@ -19,6 +19,7 @@ import { createDecorator } from 'vs/platform/instantiation/common/instantiation' import { isWeb } from 'vs/base/common/platform'; import { Schemas } from 'vs/base/common/network'; import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { Lazy } from 'vs/base/common/lazy'; //#region file service & providers @@ -237,10 +238,27 @@ export interface IFileService { /** * Allows to start a watcher that reports file/folder change events on the provided resource. * - * Note: recursive file watching is not supported from this method. Only events from files - * that are direct children of the provided resource will be reported. + * The watcher runs correlated and thus, file events will be reported on the returned + * `IFileSystemWatcher` and not on the generic `IFileService.onDidFilesChange` event. */ - watch(resource: URI, options?: IWatchOptions): IDisposable; + createWatcher(resource: URI, options: IWatchOptionsWithoutCorrelation): IFileSystemWatcher; + + /** + * Allows to start a watcher that reports file/folder change events on the provided resource. + * + * The watcher runs correlated and thus, file events will be reported on the returned + * `IFileSystemWatcher` and not on the generic `IFileService.onDidFilesChange` event. + */ + watch(resource: URI, options: IWatchOptionsWithCorrelation): IFileSystemWatcher; + + /** + * Allows to start a watcher that reports file/folder change events on the provided resource. + * + * The watcher runs uncorrelated and thus will report all events from `IFileService.onDidFilesChange`. + * This means, most listeners in the application will receive your events. It is encouraged to + * use correlated watchers (via `IWatchOptionsWithCorrelation`) to limit events to your listener. + */ + watch(resource: URI, options?: IWatchOptionsWithoutCorrelation): IDisposable; /** * Frees up any resources occupied by this service. @@ -484,13 +502,13 @@ export interface IStat { readonly permissions?: FilePermission; } -export interface IWatchOptions { +export interface IWatchOptionsWithoutCorrelation { /** * Set to `true` to watch for changes recursively in a folder * and all of its children. */ - readonly recursive: boolean; + recursive: boolean; /** * A set of glob patterns or paths to exclude from watching. @@ -511,6 +529,36 @@ export interface IWatchOptions { includes?: Array; } +export interface IWatchOptions extends IWatchOptionsWithoutCorrelation { + + /** + * If provided, file change events from the watcher that + * are a result of this watch request will carry the same + * id. + */ + readonly correlationId?: number; +} + +export interface IWatchOptionsWithCorrelation extends IWatchOptions { + readonly correlationId: number; +} + +export interface IFileSystemWatcher extends IDisposable { + + /** + * An event which fires on file/folder change only for changes + * that correlate to the watch request with matching correlation + * identifier. + */ + readonly onDidChange: Event; +} + +export function isFileSystemWatcher(thing: unknown): thing is IFileSystemWatcher { + const candidate = thing as IFileSystemWatcher | undefined; + + return !!candidate && typeof candidate.onDidChange === 'function'; +} + export const enum FileSystemProviderCapabilities { /** @@ -882,32 +930,32 @@ export interface IFileChange { /** * The type of change that occurred to the file. */ - readonly type: FileChangeType; + type: FileChangeType; /** * The unified resource identifier of the file that changed. */ readonly resource: URI; + + /** + * If provided when starting the file watcher, the correlation + * identifier will match the original file watching request as + * a way to identify the original component that is interested + * in the change. + */ + readonly cId?: number; } export class FileChangesEvent { - private readonly added: TernarySearchTree | undefined = undefined; - private readonly updated: TernarySearchTree | undefined = undefined; - private readonly deleted: TernarySearchTree | undefined = undefined; + private static readonly MIXED_CORRELATION = null; - constructor(changes: readonly IFileChange[], ignorePathCasing: boolean) { - - const entriesByType = new Map(); + private readonly correlationId: number | undefined | typeof FileChangesEvent.MIXED_CORRELATION = undefined; + constructor(changes: readonly IFileChange[], private readonly ignorePathCasing: boolean) { for (const change of changes) { - const array = entriesByType.get(change.type); - if (array) { - array.push([change.resource, change]); - } else { - entriesByType.set(change.type, [[change.resource, change]]); - } + // Split by type switch (change.type) { case FileChangeType.ADDED: this.rawAdded.push(change.resource); @@ -919,26 +967,45 @@ export class FileChangesEvent { this.rawDeleted.push(change.resource); break; } - } - for (const [key, value] of entriesByType) { - switch (key) { - case FileChangeType.ADDED: - this.added = TernarySearchTree.forUris(() => ignorePathCasing); - this.added.fill(value); - break; - case FileChangeType.UPDATED: - this.updated = TernarySearchTree.forUris(() => ignorePathCasing); - this.updated.fill(value); - break; - case FileChangeType.DELETED: - this.deleted = TernarySearchTree.forUris(() => ignorePathCasing); - this.deleted.fill(value); - break; + // Figure out events correlation + if (this.correlationId !== FileChangesEvent.MIXED_CORRELATION) { + if (typeof change.cId === 'number') { + if (this.correlationId === undefined) { + this.correlationId = change.cId; // correlation not yet set, just take it + } else if (this.correlationId !== change.cId) { + this.correlationId = FileChangesEvent.MIXED_CORRELATION; // correlation mismatch, we have mixed correlation + } + } else { + if (this.correlationId !== undefined) { + this.correlationId = FileChangesEvent.MIXED_CORRELATION; // correlation mismatch, we have mixed correlation + } + } } } } + private readonly added = new Lazy(() => { + const added = TernarySearchTree.forUris(() => this.ignorePathCasing); + added.fill(this.rawAdded.map(resource => [resource, true])); + + return added; + }); + + private readonly updated = new Lazy(() => { + const updated = TernarySearchTree.forUris(() => this.ignorePathCasing); + updated.fill(this.rawUpdated.map(resource => [resource, true])); + + return updated; + }); + + private readonly deleted = new Lazy(() => { + const deleted = TernarySearchTree.forUris(() => this.ignorePathCasing); + deleted.fill(this.rawDeleted.map(resource => [resource, true])); + + return deleted; + }); + /** * Find out if the file change events match the provided resource. * @@ -966,33 +1033,33 @@ export class FileChangesEvent { // Added if (!hasTypesFilter || types.includes(FileChangeType.ADDED)) { - if (this.added?.get(resource)) { + if (this.added.value.get(resource)) { return true; } - if (options.includeChildren && this.added?.findSuperstr(resource)) { + if (options.includeChildren && this.added.value.findSuperstr(resource)) { return true; } } // Updated if (!hasTypesFilter || types.includes(FileChangeType.UPDATED)) { - if (this.updated?.get(resource)) { + if (this.updated.value.get(resource)) { return true; } - if (options.includeChildren && this.updated?.findSuperstr(resource)) { + if (options.includeChildren && this.updated.value.findSuperstr(resource)) { return true; } } // Deleted if (!hasTypesFilter || types.includes(FileChangeType.DELETED)) { - if (this.deleted?.findSubstr(resource) /* deleted also considers parent folders */) { + if (this.deleted.value.findSubstr(resource) /* deleted also considers parent folders */) { return true; } - if (options.includeChildren && this.deleted?.findSuperstr(resource)) { + if (options.includeChildren && this.deleted.value.findSuperstr(resource)) { return true; } } @@ -1004,21 +1071,47 @@ export class FileChangesEvent { * Returns if this event contains added files. */ gotAdded(): boolean { - return !!this.added; + return this.rawAdded.length > 0; } /** * Returns if this event contains deleted files. */ gotDeleted(): boolean { - return !!this.deleted; + return this.rawDeleted.length > 0; } /** * Returns if this event contains updated files. */ gotUpdated(): boolean { - return !!this.updated; + return this.rawUpdated.length > 0; + } + + /** + * Returns if this event contains changes that correlate to the + * provided `correlationId`. + * + * File change event correlation is an advanced watch feature that + * allows to identify from which watch request the events originate + * from. This correlation allows to route events specifically + * only to the requestor and not emit them to all listeners. + */ + correlates(correlationId: number): boolean { + return this.correlationId === correlationId; + } + + /** + * Figure out if the event contains changes that correlate to one + * correlation identifier. + * + * File change event correlation is an advanced watch feature that + * allows to identify from which watch request the events originate + * from. This correlation allows to route events specifically + * only to the requestor and not emit them to all listeners. + */ + hasCorrelation(): boolean { + return typeof this.correlationId === 'number'; } /** diff --git a/src/vs/platform/files/common/watcher.ts b/src/vs/platform/files/common/watcher.ts index 798c884805f53..ae97f833078cd 100644 --- a/src/vs/platform/files/common/watcher.ts +++ b/src/vs/platform/files/common/watcher.ts @@ -34,6 +34,13 @@ interface IWatchRequest { * events. */ readonly includes?: Array; + + /** + * If provided, file change events from the watcher that + * are a result of this watch request will carry the same + * id. + */ + readonly correlationId?: number; } export interface INonRecursiveWatchRequest extends IWatchRequest { @@ -70,7 +77,7 @@ interface IWatcher { * A normalized file change event from the raw events * the watcher emits. */ - readonly onDidChangeFile: Event; + readonly onDidChangeFile: Event; /** * An event to indicate a message that should get logged. @@ -148,7 +155,7 @@ export abstract class AbstractWatcherClient extends Disposable { private restartCounter = 0; constructor( - private readonly onFileChanges: (changes: IDiskFileChange[]) => void, + private readonly onFileChanges: (changes: IFileChange[]) => void, private readonly onLogMessage: (msg: ILogMessage) => void, private verboseLogging: boolean, private options: { @@ -234,7 +241,7 @@ export abstract class AbstractWatcherClient extends Disposable { export abstract class AbstractNonRecursiveWatcherClient extends AbstractWatcherClient { constructor( - onFileChanges: (changes: IDiskFileChange[]) => void, + onFileChanges: (changes: IFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean ) { @@ -247,7 +254,7 @@ export abstract class AbstractNonRecursiveWatcherClient extends AbstractWatcherC export abstract class AbstractUniversalWatcherClient extends AbstractWatcherClient { constructor( - onFileChanges: (changes: IDiskFileChange[]) => void, + onFileChanges: (changes: IFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean ) { @@ -257,24 +264,20 @@ export abstract class AbstractUniversalWatcherClient extends AbstractWatcherClie protected abstract override createWatcher(disposables: DisposableStore): IUniversalWatcher; } -export interface IDiskFileChange { - type: FileChangeType; - readonly resource: URI; -} - export interface ILogMessage { readonly type: 'trace' | 'warn' | 'error' | 'info' | 'debug'; readonly message: string; } -export function toFileChanges(changes: IDiskFileChange[]): IFileChange[] { +export function reviveFileChanges(changes: IFileChange[]): IFileChange[] { return changes.map(change => ({ type: change.type, - resource: URI.revive(change.resource) + resource: URI.revive(change.resource), + cId: change.cId })); } -export function coalesceEvents(changes: IDiskFileChange[]): IDiskFileChange[] { +export function coalesceEvents(changes: IFileChange[]): IFileChange[] { // Build deltas const coalescer = new EventCoalescer(); @@ -312,10 +315,10 @@ export function parseWatcherPatterns(path: string, patterns: Array(); - private readonly mapPathToChange = new Map(); + private readonly coalesced = new Set(); + private readonly mapPathToChange = new Map(); - private toKey(event: IDiskFileChange): string { + private toKey(event: IFileChange): string { if (isLinux) { return event.resource.fsPath; } @@ -323,7 +326,7 @@ class EventCoalescer { return event.resource.fsPath.toLowerCase(); // normalise to file system case sensitivity } - processEvent(event: IDiskFileChange): void { + processEvent(event: IFileChange): void { const existingEvent = this.mapPathToChange.get(this.toKey(event)); let keepEvent = false; @@ -370,8 +373,8 @@ class EventCoalescer { } } - coalesce(): IDiskFileChange[] { - const addOrChangeEvents: IDiskFileChange[] = []; + coalesce(): IFileChange[] { + const addOrChangeEvents: IFileChange[] = []; const deletedPaths: string[] = []; // This algorithm will remove all DELETE events up to the root folder diff --git a/src/vs/platform/files/node/diskFileSystemProvider.ts b/src/vs/platform/files/node/diskFileSystemProvider.ts index 2aa00b75c5519..180aa7e2960ff 100644 --- a/src/vs/platform/files/node/diskFileSystemProvider.ts +++ b/src/vs/platform/files/node/diskFileSystemProvider.ts @@ -19,9 +19,9 @@ import { newWriteableStream, ReadableStreamEvents } from 'vs/base/common/stream' import { URI } from 'vs/base/common/uri'; import { IDirent, Promises, RimRafMode, SymlinkSupport } from 'vs/base/node/pfs'; import { localize } from 'vs/nls'; -import { createFileSystemProviderError, IFileAtomicReadOptions, IFileDeleteOptions, IFileOpenOptions, IFileOverwriteOptions, IFileReadStreamOptions, FileSystemProviderCapabilities, FileSystemProviderError, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileCloneCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, isFileOpenForWriteOptions, IStat, FilePermission, IFileSystemProviderWithFileAtomicWriteCapability, IFileSystemProviderWithFileAtomicDeleteCapability } from 'vs/platform/files/common/files'; +import { createFileSystemProviderError, IFileAtomicReadOptions, IFileDeleteOptions, IFileOpenOptions, IFileOverwriteOptions, IFileReadStreamOptions, FileSystemProviderCapabilities, FileSystemProviderError, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileCloneCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, isFileOpenForWriteOptions, IStat, FilePermission, IFileSystemProviderWithFileAtomicWriteCapability, IFileSystemProviderWithFileAtomicDeleteCapability, IFileChange } from 'vs/platform/files/common/files'; import { readFileIntoStream } from 'vs/platform/files/common/io'; -import { AbstractNonRecursiveWatcherClient, AbstractUniversalWatcherClient, IDiskFileChange, ILogMessage } from 'vs/platform/files/common/watcher'; +import { AbstractNonRecursiveWatcherClient, AbstractUniversalWatcherClient, ILogMessage } from 'vs/platform/files/common/watcher'; import { ILogService } from 'vs/platform/log/common/log'; import { AbstractDiskFileSystemProvider, IDiskFileSystemProviderOptions } from 'vs/platform/files/common/diskFileSystemProvider'; import { toErrorMessage } from 'vs/base/common/errorMessage'; @@ -812,7 +812,7 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple //#region File Watching protected createUniversalWatcher( - onChange: (changes: IDiskFileChange[]) => void, + onChange: (changes: IFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean ): AbstractUniversalWatcherClient { @@ -820,7 +820,7 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple } protected createNonRecursiveWatcher( - onChange: (changes: IDiskFileChange[]) => void, + onChange: (changes: IFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean ): AbstractNonRecursiveWatcherClient { diff --git a/src/vs/platform/files/node/diskFileSystemProviderServer.ts b/src/vs/platform/files/node/diskFileSystemProviderServer.ts index fefc836be543e..a6407715b1089 100644 --- a/src/vs/platform/files/node/diskFileSystemProviderServer.ts +++ b/src/vs/platform/files/node/diskFileSystemProviderServer.ts @@ -292,7 +292,8 @@ export abstract class AbstractSessionFileWatcher extends Disposable implements I sessionEmitter.fire( events.map(e => ({ resource: this.uriTransformer.transformOutgoingURI(e.resource), - type: e.type + type: e.type, + cId: e.cId })) ); })); diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts index b57721ef43c3c..11eb6b8a1091b 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { DisposableStore } from 'vs/base/common/lifecycle'; -import { IDiskFileChange, ILogMessage, AbstractNonRecursiveWatcherClient, INonRecursiveWatcher } from 'vs/platform/files/common/watcher'; +import { IFileChange } from 'vs/platform/files/common/files'; +import { ILogMessage, AbstractNonRecursiveWatcherClient, INonRecursiveWatcher } from 'vs/platform/files/common/watcher'; import { NodeJSWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; export class NodeJSWatcherClient extends AbstractNonRecursiveWatcherClient { constructor( - onFileChanges: (changes: IDiskFileChange[]) => void, + onFileChanges: (changes: IFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean ) { diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts index 3a5c43c9ff5d9..dac55a138c5fc 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts @@ -7,7 +7,8 @@ import { Event, Emitter } from 'vs/base/common/event'; import { patternsEquals } from 'vs/base/common/glob'; import { Disposable } from 'vs/base/common/lifecycle'; import { isLinux } from 'vs/base/common/platform'; -import { IDiskFileChange, ILogMessage, INonRecursiveWatchRequest, INonRecursiveWatcher } from 'vs/platform/files/common/watcher'; +import { IFileChange } from 'vs/platform/files/common/files'; +import { ILogMessage, INonRecursiveWatchRequest, INonRecursiveWatcher } from 'vs/platform/files/common/watcher'; import { NodeJSFileWatcherLibrary } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib'; export interface INodeJSWatcherInstance { @@ -25,7 +26,7 @@ export interface INodeJSWatcherInstance { export class NodeJSWatcher extends Disposable implements INonRecursiveWatcher { - private readonly _onDidChangeFile = this._register(new Emitter()); + private readonly _onDidChangeFile = this._register(new Emitter()); readonly onDidChangeFile = this._onDidChangeFile.event; private readonly _onDidLogMessage = this._register(new Emitter()); @@ -61,7 +62,7 @@ export class NodeJSWatcher extends Disposable implements INonRecursiveWatcher { // Logging if (requestsToStartWatching.length) { - this.trace(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes.length > 0 ? request.excludes : ''}, includes: ${request.includes && request.includes.length > 0 ? JSON.stringify(request.includes) : ''})`).join(',')}`); + this.trace(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes.length > 0 ? request.excludes : ''}, includes: ${request.includes && request.includes.length > 0 ? JSON.stringify(request.includes) : ''}, correlationId: ${typeof request.correlationId === 'number' ? request.correlationId : ''})`).join(',')}`); } if (pathsToStopWatching.length) { @@ -107,15 +108,22 @@ export class NodeJSWatcher extends Disposable implements INonRecursiveWatcher { } private normalizeRequests(requests: INonRecursiveWatchRequest[]): INonRecursiveWatchRequest[] { - const requestsMap = new Map(); + const mapCorrelationtoRequests = new Map>(); - // Ignore requests for the same paths + // Ignore requests for the same paths that have the same correlation for (const request of requests) { const path = isLinux ? request.path : request.path.toLowerCase(); // adjust for case sensitivity - requestsMap.set(path, request); + + let requestsForCorrelation = mapCorrelationtoRequests.get(request.correlationId); + if (!requestsForCorrelation) { + requestsForCorrelation = new Map(); + mapCorrelationtoRequests.set(request.correlationId, requestsForCorrelation); + } + + requestsForCorrelation.set(path, request); } - return Array.from(requestsMap.values()); + return Array.from(mapCorrelationtoRequests.values()).map(requests => Array.from(requests.values())).flat(); } async setVerboseLogging(enabled: boolean): Promise { diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts index ea35280301fc7..8f0f7d6a87fb5 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts @@ -11,11 +11,12 @@ import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/ import { normalizeNFC } from 'vs/base/common/normalization'; import { basename, dirname, join } from 'vs/base/common/path'; import { isLinux, isMacintosh } from 'vs/base/common/platform'; +import { joinPath } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { realcase } from 'vs/base/node/extpath'; import { Promises } from 'vs/base/node/pfs'; -import { FileChangeType } from 'vs/platform/files/common/files'; -import { IDiskFileChange, ILogMessage, coalesceEvents, INonRecursiveWatchRequest, parseWatcherPatterns } from 'vs/platform/files/common/watcher'; +import { FileChangeType, IFileChange } from 'vs/platform/files/common/files'; +import { ILogMessage, coalesceEvents, INonRecursiveWatchRequest, parseWatcherPatterns } from 'vs/platform/files/common/watcher'; export class NodeJSFileWatcherLibrary extends Disposable { @@ -35,7 +36,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { // recursive watcher because we can have many individual // node.js watchers per request. // (https://github.com/microsoft/vscode/issues/124723) - private readonly throttledFileChangesEmitter = this._register(new ThrottledWorker( + private readonly throttledFileChangesEmitter = this._register(new ThrottledWorker( { maxWorkChunkSize: 100, // only process up to 100 changes at once before... throttleDelay: 200, // ...resting for 200ms until we process events again... @@ -46,7 +47,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { // Aggregate file changes over FILE_CHANGES_HANDLER_DELAY // to coalesce events and reduce spam. - private readonly fileChangesAggregator = this._register(new RunOnceWorker(events => this.handleFileChanges(events), NodeJSFileWatcherLibrary.FILE_CHANGES_HANDLER_DELAY)); + private readonly fileChangesAggregator = this._register(new RunOnceWorker(events => this.handleFileChanges(events), NodeJSFileWatcherLibrary.FILE_CHANGES_HANDLER_DELAY)); private readonly excludes = parseWatcherPatterns(this.request.path, this.request.excludes); private readonly includes = this.request.includes ? parseWatcherPatterns(this.request.path, this.request.includes) : undefined; @@ -57,7 +58,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { constructor( private request: INonRecursiveWatchRequest, - private onDidFilesChange: (changes: IDiskFileChange[]) => void, + private onDidFilesChange: (changes: IFileChange[]) => void, private onLogMessage?: (msg: ILogMessage) => void, private verboseLogging?: boolean ) { @@ -128,6 +129,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { const disposables = new DisposableStore(); try { + const requestResource = URI.file(this.request.path); const pathBasename = basename(path); // Creating watcher can fail with an exception @@ -261,7 +263,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { type = FileChangeType.DELETED; } - this.onFileChange({ resource: URI.file(join(this.request.path, changedFileName)), type }); + this.onFileChange({ resource: joinPath(requestResource, changedFileName), type, cId: this.request.correlationId }); }, NodeJSFileWatcherLibrary.FILE_DELETE_HANDLER_DELAY); mapPathToStatDisposable.set(changedFileName, toDisposable(() => clearTimeout(timeoutHandle))); @@ -280,7 +282,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { folderChildren.add(changedFileName); } - this.onFileChange({ resource: URI.file(join(this.request.path, changedFileName)), type }); + this.onFileChange({ resource: joinPath(requestResource, changedFileName), type, cId: this.request.correlationId }); } } @@ -319,14 +321,14 @@ export class NodeJSFileWatcherLibrary extends Disposable { // File still exists, so emit as change event and reapply the watcher if (fileExists) { - this.onFileChange({ resource: URI.file(this.request.path), type: FileChangeType.UPDATED }, true /* skip excludes/includes (file is explicitly watched) */); + this.onFileChange({ resource: requestResource, type: FileChangeType.UPDATED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */); disposables.add(await this.doWatch(path, false)); } // File seems to be really gone, so emit a deleted event and dispose else { - this.onFileChange({ resource: URI.file(this.request.path), type: FileChangeType.DELETED }, true /* skip excludes/includes (file is explicitly watched) */); + this.onFileChange({ resource: requestResource, type: FileChangeType.DELETED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */); // Important to flush the event delivery // before disposing the watcher, otherwise @@ -345,7 +347,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { // File changed else { - this.onFileChange({ resource: URI.file(this.request.path), type: FileChangeType.UPDATED }, true /* skip excludes/includes (file is explicitly watched) */); + this.onFileChange({ resource: requestResource, type: FileChangeType.UPDATED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */); } } }); @@ -361,7 +363,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { }); } - private onFileChange(event: IDiskFileChange, skipIncludeExcludeChecks = false): void { + private onFileChange(event: IFileChange, skipIncludeExcludeChecks = false): void { if (this.cts.token.isCancellationRequested) { return; } @@ -385,7 +387,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { } } - private handleFileChanges(fileChanges: IDiskFileChange[]): void { + private handleFileChanges(fileChanges: IFileChange[]): void { // Coalesce events: merge events of same kind const coalescedFileChanges = coalesceEvents(fileChanges); @@ -394,7 +396,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { // Logging if (this.verboseLogging) { for (const event of coalescedFileChanges) { - this.trace(`>> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`); + this.trace(` >> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`); } } diff --git a/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts b/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts index 89f19fd52612d..d1b978043ede3 100644 --- a/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts +++ b/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts @@ -20,8 +20,8 @@ import { dirname, normalize } from 'vs/base/common/path'; import { isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; import { realcaseSync, realpathSync } from 'vs/base/node/extpath'; import { NodeJSFileWatcherLibrary } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib'; -import { FileChangeType } from 'vs/platform/files/common/files'; -import { IDiskFileChange, ILogMessage, coalesceEvents, IRecursiveWatchRequest, IRecursiveWatcher, parseWatcherPatterns } from 'vs/platform/files/common/watcher'; +import { FileChangeType, IFileChange } from 'vs/platform/files/common/files'; +import { ILogMessage, coalesceEvents, IRecursiveWatchRequest, IRecursiveWatcher, parseWatcherPatterns } from 'vs/platform/files/common/watcher'; export interface IParcelWatcherInstance { @@ -49,7 +49,7 @@ export interface IParcelWatcherInstance { /** * An event aggregator to coalesce events and reduce duplicates. */ - readonly worker: RunOnceWorker; + readonly worker: RunOnceWorker; /** * Stops and disposes the watcher. This operation is async to await @@ -70,7 +70,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { private static readonly PARCEL_WATCHER_BACKEND = isWindows ? 'windows' : isLinux ? 'inotify' : 'fs-events'; - private readonly _onDidChangeFile = this._register(new Emitter()); + private readonly _onDidChangeFile = this._register(new Emitter()); readonly onDidChangeFile = this._onDidChangeFile.event; private readonly _onDidLogMessage = this._register(new Emitter()); @@ -95,7 +95,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { // Reduce likelyhood of spam from file events via throttling. // (https://github.com/microsoft/vscode/issues/124723) - private readonly throttledFileChangesEmitter = this._register(new ThrottledWorker( + private readonly throttledFileChangesEmitter = this._register(new ThrottledWorker( { maxWorkChunkSize: 500, // only process up to 500 changes at once before... throttleDelay: 200, // ...resting for 200ms until we process events again... @@ -150,7 +150,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { // Logging if (requestsToStartWatching.length) { - this.trace(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes.length > 0 ? request.excludes : ''}, includes: ${request.includes && request.includes.length > 0 ? JSON.stringify(request.includes) : ''})`).join(',')}`); + this.trace(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes.length > 0 ? request.excludes : ''}, includes: ${request.includes && request.includes.length > 0 ? JSON.stringify(request.includes) : ''}, correlationId: ${typeof request.correlationId === 'number' ? request.correlationId : ''})`).join(',')}`); } if (pathsToStopWatching.length) { @@ -185,7 +185,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { ready: instance.p, restarts, token: cts.token, - worker: new RunOnceWorker(events => this.handleParcelEvents(events, watcher), ParcelWatcher.FILE_CHANGES_HANDLER_DELAY), + worker: new RunOnceWorker(events => this.handleParcelEvents(events, watcher), ParcelWatcher.FILE_CHANGES_HANDLER_DELAY), stop: async () => { cts.dispose(true); @@ -256,7 +256,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { ready: instance.p, restarts, token: cts.token, - worker: new RunOnceWorker(events => this.handleParcelEvents(events, watcher), ParcelWatcher.FILE_CHANGES_HANDLER_DELAY), + worker: new RunOnceWorker(events => this.handleParcelEvents(events, watcher), ParcelWatcher.FILE_CHANGES_HANDLER_DELAY), stop: async () => { cts.dispose(true); @@ -315,7 +315,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { this.normalizeEvents(parcelEvents, watcher.request, realPathDiffers, realPathLength); // Check for includes - const includedEvents = this.handleIncludes(parcelEvents, includes); + const includedEvents = this.handleIncludes(watcher, parcelEvents, includes); // Add to event aggregator for later processing for (const includedEvent of includedEvents) { @@ -323,8 +323,8 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } } - private handleIncludes(parcelEvents: parcelWatcher.Event[], includes: ParsedPattern[] | undefined): IDiskFileChange[] { - const events: IDiskFileChange[] = []; + private handleIncludes(watcher: IParcelWatcherInstance, parcelEvents: parcelWatcher.Event[], includes: ParsedPattern[] | undefined): IFileChange[] { + const events: IFileChange[] = []; for (const { path, type: parcelEventType } of parcelEvents) { const type = ParcelWatcher.MAP_PARCEL_WATCHER_ACTION_TO_FILE_CHANGE.get(parcelEventType)!; @@ -338,14 +338,14 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { this.trace(` >> ignored (not included) ${path}`); } } else { - events.push({ type, resource: URI.file(path) }); + events.push({ type, resource: URI.file(path), cId: watcher.request.correlationId }); } } return events; } - private handleParcelEvents(parcelEvents: IDiskFileChange[], watcher: IParcelWatcherInstance): void { + private handleParcelEvents(parcelEvents: IFileChange[], watcher: IParcelWatcherInstance): void { // Coalesce events: merge events of same kind const coalescedEvents = coalesceEvents(parcelEvents); @@ -354,7 +354,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { const { events: filteredEvents, rootDeleted } = this.filterEvents(coalescedEvents, watcher); // Broadcast to clients - this.emitEvents(filteredEvents); + this.emitEvents(filteredEvents, watcher); // Handle root path deletes if (rootDeleted) { @@ -362,7 +362,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } } - private emitEvents(events: IDiskFileChange[]): void { + private emitEvents(events: IFileChange[], watcher: IParcelWatcherInstance): void { if (events.length === 0) { return; } @@ -370,7 +370,8 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { // Logging if (this.verboseLogging) { for (const event of events) { - this.trace(` >> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`); + const traceMsg = ` >> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`; + this.trace(typeof watcher.request.correlationId === 'number' ? `${traceMsg} (correlationId: ${watcher.request.correlationId})` : traceMsg); } } @@ -440,8 +441,8 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } } - private filterEvents(events: IDiskFileChange[], watcher: IParcelWatcherInstance): { events: IDiskFileChange[]; rootDeleted?: boolean } { - const filteredEvents: IDiskFileChange[] = []; + private filterEvents(events: IFileChange[], watcher: IParcelWatcherInstance): { events: IFileChange[]; rootDeleted?: boolean } { + const filteredEvents: IFileChange[] = []; let rootDeleted = false; for (const event of events) { @@ -467,7 +468,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { const parentPath = dirname(watcher.request.path); if (existsSync(parentPath)) { - const nodeWatcher = new NodeJSFileWatcherLibrary({ path: parentPath, excludes: [], recursive: false }, changes => { + const nodeWatcher = new NodeJSFileWatcherLibrary({ path: parentPath, excludes: [], recursive: false, correlationId: watcher.request.correlationId }, changes => { if (watcher.token.isCancellationRequested) { return; // return early when disposed } @@ -569,62 +570,86 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } protected normalizeRequests(requests: IRecursiveWatchRequest[], validatePaths = true): IRecursiveWatchRequest[] { - const requestTrie = TernarySearchTree.forPaths(!isLinux); // 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. In addition, drop any request where - // everything is excluded (via `**` glob). - // - // However, allow explicit requests to watch folders - // that are symbolic links because the Parcel watcher - // does not allow to recursively watch symbolic links. + // Map request paths to correlation and ignore identical paths + const mapCorrelationtoRequests = new Map>(); for (const request of requests) { if (request.excludes.includes(GLOBSTAR)) { continue; // path is ignored entirely (via `**` glob exclude) } - // Check for overlapping requests - if (requestTrie.findSubstr(request.path)) { - try { - const realpath = realpathSync(request.path); - if (realpath === request.path) { - this.trace(`ignoring a path for watching who's parent is already watched: ${request.path}`); + const path = isLinux ? request.path : request.path.toLowerCase(); // adjust for case sensitivity + + let requestsForCorrelation = mapCorrelationtoRequests.get(request.correlationId); + if (!requestsForCorrelation) { + requestsForCorrelation = new Map(); + mapCorrelationtoRequests.set(request.correlationId, requestsForCorrelation); + } + + requestsForCorrelation.set(path, request); + } + + const normalizedRequests: IRecursiveWatchRequest[] = []; + + for (const requestsForCorrelation of mapCorrelationtoRequests.values()) { + + // Only consider requests for watching that are not + // a child of an existing request path to prevent + // duplication. In addition, drop any request where + // everything is excluded (via `**` glob). + // + // However, allow explicit requests to watch folders + // that are symbolic links because the Parcel watcher + // does not allow to recursively watch symbolic links. + + const requestTrie = TernarySearchTree.forPaths(!isLinux); + + for (const request of requestsForCorrelation.values()) { + + // Check for overlapping requests + if (requestTrie.findSubstr(request.path)) { + try { + const realpath = realpathSync(request.path); + if (realpath === request.path) { + this.trace(`ignoring a path for watching who's parent is already watched: ${request.path}`); + + continue; + } + } catch (error) { + this.trace(`ignoring a path for watching who's realpath failed to resolve: ${request.path} (error: ${error})`); continue; } - } catch (error) { - this.trace(`ignoring a path for watching who's realpath failed to resolve: ${request.path} (error: ${error})`); - - continue; } - } - // Check for invalid paths - if (validatePaths) { - try { - const stat = statSync(request.path); - if (!stat.isDirectory()) { - this.trace(`ignoring a path for watching that is a file and not a folder: ${request.path}`); + // Check for invalid paths + if (validatePaths) { + try { + const stat = statSync(request.path); + if (!stat.isDirectory()) { + this.trace(`ignoring a path for watching that is a file and not a folder: ${request.path}`); + + continue; + } + } catch (error) { + this.trace(`ignoring a path for watching who's stat info failed to resolve: ${request.path} (error: ${error})`); continue; } - } catch (error) { - this.trace(`ignoring a path for watching who's stat info failed to resolve: ${request.path} (error: ${error})`); - - continue; } + + requestTrie.set(request.path, request); } - requestTrie.set(request.path, request); + normalizedRequests.push(...Array.from(requestTrie).map(([, request]) => request)); } - return Array.from(requestTrie).map(([, request]) => request); + return normalizedRequests; } async setVerboseLogging(enabled: boolean): Promise { diff --git a/src/vs/platform/files/node/watcher/watcherClient.ts b/src/vs/platform/files/node/watcher/watcherClient.ts index 221f7629496f3..86a89330cd1b5 100644 --- a/src/vs/platform/files/node/watcher/watcherClient.ts +++ b/src/vs/platform/files/node/watcher/watcherClient.ts @@ -7,12 +7,13 @@ import { DisposableStore } 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 { AbstractUniversalWatcherClient, IDiskFileChange, ILogMessage, IUniversalWatcher } from 'vs/platform/files/common/watcher'; +import { IFileChange } from 'vs/platform/files/common/files'; +import { AbstractUniversalWatcherClient, ILogMessage, IUniversalWatcher } from 'vs/platform/files/common/watcher'; export class UniversalWatcherClient extends AbstractUniversalWatcherClient { constructor( - onFileChanges: (changes: IDiskFileChange[]) => void, + onFileChanges: (changes: IFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean ) { diff --git a/src/vs/platform/files/test/browser/fileService.test.ts b/src/vs/platform/files/test/browser/fileService.test.ts index 5d0b16250edef..7c32e878b3962 100644 --- a/src/vs/platform/files/test/browser/fileService.test.ts +++ b/src/vs/platform/files/test/browser/fileService.test.ts @@ -7,12 +7,13 @@ import * as assert from 'assert'; import { DeferredPromise, timeout } from 'vs/base/common/async'; import { bufferToReadable, bufferToStream, VSBuffer } from 'vs/base/common/buffer'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { Emitter, Event } from 'vs/base/common/event'; import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { isEqual } from 'vs/base/common/resources'; import { consumeStream, newWriteableStream, ReadableStreamEvents } from 'vs/base/common/stream'; import { URI } from 'vs/base/common/uri'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; -import { IFileOpenOptions, IFileReadStreamOptions, FileSystemProviderCapabilities, FileType, IFileSystemProviderCapabilitiesChangeEvent, IFileSystemProviderRegistrationEvent, IStat, IFileAtomicReadOptions, IFileAtomicWriteOptions, IFileAtomicDeleteOptions, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileAtomicDeleteCapability, IFileSystemProviderWithFileAtomicWriteCapability, IFileAtomicOptions } from 'vs/platform/files/common/files'; +import { IFileOpenOptions, IFileReadStreamOptions, FileSystemProviderCapabilities, FileType, IFileSystemProviderCapabilitiesChangeEvent, IFileSystemProviderRegistrationEvent, IStat, IFileAtomicReadOptions, IFileAtomicWriteOptions, IFileAtomicDeleteOptions, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileAtomicDeleteCapability, IFileSystemProviderWithFileAtomicWriteCapability, IFileAtomicOptions, IFileChange, isFileSystemWatcher, FileChangesEvent, FileChangeType } from 'vs/platform/files/common/files'; import { FileService } from 'vs/platform/files/common/fileService'; import { NullFileSystemProvider } from 'vs/platform/files/test/common/nullFileSystemProvider'; import { NullLogService } from 'vs/platform/log/common/log'; @@ -142,6 +143,58 @@ suite('File Service', () => { service.dispose(); }); + test('watch - with corelation', async () => { + const service = disposables.add(new FileService(new NullLogService())); + + const provider = new class extends NullFileSystemProvider { + private readonly _testOnDidChangeFile = new Emitter(); + override readonly onDidChangeFile: Event = this._testOnDidChangeFile.event; + + fireFileChange(changes: readonly IFileChange[]) { + this._testOnDidChangeFile.fire(changes); + } + }; + + disposables.add(service.registerProvider('test', provider)); + await service.activateProvider('test'); + + const globalEvents: FileChangesEvent[] = []; + disposables.add(service.onDidFilesChange(e => { + globalEvents.push(e); + })); + + const watcher0 = disposables.add(service.watch(URI.parse('test://watch/folder1'), { recursive: true, excludes: [], includes: [] })); + assert.strictEqual(isFileSystemWatcher(watcher0), false); + const watcher1 = disposables.add(service.watch(URI.parse('test://watch/folder2'), { recursive: true, excludes: [], includes: [], correlationId: 100 })); + assert.strictEqual(isFileSystemWatcher(watcher1), true); + const watcher2 = disposables.add(service.watch(URI.parse('test://watch/folder3'), { recursive: true, excludes: [], includes: [], correlationId: 200 })); + assert.strictEqual(isFileSystemWatcher(watcher2), true); + + const watcher1Events: FileChangesEvent[] = []; + disposables.add(watcher1.onDidChange(e => { + watcher1Events.push(e); + })); + + const watcher2Events: FileChangesEvent[] = []; + disposables.add(watcher2.onDidChange(e => { + watcher2Events.push(e); + })); + + provider.fireFileChange([{ resource: URI.parse('test://watch/folder1'), type: FileChangeType.ADDED }]); + provider.fireFileChange([{ resource: URI.parse('test://watch/folder2'), type: FileChangeType.ADDED, cId: 100 }]); + provider.fireFileChange([{ resource: URI.parse('test://watch/folder2'), type: FileChangeType.ADDED, cId: 100 }]); + provider.fireFileChange([{ resource: URI.parse('test://watch/folder3/file'), type: FileChangeType.UPDATED, cId: 200 }]); + provider.fireFileChange([{ resource: URI.parse('test://watch/folder3'), type: FileChangeType.UPDATED, cId: 200 }]); + + provider.fireFileChange([{ resource: URI.parse('test://watch/folder4'), type: FileChangeType.ADDED, cId: 50 }]); + provider.fireFileChange([{ resource: URI.parse('test://watch/folder4'), type: FileChangeType.ADDED, cId: 60 }]); + provider.fireFileChange([{ resource: URI.parse('test://watch/folder4'), type: FileChangeType.ADDED, cId: 70 }]); + + assert.strictEqual(globalEvents.length, 1); + assert.strictEqual(watcher1Events.length, 2); + assert.strictEqual(watcher2Events.length, 2); + }); + test('error from readFile bubbles through (https://github.com/microsoft/vscode/issues/118060) - async', async () => { testReadErrorBubbles(true); }); diff --git a/src/vs/platform/files/test/common/files.test.ts b/src/vs/platform/files/test/common/files.test.ts index 2a6ae1a5a3a03..1de7f86398af5 100644 --- a/src/vs/platform/files/test/common/files.test.ts +++ b/src/vs/platform/files/test/common/files.test.ts @@ -8,7 +8,7 @@ import { isEqual, isEqualOrParent } from 'vs/base/common/extpath'; import { isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; import { URI } from 'vs/base/common/uri'; import { ensureNoDisposablesAreLeakedInTestSuite, toResource } from 'vs/base/test/common/utils'; -import { FileChangesEvent, FileChangeType, isParent } from 'vs/platform/files/common/files'; +import { FileChangesEvent, FileChangeType, IFileChange, isParent } from 'vs/platform/files/common/files'; suite('Files', () => { @@ -109,6 +109,51 @@ suite('Files', () => { } }); + test('FileChangesEvent - correlation', function () { + let changes: IFileChange[] = [ + { resource: toResource.call(this, '/foo/updated.txt'), type: FileChangeType.UPDATED }, + { resource: toResource.call(this, '/foo/otherupdated.txt'), type: FileChangeType.UPDATED }, + { resource: toResource.call(this, '/added.txt'), type: FileChangeType.ADDED }, + ]; + + let event: FileChangesEvent = new FileChangesEvent(changes, true); + assert.strictEqual(event.hasCorrelation(), false); + assert.strictEqual(event.correlates(100), false); + + changes = [ + { resource: toResource.call(this, '/foo/updated.txt'), type: FileChangeType.UPDATED, cId: 100 }, + { resource: toResource.call(this, '/foo/otherupdated.txt'), type: FileChangeType.UPDATED, cId: 100 }, + { resource: toResource.call(this, '/added.txt'), type: FileChangeType.ADDED, cId: 100 }, + ]; + + event = new FileChangesEvent(changes, true); + assert.strictEqual(event.hasCorrelation(), true); + assert.strictEqual(event.correlates(100), true); + assert.strictEqual(event.correlates(120), false); + + changes = [ + { resource: toResource.call(this, '/foo/updated.txt'), type: FileChangeType.UPDATED, cId: 100 }, + { resource: toResource.call(this, '/foo/otherupdated.txt'), type: FileChangeType.UPDATED }, + { resource: toResource.call(this, '/added.txt'), type: FileChangeType.ADDED, cId: 100 }, + ]; + + event = new FileChangesEvent(changes, true); + assert.strictEqual(event.hasCorrelation(), false); + assert.strictEqual(event.correlates(100), false); + assert.strictEqual(event.correlates(120), false); + + changes = [ + { resource: toResource.call(this, '/foo/updated.txt'), type: FileChangeType.UPDATED, cId: 100 }, + { resource: toResource.call(this, '/foo/otherupdated.txt'), type: FileChangeType.UPDATED, cId: 120 }, + { resource: toResource.call(this, '/added.txt'), type: FileChangeType.ADDED, cId: 100 }, + ]; + + event = new FileChangesEvent(changes, true); + assert.strictEqual(event.hasCorrelation(), false); + assert.strictEqual(event.correlates(100), false); + assert.strictEqual(event.correlates(120), false); + }); + function testIsEqual(testMethod: (pA: string, pB: string, ignoreCase: boolean) => boolean): void { // corner cases diff --git a/src/vs/platform/files/test/common/watcher.test.ts b/src/vs/platform/files/test/common/watcher.test.ts index f1fa797119bdc..4fadddfc73fe1 100644 --- a/src/vs/platform/files/test/common/watcher.test.ts +++ b/src/vs/platform/files/test/common/watcher.test.ts @@ -11,7 +11,7 @@ import { isEqual } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { FileChangesEvent, FileChangeType, IFileChange } from 'vs/platform/files/common/files'; -import { IDiskFileChange, coalesceEvents, toFileChanges, parseWatcherPatterns } from 'vs/platform/files/common/watcher'; +import { coalesceEvents, reviveFileChanges, parseWatcherPatterns } from 'vs/platform/files/common/watcher'; class TestFileWatcher extends Disposable { private readonly _onDidFilesChange: Emitter<{ raw: IFileChange[]; event: FileChangesEvent }>; @@ -26,23 +26,23 @@ class TestFileWatcher extends Disposable { return this._onDidFilesChange.event; } - report(changes: IDiskFileChange[]): void { + report(changes: IFileChange[]): void { this.onRawFileEvents(changes); } - private onRawFileEvents(events: IDiskFileChange[]): void { + private onRawFileEvents(events: IFileChange[]): void { // Coalesce const coalescedEvents = coalesceEvents(events); // Emit through event emitter if (coalescedEvents.length > 0) { - this._onDidFilesChange.fire({ raw: toFileChanges(coalescedEvents), event: this.toFileChangesEvent(coalescedEvents) }); + this._onDidFilesChange.fire({ raw: reviveFileChanges(coalescedEvents), event: this.toFileChangesEvent(coalescedEvents) }); } } - private toFileChangesEvent(changes: IDiskFileChange[]): FileChangesEvent { - return new FileChangesEvent(toFileChanges(changes), !isLinux); + private toFileChangesEvent(changes: IFileChange[]): FileChangesEvent { + return new FileChangesEvent(reviveFileChanges(changes), !isLinux); } } @@ -126,7 +126,7 @@ suite('Watcher Events Normalizer', () => { const updated = URI.file('/users/data/src/updated.txt'); const deleted = URI.file('/users/data/src/deleted.txt'); - const raw: IDiskFileChange[] = [ + const raw: IFileChange[] = [ { resource: added, type: FileChangeType.ADDED }, { resource: updated, type: FileChangeType.UPDATED }, { resource: deleted, type: FileChangeType.DELETED }, @@ -159,7 +159,7 @@ suite('Watcher Events Normalizer', () => { const addedFile = URI.file(path === Path.UNIX ? '/users/data/src/added.txt' : path === Path.WINDOWS ? 'C:\\users\\data\\src\\added.txt' : '\\\\localhost\\users\\data\\src\\added.txt'); const updatedFile = URI.file(path === Path.UNIX ? '/users/data/src/updated.txt' : path === Path.WINDOWS ? 'C:\\users\\data\\src\\updated.txt' : '\\\\localhost\\users\\data\\src\\updated.txt'); - const raw: IDiskFileChange[] = [ + const raw: IFileChange[] = [ { resource: deletedFolderA, type: FileChangeType.DELETED }, { resource: deletedFolderB, type: FileChangeType.DELETED }, { resource: deletedFolderBF1, type: FileChangeType.DELETED }, @@ -194,7 +194,7 @@ suite('Watcher Events Normalizer', () => { const deleted = URI.file('/users/data/src/related'); const unrelated = URI.file('/users/data/src/unrelated'); - const raw: IDiskFileChange[] = [ + const raw: IFileChange[] = [ { resource: created, type: FileChangeType.ADDED }, { resource: deleted, type: FileChangeType.DELETED }, { resource: unrelated, type: FileChangeType.UPDATED }, @@ -219,7 +219,7 @@ suite('Watcher Events Normalizer', () => { const created = URI.file('/users/data/src/related'); const unrelated = URI.file('/users/data/src/unrelated'); - const raw: IDiskFileChange[] = [ + const raw: IFileChange[] = [ { resource: deleted, type: FileChangeType.DELETED }, { resource: created, type: FileChangeType.ADDED }, { resource: unrelated, type: FileChangeType.UPDATED }, @@ -245,7 +245,7 @@ suite('Watcher Events Normalizer', () => { const updated = URI.file('/users/data/src/related'); const unrelated = URI.file('/users/data/src/unrelated'); - const raw: IDiskFileChange[] = [ + const raw: IFileChange[] = [ { resource: created, type: FileChangeType.ADDED }, { resource: updated, type: FileChangeType.UPDATED }, { resource: unrelated, type: FileChangeType.UPDATED }, @@ -273,7 +273,7 @@ suite('Watcher Events Normalizer', () => { const deleted = URI.file('/users/data/src/related'); const unrelated = URI.file('/users/data/src/unrelated'); - const raw: IDiskFileChange[] = [ + const raw: IFileChange[] = [ { resource: updated, type: FileChangeType.UPDATED }, { resource: updated2, type: FileChangeType.UPDATED }, { resource: unrelated, type: FileChangeType.UPDATED }, @@ -300,7 +300,7 @@ suite('Watcher Events Normalizer', () => { const oldPath = URI.file('/users/data/src/added'); const newPath = URI.file('/users/data/src/ADDED'); - const raw: IDiskFileChange[] = [ + const raw: IFileChange[] = [ { resource: newPath, type: FileChangeType.ADDED }, { resource: oldPath, type: FileChangeType.DELETED } ]; diff --git a/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts b/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts index c7b5fc4e7f029..17c97a28bd480 100644 --- a/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts +++ b/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts @@ -17,13 +17,16 @@ import { DeferredPromise } from 'vs/base/common/async'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { NodeJSWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; import { FileAccess } from 'vs/base/common/network'; +import { extUriBiasedIgnorePathCase } from 'vs/base/common/resources'; +import { URI } from 'vs/base/common/uri'; +import { addUNCHostToAllowlist } from 'vs/base/node/unc'; // this suite has shown flaky runs in Azure pipelines where // tasks would just hang and timeout after a while (not in // mocha but generally). as such they will run only on demand // whenever we update the watcher library. -((process.env['BUILD_SOURCEVERSION'] || process.env['CI']) ? suite.skip : flakySuite)('File Watcher (node.js)', () => { +flakySuite('File Watcher (node.js)', () => { class TestNodeJSWatcher extends NodeJSWatcher { @@ -105,16 +108,22 @@ import { FileAccess } from 'vs/base/common/network'; } } - async function awaitEvent(service: TestNodeJSWatcher, path: string, type: FileChangeType): Promise { + async function awaitEvent(service: TestNodeJSWatcher, path: string, type: FileChangeType, correlationId?: number | null, expectedCount?: number): Promise { if (loggingEnabled) { console.log(`Awaiting change type '${toMsg(type)}' on file '${path}'`); } // Await the event await new Promise(resolve => { + let counter = 0; const disposable = service.onDidChangeFile(events => { for (const event of events) { - if (event.resource.fsPath === path && event.type === type) { + if (extUriBiasedIgnorePathCase.isEqual(event.resource, URI.file(path)) && event.type === type && (correlationId === null || event.cId === correlationId)) { + counter++; + if (typeof expectedCount === 'number' && counter < expectedCount) { + continue; // not yet + } + disposable.dispose(); resolve(); break; @@ -406,6 +415,13 @@ import { FileAccess } from 'vs/base/common/network'; return basicCrudTest(join(testDir, 'files-includes.txt')); }); + test('correlationId is supported', async function () { + const correlationId = Math.random(); + await watcher.watch([{ correlationId, path: testDir, excludes: [], recursive: false }]); + + return basicCrudTest(join(testDir, 'newFile.txt'), undefined, correlationId); + }); + (isWindows /* windows: cannot create file symbolic link without elevated context */ ? test.skip : test)('symlink support (folder watch)', async function () { const link = join(testDir, 'deep-linked'); const linkTarget = join(testDir, 'deep'); @@ -416,23 +432,23 @@ import { FileAccess } from 'vs/base/common/network'; return basicCrudTest(join(link, 'newFile.txt')); }); - async function basicCrudTest(filePath: string, skipAdd?: boolean): Promise { + async function basicCrudTest(filePath: string, skipAdd?: boolean, correlationId?: number | null, expectedCount?: number): Promise { let changeFuture: Promise; // New file if (!skipAdd) { - changeFuture = awaitEvent(watcher, filePath, FileChangeType.ADDED); + changeFuture = awaitEvent(watcher, filePath, FileChangeType.ADDED, correlationId, expectedCount); await Promises.writeFile(filePath, 'Hello World'); await changeFuture; } // Change file - changeFuture = awaitEvent(watcher, filePath, FileChangeType.UPDATED); + changeFuture = awaitEvent(watcher, filePath, FileChangeType.UPDATED, correlationId, expectedCount); await Promises.writeFile(filePath, 'Hello Change'); await changeFuture; // Delete file - changeFuture = awaitEvent(watcher, filePath, FileChangeType.DELETED); + changeFuture = awaitEvent(watcher, filePath, FileChangeType.DELETED, correlationId, expectedCount); await Promises.unlink(await Promises.realpath(filePath)); // support symlinks await changeFuture; } @@ -448,6 +464,7 @@ import { FileAccess } from 'vs/base/common/network'; }); (!isWindows /* UNC is windows only */ ? test.skip : test)('unc support (folder watch)', async function () { + addUNCHostToAllowlist('localhost'); // Local UNC paths are in the form of: \\localhost\c$\my_dir const uncPath = `\\\\localhost\\${getDriveLetter(testDir)?.toLowerCase()}$\\${ltrim(testDir.substr(testDir.indexOf(':') + 1), '\\')}`; @@ -458,6 +475,7 @@ import { FileAccess } from 'vs/base/common/network'; }); (!isWindows /* UNC is windows only */ ? test.skip : test)('unc support (file watch)', async function () { + addUNCHostToAllowlist('localhost'); // Local UNC paths are in the form of: \\localhost\c$\my_dir const uncPath = `\\\\localhost\\${getDriveLetter(testDir)?.toLowerCase()}$\\${ltrim(testDir.substr(testDir.indexOf(':') + 1), '\\')}\\lorem.txt`; @@ -528,4 +546,17 @@ import { FileAccess } from 'vs/base/common/network'; return watchPromise; }); + + test('watching same or overlapping paths supported when correlation is applied', async () => { + + // same path, same options + await watcher.watch([ + { path: testDir, excludes: [], recursive: false, correlationId: 1 }, + { path: testDir, excludes: [], recursive: false, correlationId: 2, }, + { path: testDir, excludes: [], recursive: false, correlationId: undefined } + ]); + + await basicCrudTest(join(testDir, 'newFile.txt'), undefined, null, 3); + await basicCrudTest(join(testDir, 'otherNewFile.txt'), undefined, null, 3); + }); }); diff --git a/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts b/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts index 67ad2ee3d444d..4063f2ad8ebf7 100644 --- a/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts +++ b/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts @@ -7,23 +7,26 @@ import * as assert from 'assert'; import { realpathSync } from 'fs'; import { tmpdir } from 'os'; import { timeout } from 'vs/base/common/async'; -import { join } from 'vs/base/common/path'; +import { dirname, join } from 'vs/base/common/path'; import { isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; import { Promises, RimRafMode } from 'vs/base/node/pfs'; import { flakySuite, getRandomTestPath } from 'vs/base/test/node/testUtils'; -import { FileChangeType } from 'vs/platform/files/common/files'; +import { FileChangeType, IFileChange } from 'vs/platform/files/common/files'; import { ParcelWatcher } from 'vs/platform/files/node/watcher/parcel/parcelWatcher'; -import { IDiskFileChange, IRecursiveWatchRequest } from 'vs/platform/files/common/watcher'; +import { IRecursiveWatchRequest } from 'vs/platform/files/common/watcher'; import { getDriveLetter } from 'vs/base/common/extpath'; import { ltrim } from 'vs/base/common/strings'; import { FileAccess } from 'vs/base/common/network'; +import { extUriBiasedIgnorePathCase } from 'vs/base/common/resources'; +import { URI } from 'vs/base/common/uri'; +import { addUNCHostToAllowlist } from 'vs/base/node/unc'; // this suite has shown flaky runs in Azure pipelines where // tasks would just hang and timeout after a while (not in // mocha but generally). as such they will run only on demand // whenever we update the watcher library. -((process.env['BUILD_SOURCEVERSION'] || process.env['CI']) ? suite.skip : flakySuite)('File Watcher (parcel)', () => { +flakySuite('File Watcher (parcel)', () => { class TestParcelWatcher extends ParcelWatcher { @@ -103,16 +106,22 @@ import { FileAccess } from 'vs/base/common/network'; } } - async function awaitEvent(service: TestParcelWatcher, path: string, type: FileChangeType, failOnEventReason?: string): Promise { + async function awaitEvent(watcher: TestParcelWatcher, path: string, type: FileChangeType, failOnEventReason?: string, correlationId?: number | null, expectedCount?: number): Promise { if (loggingEnabled) { console.log(`Awaiting change type '${toMsg(type)}' on file '${path}'`); } // Await the event - const res = await new Promise((resolve, reject) => { - const disposable = service.onDidChangeFile(events => { + const res = await new Promise((resolve, reject) => { + let counter = 0; + const disposable = watcher.onDidChangeFile(events => { for (const event of events) { - if (event.resource.fsPath === path && event.type === type) { + if (extUriBiasedIgnorePathCase.isEqual(event.resource, URI.file(path)) && event.type === type && (correlationId === null || event.cId === correlationId)) { + counter++; + if (typeof expectedCount === 'number' && counter < expectedCount) { + continue; // not yet + } + disposable.dispose(); if (failOnEventReason) { reject(new Error(`Unexpected file event: ${failOnEventReason}`)); @@ -134,14 +143,14 @@ import { FileAccess } from 'vs/base/common/network'; return res; } - function awaitMessage(service: TestParcelWatcher, type: 'trace' | 'warn' | 'error' | 'info' | 'debug'): Promise { + function awaitMessage(watcher: TestParcelWatcher, type: 'trace' | 'warn' | 'error' | 'info' | 'debug'): Promise { if (loggingEnabled) { console.log(`Awaiting message of type ${type}`); } // Await the message return new Promise(resolve => { - const disposable = service.onDidLogMessage(msg => { + const disposable = watcher.onDidLogMessage(msg => { if (msg.type === type) { disposable.dispose(); resolve(); @@ -287,20 +296,20 @@ import { FileAccess } from 'vs/base/common/network'; return basicCrudTest(join(testDir, 'deep', 'newFile.txt')); }); - async function basicCrudTest(filePath: string): Promise { + async function basicCrudTest(filePath: string, correlationId?: number | null, expectedCount?: number): Promise { // New file - let changeFuture = awaitEvent(watcher, filePath, FileChangeType.ADDED); + let changeFuture = awaitEvent(watcher, filePath, FileChangeType.ADDED, undefined, correlationId, expectedCount); await Promises.writeFile(filePath, 'Hello World'); await changeFuture; // Change file - changeFuture = awaitEvent(watcher, filePath, FileChangeType.UPDATED); + changeFuture = awaitEvent(watcher, filePath, FileChangeType.UPDATED, undefined, correlationId, expectedCount); await Promises.writeFile(filePath, 'Hello Change'); await changeFuture; // Delete file - changeFuture = awaitEvent(watcher, filePath, FileChangeType.DELETED); + changeFuture = awaitEvent(watcher, filePath, FileChangeType.DELETED, undefined, correlationId, expectedCount); await Promises.unlink(filePath); await changeFuture; } @@ -509,6 +518,7 @@ import { FileAccess } from 'vs/base/common/network'; }); (!isWindows /* UNC is windows only */ ? test.skip : test)('unc support', async function () { + addUNCHostToAllowlist('localhost'); // Local UNC paths are in the form of: \\localhost\c$\my_dir const uncPath = `\\\\localhost\\${getDriveLetter(testDir)?.toLowerCase()}$\\${ltrim(testDir.substr(testDir.indexOf(':') + 1), '\\')}`; @@ -532,7 +542,7 @@ import { FileAccess } from 'vs/base/common/network'; await watcher.watch([{ path: invalidPath, excludes: [], recursive: true }]); }); - test('deleting watched path is handled properly', async function () { + (isWindows /* flaky on windows */ ? test.skip : test)('deleting watched path is handled properly', async function () { const watchedPath = join(testDir, 'deep'); await watcher.watch([{ path: watchedPath, excludes: [], recursive: true }]); @@ -555,6 +565,13 @@ import { FileAccess } from 'vs/base/common/network'; await changeFuture; }); + test('correlationId is supported', async function () { + const correlationId = Math.random(); + await watcher.watch([{ correlationId, path: testDir, excludes: [], recursive: true }]); + + return basicCrudTest(join(testDir, 'newFile.txt'), correlationId); + }); + test('should not exclude roots that do not overlap', () => { if (isWindows) { assert.deepStrictEqual(watcher.testNormalizePaths(['C:\\a']), ['C:\\a']); @@ -584,4 +601,49 @@ import { FileAccess } from 'vs/base/common/network'; test('should ignore when everything excluded', () => { assert.deepStrictEqual(watcher.testNormalizePaths(['/foo/bar', '/bar'], ['**', 'something']), []); }); + + test('watching same or overlapping paths supported when correlation is applied', async () => { + + // same path, same options + await watcher.watch([ + { path: testDir, excludes: [], recursive: true, correlationId: 1 }, + { path: testDir, excludes: [], recursive: true, correlationId: 2, }, + { path: testDir, excludes: [], recursive: true, correlationId: undefined } + ]); + + await basicCrudTest(join(testDir, 'newFile.txt'), null, 3); + await basicCrudTest(join(testDir, 'otherNewFile.txt'), null, 3); + + // same path, different options + await watcher.watch([ + { path: testDir, excludes: [], recursive: true, correlationId: 1 }, + { path: testDir, excludes: [], recursive: true, correlationId: 2 }, + { path: testDir, excludes: [], recursive: true, correlationId: undefined }, + { path: testDir, excludes: [join(realpathSync(testDir), 'deep')], recursive: true, correlationId: 3 }, + { path: testDir, excludes: [join(realpathSync(testDir), 'other')], recursive: true, correlationId: 4 }, + ]); + + await basicCrudTest(join(testDir, 'newFile.txt'), null, 5); + await basicCrudTest(join(testDir, 'otherNewFile.txt'), null, 5); + + // overlapping paths (same options) + await watcher.watch([ + { path: dirname(testDir), excludes: [], recursive: true, correlationId: 1 }, + { path: testDir, excludes: [], recursive: true, correlationId: 2 }, + { path: join(testDir, 'deep'), excludes: [], recursive: true, correlationId: 3 }, + ]); + + await basicCrudTest(join(testDir, 'deep', 'newFile.txt'), null, 3); + await basicCrudTest(join(testDir, 'deep', 'otherNewFile.txt'), null, 3); + + // overlapping paths (different options) + await watcher.watch([ + { path: dirname(testDir), excludes: [], recursive: true, correlationId: 1 }, + { path: testDir, excludes: [join(realpathSync(testDir), 'some')], recursive: true, correlationId: 2 }, + { path: join(testDir, 'deep'), excludes: [join(realpathSync(testDir), 'other')], recursive: true, correlationId: 3 }, + ]); + + await basicCrudTest(join(testDir, 'deep', 'newFile.txt'), null, 3); + await basicCrudTest(join(testDir, 'deep', 'otherNewFile.txt'), null, 3); + }); }); diff --git a/src/vs/platform/userData/common/fileUserDataProvider.ts b/src/vs/platform/userData/common/fileUserDataProvider.ts index 3cddb5514a3a5..eaded2a093874 100644 --- a/src/vs/platform/userData/common/fileUserDataProvider.ts +++ b/src/vs/platform/userData/common/fileUserDataProvider.ts @@ -156,7 +156,8 @@ export class FileUserDataProvider extends Disposable implements if (this.watchResources.findSubstr(userDataResource)) { userDataChanges.push({ resource: userDataResource, - type: change.type + type: change.type, + cId: change.cId }); } } diff --git a/src/vs/workbench/api/browser/mainThreadFileSystem.ts b/src/vs/workbench/api/browser/mainThreadFileSystem.ts index c7a5c6cc66181..e0f9359f0e943 100644 --- a/src/vs/workbench/api/browser/mainThreadFileSystem.ts +++ b/src/vs/workbench/api/browser/mainThreadFileSystem.ts @@ -6,17 +6,10 @@ import { Emitter, Event } from 'vs/base/common/event'; import { IDisposable, toDisposable, DisposableStore, DisposableMap } from 'vs/base/common/lifecycle'; import { URI, UriComponents } from 'vs/base/common/uri'; -import { IFileWriteOptions, FileSystemProviderCapabilities, IFileChange, IFileService, IStat, IWatchOptions, FileType, IFileOverwriteOptions, IFileDeleteOptions, IFileOpenOptions, FileOperationError, FileOperationResult, FileSystemProviderErrorCode, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithFileFolderCopyCapability, FilePermission, toFileSystemProviderErrorCode, IFilesConfiguration, IFileStatWithPartialMetadata, IFileStat } from 'vs/platform/files/common/files'; +import { IFileWriteOptions, FileSystemProviderCapabilities, IFileChange, IFileService, IStat, IWatchOptions, FileType, IFileOverwriteOptions, IFileDeleteOptions, IFileOpenOptions, FileOperationError, FileOperationResult, FileSystemProviderErrorCode, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithFileFolderCopyCapability, FilePermission, toFileSystemProviderErrorCode, IFileStatWithPartialMetadata, IFileStat } from 'vs/platform/files/common/files'; import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; import { ExtHostContext, ExtHostFileSystemShape, IFileChangeDto, MainContext, MainThreadFileSystemShape } from '../common/extHost.protocol'; import { VSBuffer } from 'vs/base/common/buffer'; -import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { ILogService } from 'vs/platform/log/common/log'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IWorkbenchFileService } from 'vs/workbench/services/files/common/files'; -import { normalizeWatcherPattern } from 'vs/platform/files/common/watcher'; -import { GLOBSTAR } from 'vs/base/common/glob'; -import { rtrim } from 'vs/base/common/strings'; import { IMarkdownString } from 'vs/base/common/htmlContent'; @extHostNamedCustomer(MainContext.MainThreadFileSystem) @@ -25,14 +18,10 @@ export class MainThreadFileSystem implements MainThreadFileSystemShape { private readonly _proxy: ExtHostFileSystemShape; private readonly _fileProvider = new DisposableMap(); private readonly _disposables = new DisposableStore(); - private readonly _watches = new DisposableMap(); constructor( extHostContext: IExtHostContext, - @IWorkbenchFileService private readonly _fileService: IWorkbenchFileService, - @IWorkspaceContextService private readonly _contextService: IWorkspaceContextService, - @ILogService private readonly _logService: ILogService, - @IConfigurationService private readonly _configurationService: IConfigurationService + @IFileService private readonly _fileService: IFileService ) { this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostFileSystem); @@ -48,7 +37,6 @@ export class MainThreadFileSystem implements MainThreadFileSystemShape { dispose(): void { this._disposables.dispose(); this._fileProvider.dispose(); - this._watches.dispose(); } async $registerFileSystemProvider(handle: number, scheme: string, capabilities: FileSystemProviderCapabilities, readonlyMessage?: IMarkdownString): Promise { @@ -165,99 +153,7 @@ export class MainThreadFileSystem implements MainThreadFileSystemShape { return this._fileService.activateProvider(scheme); } - async $watch(extensionId: string, session: number, resource: UriComponents, unvalidatedOpts: IWatchOptions): Promise { - const uri = URI.revive(resource); - const workspaceFolder = this._contextService.getWorkspaceFolder(uri); - - const opts = { ...unvalidatedOpts }; - - // Convert a recursive watcher to a flat watcher if the path - // turns out to not be a folder. Recursive watching is only - // possible on folders, so we help all file watchers by checking - // early. - if (opts.recursive) { - try { - const stat = await this._fileService.stat(uri); - if (!stat.isDirectory) { - opts.recursive = false; - } - } catch (error) { - this._logService.error(`MainThreadFileSystem#$watch(): failed to stat a resource for file watching (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session}): ${error}`); - } - } - - // Refuse to watch anything that is already watched via - // our workspace watchers in case the request is a - // recursive file watcher. - // Still allow for non-recursive watch requests as a way - // to bypass configured exclude rules though - // (see https://github.com/microsoft/vscode/issues/146066) - if (workspaceFolder && opts.recursive) { - this._logService.trace(`MainThreadFileSystem#$watch(): ignoring request to start watching because path is inside workspace (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session})`); - return; - } - - this._logService.trace(`MainThreadFileSystem#$watch(): request to start watching (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session})`); - - // Automatically add `files.watcherExclude` patterns when watching - // recursively to give users a chance to configure exclude rules - // for reducing the overhead of watching recursively - if (opts.recursive) { - const config = this._configurationService.getValue(); - if (config.files?.watcherExclude) { - for (const key in config.files.watcherExclude) { - if (config.files.watcherExclude[key] === true) { - opts.excludes.push(key); - } - } - } - } - - // Non-recursive watching inside the workspace will overlap with - // our standard workspace watchers. To prevent duplicate events, - // we only want to include events for files that are otherwise - // excluded via `files.watcherExclude`. As such, we configure - // to include each configured exclude pattern so that only those - // events are reported that are otherwise excluded. - // However, we cannot just use the pattern as is, because a pattern - // such as `bar` for a exclude, will work to exclude any of - // `/bar` but will not work as include for files within - // `bar` unless a suffix of `/**` if added. - // (https://github.com/microsoft/vscode/issues/148245) - else if (workspaceFolder) { - const config = this._configurationService.getValue(); - if (config.files?.watcherExclude) { - for (const key in config.files.watcherExclude) { - if (config.files.watcherExclude[key] === true) { - if (!opts.includes) { - opts.includes = []; - } - - const includePattern = `${rtrim(key, '/')}/${GLOBSTAR}`; - opts.includes.push(normalizeWatcherPattern(workspaceFolder.uri.fsPath, includePattern)); - } - } - } - - // Still ignore watch request if there are actually no configured - // exclude rules, because in that case our default recursive watcher - // should be able to take care of all events. - if (!opts.includes || opts.includes.length === 0) { - this._logService.trace(`MainThreadFileSystem#$watch(): ignoring request to start watching because path is inside workspace and no excludes are configured (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session})`); - return; - } - } - const subscription = this._fileService.watch(uri, opts); - this._watches.set(session, subscription); - } - - $unwatch(session: number): void { - if (this._watches.has(session)) { - this._logService.trace(`MainThreadFileSystem#$unwatch(): request to stop watching (session: ${session})`); - this._watches.deleteAndDispose(session); - } - } } class RemoteFileSystemProvider implements IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileSystemProviderWithFileFolderCopyCapability { diff --git a/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts b/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts index b9d1f467a19e5..8ddb730dc169c 100644 --- a/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts +++ b/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DisposableStore } from 'vs/base/common/lifecycle'; -import { FileOperation, IFileService } from 'vs/platform/files/common/files'; -import { extHostCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; -import { ExtHostContext } from '../common/extHost.protocol'; +import { DisposableMap, DisposableStore } from 'vs/base/common/lifecycle'; +import { FileOperation, IFileService, IFilesConfiguration, IWatchOptions } from 'vs/platform/files/common/files'; +import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; +import { ExtHostContext, ExtHostFileSystemEventServiceShape, MainContext, MainThreadFileSystemEventServiceShape } from '../common/extHost.protocol'; import { localize } from 'vs/nls'; import { IWorkingCopyFileOperationParticipant, IWorkingCopyFileService, SourceTargetPair, IFileOperationUndoRedoInfo } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; import { IBulkEditService } from 'vs/editor/browser/services/bulkEditService'; @@ -22,17 +22,26 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; import { reviveWorkspaceEditDto } from 'vs/workbench/api/browser/mainThreadBulkEdits'; +import { GLOBSTAR } from 'vs/base/common/glob'; +import { rtrim } from 'vs/base/common/strings'; +import { UriComponents, URI } from 'vs/base/common/uri'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { normalizeWatcherPattern } from 'vs/platform/files/common/watcher'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -@extHostCustomer -export class MainThreadFileSystemEventService { +@extHostNamedCustomer(MainContext.MainThreadFileSystemEventService) +export class MainThreadFileSystemEventService implements MainThreadFileSystemEventServiceShape { static readonly MementoKeyAdditionalEdits = `file.particpants.additionalEdits`; + private readonly _proxy: ExtHostFileSystemEventServiceShape; + private readonly _listener = new DisposableStore(); + private readonly _watches = new DisposableMap(); constructor( extHostContext: IExtHostContext, - @IFileService fileService: IFileService, + @IFileService private readonly _fileService: IFileService, @IWorkingCopyFileService workingCopyFileService: IWorkingCopyFileService, @IBulkEditService bulkEditService: IBulkEditService, @IProgressService progressService: IProgressService, @@ -40,19 +49,22 @@ export class MainThreadFileSystemEventService { @IStorageService storageService: IStorageService, @ILogService logService: ILogService, @IEnvironmentService envService: IEnvironmentService, - @IUriIdentityService uriIdentService: IUriIdentityService + @IUriIdentityService uriIdentService: IUriIdentityService, + @IWorkspaceContextService private readonly _contextService: IWorkspaceContextService, + @ILogService private readonly _logService: ILogService, + @IConfigurationService private readonly _configurationService: IConfigurationService ) { + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostFileSystemEventService); - const proxy = extHostContext.getProxy(ExtHostContext.ExtHostFileSystemEventService); - - this._listener.add(fileService.onDidFilesChange(event => { - proxy.$onFileEvent({ + this._listener.add(_fileService.onDidFilesChange(event => { + this._proxy.$onFileEvent({ created: event.rawAdded, changed: event.rawUpdated, deleted: event.rawDeleted }); })); + const that = this; const fileOperationParticipant = new class implements IWorkingCopyFileOperationParticipant { async participate(files: SourceTargetPair[], operation: FileOperation, undoInfo: IFileOperationUndoRedoInfo | undefined, timeout: number, token: CancellationToken) { if (undoInfo?.isUndoing) { @@ -69,7 +81,7 @@ export class MainThreadFileSystemEventService { delay: Math.min(timeout / 2, 3000) }, () => { // race extension host event delivery against timeout AND user-cancel - const onWillEvent = proxy.$onWillRunFileOperation(operation, files, timeout, cts.token); + const onWillEvent = that._proxy.$onWillRunFileOperation(operation, files, timeout, cts.token); return raceCancellation(onWillEvent, cts.token); }, () => { // user-cancel @@ -197,11 +209,132 @@ export class MainThreadFileSystemEventService { this._listener.add(workingCopyFileService.addFileOperationParticipant(fileOperationParticipant)); // AFTER file operation - this._listener.add(workingCopyFileService.onDidRunWorkingCopyFileOperation(e => proxy.$onDidRunFileOperation(e.operation, e.files))); + this._listener.add(workingCopyFileService.onDidRunWorkingCopyFileOperation(e => this._proxy.$onDidRunFileOperation(e.operation, e.files))); + } + + async $watch(extensionId: string, session: number, resource: UriComponents, unvalidatedOpts: IWatchOptions): Promise { + const uri = URI.revive(resource); + const correlate = Array.isArray(unvalidatedOpts?.excludes) && unvalidatedOpts.excludes.length > 0; // TODO@bpasero for now only correlate proposed new file system watcher API with excludes + + const opts: IWatchOptions = { + ...unvalidatedOpts + }; + + // Convert a recursive watcher to a flat watcher if the path + // turns out to not be a folder. Recursive watching is only + // possible on folders, so we help all file watchers by checking + // early. + if (opts.recursive) { + try { + const stat = await this._fileService.stat(uri); + if (!stat.isDirectory) { + opts.recursive = false; + } + } catch (error) { + this._logService.error(`MainThreadFileSystemEventService#$watch(): failed to stat a resource for file watching (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session}): ${error}`); + } + } + + // Correlated file watching is taken as is + if (correlate) { + this._logService.trace(`MainThreadFileSystemEventService#$watch(): request to start watching correlated (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session})`); + + const watcherDisposables = new DisposableStore(); + const subscription = watcherDisposables.add(this._fileService.createWatcher(uri, opts)); + watcherDisposables.add(subscription.onDidChange(event => { + this._proxy.$onFileEvent({ + session, + created: event.rawAdded, + changed: event.rawUpdated, + deleted: event.rawDeleted + }); + })); + + this._watches.set(session, watcherDisposables); + } + + // Uncorrelated file watching gets special treatment + else { + + // Refuse to watch anything that is already watched via + // our workspace watchers in case the request is a + // recursive file watcher and does not opt-in to event + // correlation via specific exclude rules. + // Still allow for non-recursive watch requests as a way + // to bypass configured exclude rules though + // (see https://github.com/microsoft/vscode/issues/146066) + const workspaceFolder = this._contextService.getWorkspaceFolder(uri); + if (workspaceFolder && opts.recursive) { + this._logService.trace(`MainThreadFileSystemEventService#$watch(): ignoring request to start watching because path is inside workspace (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session})`); + return; + } + + this._logService.trace(`MainThreadFileSystemEventService#$watch(): request to start watching uncorrelated (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session})`); + + // Automatically add `files.watcherExclude` patterns when watching + // recursively to give users a chance to configure exclude rules + // for reducing the overhead of watching recursively + if (opts.recursive && opts.excludes.length === 0) { + const config = this._configurationService.getValue(); + if (config.files?.watcherExclude) { + for (const key in config.files.watcherExclude) { + if (config.files.watcherExclude[key] === true) { + opts.excludes.push(key); + } + } + } + } + + // Non-recursive watching inside the workspace will overlap with + // our standard workspace watchers. To prevent duplicate events, + // we only want to include events for files that are otherwise + // excluded via `files.watcherExclude`. As such, we configure + // to include each configured exclude pattern so that only those + // events are reported that are otherwise excluded. + // However, we cannot just use the pattern as is, because a pattern + // such as `bar` for a exclude, will work to exclude any of + // `/bar` but will not work as include for files within + // `bar` unless a suffix of `/**` if added. + // (https://github.com/microsoft/vscode/issues/148245) + else if (workspaceFolder) { + const config = this._configurationService.getValue(); + if (config.files?.watcherExclude) { + for (const key in config.files.watcherExclude) { + if (config.files.watcherExclude[key] === true) { + if (!opts.includes) { + opts.includes = []; + } + + const includePattern = `${rtrim(key, '/')}/${GLOBSTAR}`; + opts.includes.push(normalizeWatcherPattern(workspaceFolder.uri.fsPath, includePattern)); + } + } + } + + // Still ignore watch request if there are actually no configured + // exclude rules, because in that case our default recursive watcher + // should be able to take care of all events. + if (!opts.includes || opts.includes.length === 0) { + this._logService.trace(`MainThreadFileSystemEventService#$watch(): ignoring request to start watching because path is inside workspace and no excludes are configured (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session})`); + return; + } + } + + const subscription = this._fileService.watch(uri, opts); + this._watches.set(session, subscription); + } + } + + $unwatch(session: number): void { + if (this._watches.has(session)) { + this._logService.trace(`MainThreadFileSystemEventService#$unwatch(): request to stop watching (session: ${session})`); + this._watches.deleteAndDispose(session); + } } dispose(): void { this._listener.dispose(); + this._watches.dispose(); } } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 5765ab2eed04a..8840c9eb225bd 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -938,8 +938,21 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I applyEdit(edit: vscode.WorkspaceEdit, metadata?: vscode.WorkspaceEditMetadata): Thenable { return extHostBulkEdits.applyWorkspaceEdit(edit, extension, metadata); }, - createFileSystemWatcher: (pattern, ignoreCreate, ignoreChange, ignoreDelete): vscode.FileSystemWatcher => { - return extHostFileSystemEvent.createFileSystemWatcher(extHostWorkspace, extension, pattern, ignoreCreate, ignoreChange, ignoreDelete); + createFileSystemWatcher: (pattern, optionsOrIgnoreCreate, ignoreChange?, ignoreDelete?): vscode.FileSystemWatcher => { + let options: vscode.FileSystemWatcherOptions | undefined = undefined; + + if (typeof optionsOrIgnoreCreate === 'boolean') { + options = { + ignoreCreateEvents: Boolean(optionsOrIgnoreCreate), + ignoreChangeEvents: Boolean(ignoreChange), + ignoreDeleteEvents: Boolean(ignoreDelete) + }; + } else if (optionsOrIgnoreCreate) { + checkProposedApiEnabled(extension, 'createFileSystemWatcher'); + options = optionsOrIgnoreCreate; + } + + return extHostFileSystemEvent.createFileSystemWatcher(extHostWorkspace, extension, pattern, options); }, get textDocuments() { return extHostDocuments.getAllDocumentData().map(data => data.document); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index b09862426979d..4b9df6fd4ab99 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1310,10 +1310,12 @@ export interface MainThreadFileSystemShape extends IDisposable { $mkdir(resource: UriComponents): Promise; $delete(resource: UriComponents, opts: files.IFileDeleteOptions): Promise; + $ensureActivation(scheme: string): Promise; +} + +export interface MainThreadFileSystemEventServiceShape extends IDisposable { $watch(extensionId: string, session: number, resource: UriComponents, opts: files.IWatchOptions): void; $unwatch(session: number): void; - - $ensureActivation(scheme: string): Promise; } export interface MainThreadLabelServiceShape extends IDisposable { @@ -1762,6 +1764,7 @@ export interface ExtHostExtensionServiceShape { } export interface FileSystemEvents { + session?: number; created: UriComponents[]; changed: UriComponents[]; deleted: UriComponents[]; @@ -2700,6 +2703,7 @@ export const MainContext = { MainThreadProfileContentHandlers: createProxyIdentifier('MainThreadProfileContentHandlers'), MainThreadWorkspace: createProxyIdentifier('MainThreadWorkspace'), MainThreadFileSystem: createProxyIdentifier('MainThreadFileSystem'), + MainThreadFileSystemEventService: createProxyIdentifier('MainThreadFileSystemEventService'), MainThreadExtensionService: createProxyIdentifier('MainThreadExtensionService'), MainThreadSCM: createProxyIdentifier('MainThreadSCM'), MainThreadSearch: createProxyIdentifier('MainThreadSearch'), diff --git a/src/vs/workbench/api/common/extHostFileSystemEventService.ts b/src/vs/workbench/api/common/extHostFileSystemEventService.ts index fe4a679631256..c8c657977ba28 100644 --- a/src/vs/workbench/api/common/extHostFileSystemEventService.ts +++ b/src/vs/workbench/api/common/extHostFileSystemEventService.ts @@ -18,8 +18,18 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IExtHostWorkspace } from 'vs/workbench/api/common/extHostWorkspace'; import { Lazy } from 'vs/base/common/lazy'; +interface FileSystemWatcherCreateOptions { + readonly ignoreCreateEvents?: boolean; + readonly ignoreChangeEvents?: boolean; + readonly ignoreDeleteEvents?: boolean; + + readonly excludes?: string[]; +} + class FileSystemWatcher implements vscode.FileSystemWatcher { + private readonly session = Math.random(); + private readonly _onDidCreate = new Emitter(); private readonly _onDidChange = new Emitter(); private readonly _onDidDelete = new Emitter(); @@ -39,17 +49,15 @@ class FileSystemWatcher implements vscode.FileSystemWatcher { return Boolean(this._config & 0b100); } - constructor(mainContext: IMainContext, workspace: IExtHostWorkspace, extension: IExtensionDescription, dispatcher: Event, globPattern: string | IRelativePatternDto, ignoreCreateEvents?: boolean, ignoreChangeEvents?: boolean, ignoreDeleteEvents?: boolean) { - const watcherDisposable = this.ensureWatching(mainContext, extension, globPattern); - + constructor(mainContext: IMainContext, workspace: IExtHostWorkspace, extension: IExtensionDescription, dispatcher: Event, globPattern: string | IRelativePatternDto, options?: FileSystemWatcherCreateOptions) { this._config = 0; - if (ignoreCreateEvents) { + if (options?.ignoreCreateEvents) { this._config += 0b001; } - if (ignoreChangeEvents) { + if (options?.ignoreChangeEvents) { this._config += 0b010; } - if (ignoreDeleteEvents) { + if (options?.ignoreDeleteEvents) { this._config += 0b100; } @@ -63,7 +71,11 @@ class FileSystemWatcher implements vscode.FileSystemWatcher { const excludeOutOfWorkspaceEvents = typeof globPattern === 'string'; const subscription = dispatcher(events => { - if (!ignoreCreateEvents) { + if (typeof events.session === 'number' && events.session !== this.session) { + return; // ignore events from other file watchers + } + + if (!options?.ignoreCreateEvents) { for (const created of events.created) { const uri = URI.revive(created); if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) { @@ -71,7 +83,7 @@ class FileSystemWatcher implements vscode.FileSystemWatcher { } } } - if (!ignoreChangeEvents) { + if (!options?.ignoreChangeEvents) { for (const changed of events.changed) { const uri = URI.revive(changed); if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) { @@ -79,7 +91,7 @@ class FileSystemWatcher implements vscode.FileSystemWatcher { } } } - if (!ignoreDeleteEvents) { + if (!options?.ignoreDeleteEvents) { for (const deleted of events.deleted) { const uri = URI.revive(deleted); if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) { @@ -89,27 +101,26 @@ class FileSystemWatcher implements vscode.FileSystemWatcher { } }); - this._disposable = Disposable.from(watcherDisposable, this._onDidCreate, this._onDidChange, this._onDidDelete, subscription); + this._disposable = Disposable.from(this.ensureWatching(mainContext, extension, globPattern, options), this._onDidCreate, this._onDidChange, this._onDidDelete, subscription); } - private ensureWatching(mainContext: IMainContext, extension: IExtensionDescription, globPattern: string | IRelativePatternDto): Disposable { + private ensureWatching(mainContext: IMainContext, extension: IExtensionDescription, globPattern: string | IRelativePatternDto, options?: FileSystemWatcherCreateOptions): Disposable { const disposable = Disposable.from(); if (typeof globPattern === 'string') { - return disposable; // a pattern alone does not carry sufficient information to start watching anything + return disposable; // workspace is already watched by default, no need to watch again! } - const proxy = mainContext.getProxy(MainContext.MainThreadFileSystem); + const proxy = mainContext.getProxy(MainContext.MainThreadFileSystemEventService); let recursive = false; if (globPattern.pattern.includes(GLOBSTAR) || globPattern.pattern.includes(GLOB_SPLIT)) { recursive = true; // only watch recursively if pattern indicates the need for it } - const session = Math.random(); - proxy.$watch(extension.identifier.value, session, globPattern.baseUri, { recursive, excludes: [] /* excludes are not yet surfaced in the API */ }); + proxy.$watch(extension.identifier.value, this.session, globPattern.baseUri, { recursive, excludes: options?.excludes ?? [] }); - return Disposable.from({ dispose: () => proxy.$unwatch(session) }); + return Disposable.from({ dispose: () => proxy.$unwatch(this.session) }); } dispose() { @@ -138,6 +149,8 @@ class LazyRevivedFileSystemEvents implements FileSystemEvents { constructor(private readonly _events: FileSystemEvents) { } + readonly session = this._events.session; + private _created = new Lazy(() => this._events.created.map(URI.revive) as URI[]); get created(): URI[] { return this._created.value; } @@ -173,15 +186,14 @@ export class ExtHostFileSystemEventService implements ExtHostFileSystemEventServ //--- file events - createFileSystemWatcher(workspace: IExtHostWorkspace, extension: IExtensionDescription, globPattern: vscode.GlobPattern, ignoreCreateEvents?: boolean, ignoreChangeEvents?: boolean, ignoreDeleteEvents?: boolean): vscode.FileSystemWatcher { - return new FileSystemWatcher(this._mainContext, workspace, extension, this._onFileSystemEvent.event, typeConverter.GlobPattern.from(globPattern), ignoreCreateEvents, ignoreChangeEvents, ignoreDeleteEvents); + createFileSystemWatcher(workspace: IExtHostWorkspace, extension: IExtensionDescription, globPattern: vscode.GlobPattern, options?: FileSystemWatcherCreateOptions): vscode.FileSystemWatcher { + return new FileSystemWatcher(this._mainContext, workspace, extension, this._onFileSystemEvent.event, typeConverter.GlobPattern.from(globPattern), options); } $onFileEvent(events: FileSystemEvents) { this._onFileSystemEvent.fire(new LazyRevivedFileSystemEvents(events)); } - //--- file operations $onDidRunFileOperation(operation: FileOperation, files: SourceTargetPair[]): void { diff --git a/src/vs/workbench/api/test/browser/extHostFileSystemEventService.test.ts b/src/vs/workbench/api/test/browser/extHostFileSystemEventService.test.ts index 5063354ee5966..7c42f7e90eeac 100644 --- a/src/vs/workbench/api/test/browser/extHostFileSystemEventService.test.ts +++ b/src/vs/workbench/api/test/browser/extHostFileSystemEventService.test.ts @@ -22,13 +22,13 @@ suite('ExtHostFileSystemEventService', () => { drain: undefined! }; - const watcher1 = new ExtHostFileSystemEventService(protocol, new NullLogService(), undefined!).createFileSystemWatcher(undefined!, undefined!, '**/somethingInteresting', false, false, false); + const watcher1 = new ExtHostFileSystemEventService(protocol, new NullLogService(), undefined!).createFileSystemWatcher(undefined!, undefined!, '**/somethingInteresting', {}); assert.strictEqual(watcher1.ignoreChangeEvents, false); assert.strictEqual(watcher1.ignoreCreateEvents, false); assert.strictEqual(watcher1.ignoreDeleteEvents, false); watcher1.dispose(); - const watcher2 = new ExtHostFileSystemEventService(protocol, new NullLogService(), undefined!).createFileSystemWatcher(undefined!, undefined!, '**/somethingBoring', true, true, true); + const watcher2 = new ExtHostFileSystemEventService(protocol, new NullLogService(), undefined!).createFileSystemWatcher(undefined!, undefined!, '**/somethingBoring', { ignoreCreateEvents: true, ignoreChangeEvents: true, ignoreDeleteEvents: true }); assert.strictEqual(watcher2.ignoreChangeEvents, true); assert.strictEqual(watcher2.ignoreCreateEvents, true); assert.strictEqual(watcher2.ignoreDeleteEvents, true); diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index 03a5a2f4d7eab..0ec2e0388f0b9 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -20,7 +20,7 @@ import { RemoteAgentService } from 'vs/workbench/services/remote/browser/remoteA import { RemoteAuthorityResolverService } from 'vs/platform/remote/browser/remoteAuthorityResolverService'; import { IRemoteAuthorityResolverService, RemoteConnectionType } from 'vs/platform/remote/common/remoteAuthorityResolver'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; -import { IWorkbenchFileService } from 'vs/workbench/services/files/common/files'; +import { IFileService } from 'vs/platform/files/common/files'; import { FileService } from 'vs/platform/files/common/fileService'; import { Schemas, connectionTokenCookieName } from 'vs/base/common/network'; import { IAnyWorkspaceIdentifier, IWorkspaceContextService, UNKNOWN_EMPTY_WINDOW_WORKSPACE, isTemporaryWorkspace, isWorkspaceIdentifier } from 'vs/platform/workspace/common/workspace'; @@ -261,7 +261,7 @@ export class BrowserMain extends Disposable { // Files const fileLogger = new BufferLogger(); const fileService = this._register(new FileService(fileLogger)); - serviceCollection.set(IWorkbenchFileService, fileService); + serviceCollection.set(IFileService, fileService); // Logger const loggerService = new FileLoggerService(getLogLevel(environmentService), logsPath, fileService); @@ -429,7 +429,8 @@ export class BrowserMain extends Disposable { } } - private async registerIndexedDBFileSystemProviders(environmentService: IWorkbenchEnvironmentService, fileService: IWorkbenchFileService, logService: ILogService, loggerService: ILoggerService, logsPath: URI): Promise { + private async registerIndexedDBFileSystemProviders(environmentService: IWorkbenchEnvironmentService, fileService: IFileService, logService: ILogService, loggerService: ILoggerService, logsPath: URI): Promise { + // IndexedDB is used for logging and user data let indexedDB: IndexedDB | undefined; const userDataStore = 'vscode-userdata-store'; diff --git a/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts b/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts index afedb54aecd02..f9100a0eb4dc2 100644 --- a/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts +++ b/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts @@ -7,7 +7,7 @@ import { localize } from 'vs/nls'; 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 } from 'vs/platform/files/common/files'; +import { IFileService, IFilesConfiguration } from 'vs/platform/files/common/files'; import { IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent } from 'vs/platform/workspace/common/workspace'; import { ResourceMap } from 'vs/base/common/map'; import { INotificationService, Severity, NeverShowAgainScope, NotificationPriority } from 'vs/platform/notification/common/notification'; @@ -15,14 +15,13 @@ import { IOpenerService } from 'vs/platform/opener/common/opener'; import { isAbsolute } from 'vs/base/common/path'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; import { IHostService } from 'vs/workbench/services/host/browser/host'; -import { IWorkbenchFileService } from 'vs/workbench/services/files/common/files'; export class WorkspaceWatcher extends Disposable { private readonly watchedWorkspaces = new ResourceMap(resource => this.uriIdentityService.extUri.getComparisonKey(resource)); constructor( - @IWorkbenchFileService private readonly fileService: IWorkbenchFileService, + @IFileService private readonly fileService: IFileService, @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @INotificationService private readonly notificationService: INotificationService, diff --git a/src/vs/workbench/electron-sandbox/desktop.main.ts b/src/vs/workbench/electron-sandbox/desktop.main.ts index d6522c913f6bb..e202c0de9c170 100644 --- a/src/vs/workbench/electron-sandbox/desktop.main.ts +++ b/src/vs/workbench/electron-sandbox/desktop.main.ts @@ -29,7 +29,7 @@ import { IRemoteAuthorityResolverService, RemoteConnectionType } from 'vs/platfo import { RemoteAgentService } from 'vs/workbench/services/remote/electron-sandbox/remoteAgentService'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; import { FileService } from 'vs/platform/files/common/fileService'; -import { IWorkbenchFileService } from 'vs/workbench/services/files/common/files'; +import { IFileService } from 'vs/platform/files/common/files'; import { RemoteFileSystemProviderClient } from 'vs/workbench/services/remote/common/remoteFileSystemProviderClient'; import { ConfigurationCache } from 'vs/workbench/services/configuration/common/configurationCache'; import { ISignService } from 'vs/platform/sign/common/sign'; @@ -227,7 +227,7 @@ export class DesktopMain extends Disposable { // Files const fileService = this._register(new FileService(logService)); - serviceCollection.set(IWorkbenchFileService, fileService); + serviceCollection.set(IFileService, fileService); // Remote const remoteAuthorityResolverService = new RemoteAuthorityResolverService(productService, new ElectronRemoteResourceLoader(environmentService.window.id, mainProcessService, fileService)); diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index ac67c67fc86d3..18a609800a8ce 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -32,6 +32,7 @@ export const allApiProposals = Object.freeze({ contribStatusBarItems: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribStatusBarItems.d.ts', contribViewsRemote: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts', contribViewsWelcome: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribViewsWelcome.d.ts', + createFileSystemWatcher: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.createFileSystemWatcher.d.ts', customEditorMove: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorMove.d.ts', debugFocus: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.debugFocus.d.ts', diffCommand: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.diffCommand.d.ts', diff --git a/src/vs/workbench/services/files/common/files.ts b/src/vs/workbench/services/files/common/files.ts deleted file mode 100644 index e4bbbe5a32631..0000000000000 --- a/src/vs/workbench/services/files/common/files.ts +++ /dev/null @@ -1,22 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IDisposable } from 'vs/base/common/lifecycle'; -import { URI } from 'vs/base/common/uri'; -import { IFileService, IWatchOptions } from 'vs/platform/files/common/files'; -import { refineServiceDecorator } from 'vs/platform/instantiation/common/instantiation'; - -export const IWorkbenchFileService = refineServiceDecorator(IFileService); - -export interface IWorkbenchFileService extends IFileService { - - /** - * Allows to start a watcher that reports file/folder change events on the provided resource. - * - * Note: watching a folder does not report events recursively unless the provided options - * explicitly opt-in to recursive watching. - */ - watch(resource: URI, options?: IWatchOptions): IDisposable; -} diff --git a/src/vs/workbench/services/files/electron-sandbox/diskFileSystemProvider.ts b/src/vs/workbench/services/files/electron-sandbox/diskFileSystemProvider.ts index c933f179ff75d..bae365417f387 100644 --- a/src/vs/workbench/services/files/electron-sandbox/diskFileSystemProvider.ts +++ b/src/vs/workbench/services/files/electron-sandbox/diskFileSystemProvider.ts @@ -5,14 +5,14 @@ import { Event } from 'vs/base/common/event'; import { isLinux } from 'vs/base/common/platform'; -import { FileSystemProviderCapabilities, IFileDeleteOptions, IStat, FileType, IFileReadStreamOptions, IFileWriteOptions, IFileOpenOptions, IFileOverwriteOptions, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileAtomicReadCapability, IFileAtomicReadOptions, IFileSystemProviderWithFileCloneCapability } from 'vs/platform/files/common/files'; +import { FileSystemProviderCapabilities, IFileDeleteOptions, IStat, FileType, IFileReadStreamOptions, IFileWriteOptions, IFileOpenOptions, IFileOverwriteOptions, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileAtomicReadCapability, IFileAtomicReadOptions, IFileSystemProviderWithFileCloneCapability, IFileChange } from 'vs/platform/files/common/files'; import { AbstractDiskFileSystemProvider } from 'vs/platform/files/common/diskFileSystemProvider'; import { IMainProcessService } from 'vs/platform/ipc/common/mainProcessService'; import { CancellationToken } from 'vs/base/common/cancellation'; import { ReadableStreamEvents } from 'vs/base/common/stream'; import { URI } from 'vs/base/common/uri'; import { DiskFileSystemProviderClient, LOCAL_FILE_SYSTEM_CHANNEL_NAME } from 'vs/platform/files/common/diskFileSystemProviderClient'; -import { IDiskFileChange, ILogMessage, AbstractUniversalWatcherClient } from 'vs/platform/files/common/watcher'; +import { ILogMessage, AbstractUniversalWatcherClient } from 'vs/platform/files/common/watcher'; import { UniversalWatcherClient } from 'vs/workbench/services/files/electron-sandbox/watcherClient'; import { ILogService } from 'vs/platform/log/common/log'; import { IUtilityProcessWorkerWorkbenchService } from 'vs/workbench/services/utilityProcess/electron-sandbox/utilityProcessWorkerWorkbenchService'; @@ -132,7 +132,7 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple //#region File Watching protected createUniversalWatcher( - onChange: (changes: IDiskFileChange[]) => void, + onChange: (changes: IFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean ): AbstractUniversalWatcherClient { diff --git a/src/vs/workbench/services/files/electron-sandbox/watcherClient.ts b/src/vs/workbench/services/files/electron-sandbox/watcherClient.ts index 5f9237fd93024..524c7e3749eed 100644 --- a/src/vs/workbench/services/files/electron-sandbox/watcherClient.ts +++ b/src/vs/workbench/services/files/electron-sandbox/watcherClient.ts @@ -5,13 +5,14 @@ import { DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import { getDelayedChannel, ProxyChannel } from 'vs/base/parts/ipc/common/ipc'; -import { AbstractUniversalWatcherClient, IDiskFileChange, ILogMessage, IRecursiveWatcher } from 'vs/platform/files/common/watcher'; +import { IFileChange } from 'vs/platform/files/common/files'; +import { AbstractUniversalWatcherClient, ILogMessage, IRecursiveWatcher } from 'vs/platform/files/common/watcher'; import { IUtilityProcessWorkerWorkbenchService } from 'vs/workbench/services/utilityProcess/electron-sandbox/utilityProcessWorkerWorkbenchService'; export class UniversalWatcherClient extends AbstractUniversalWatcherClient { constructor( - onFileChanges: (changes: IDiskFileChange[]) => void, + onFileChanges: (changes: IFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean, private readonly utilityProcessWorkerWorkbenchService: IUtilityProcessWorkerWorkbenchService diff --git a/src/vs/workbench/services/textfile/browser/textFileService.ts b/src/vs/workbench/services/textfile/browser/textFileService.ts index ccbeb53cda4a0..b877f973eb6b8 100644 --- a/src/vs/workbench/services/textfile/browser/textFileService.ts +++ b/src/vs/workbench/services/textfile/browser/textFileService.ts @@ -45,9 +45,6 @@ import { Codicon } from 'vs/base/common/codicons'; import { listErrorForeground } from 'vs/platform/theme/common/colorRegistry'; import { firstOrDefault } from 'vs/base/common/arrays'; -/** - * The workbench file service implementation implements the raw file service spec and adds additional methods on top. - */ export abstract class AbstractTextFileService extends Disposable implements ITextFileService { declare readonly _serviceBrand: undefined; diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 8a1bd5f026d70..0633ed9829680 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -23,7 +23,7 @@ import { IUntitledTextEditorService, UntitledTextEditorService } from 'vs/workbe import { IWorkspaceContextService, IWorkspaceIdentifier } from 'vs/platform/workspace/common/workspace'; import { ILifecycleService, ShutdownReason, StartupKind, LifecyclePhase, WillShutdownEvent, BeforeShutdownErrorEvent, InternalBeforeShutdownEvent, IWillShutdownEventJoiner } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; -import { FileOperationEvent, IFileService, IFileStat, IFileStatResult, FileChangesEvent, IResolveFileOptions, ICreateFileOptions, IFileSystemProvider, FileSystemProviderCapabilities, IFileChange, IWatchOptions, IStat, FileType, IFileDeleteOptions, IFileOverwriteOptions, IFileWriteOptions, IFileOpenOptions, IFileStatWithMetadata, IResolveMetadataFileOptions, IWriteFileOptions, IReadFileOptions, IFileContent, IFileStreamContent, FileOperationError, IFileSystemProviderWithFileReadStreamCapability, IFileReadStreamOptions, IReadFileStreamOptions, IFileSystemProviderCapabilitiesChangeEvent, IFileStatWithPartialMetadata } from 'vs/platform/files/common/files'; +import { FileOperationEvent, IFileService, IFileStat, IFileStatResult, FileChangesEvent, IResolveFileOptions, ICreateFileOptions, IFileSystemProvider, FileSystemProviderCapabilities, IFileChange, IWatchOptions, IStat, FileType, IFileDeleteOptions, IFileOverwriteOptions, IFileWriteOptions, IFileOpenOptions, IFileStatWithMetadata, IResolveMetadataFileOptions, IWriteFileOptions, IReadFileOptions, IFileContent, IFileStreamContent, FileOperationError, IFileSystemProviderWithFileReadStreamCapability, IFileReadStreamOptions, IReadFileStreamOptions, IFileSystemProviderCapabilitiesChangeEvent, IFileStatWithPartialMetadata, IFileSystemWatcher, IWatchOptionsWithCorrelation } from 'vs/platform/files/common/files'; import { IModelService } from 'vs/editor/common/services/model'; import { LanguageService } from 'vs/editor/common/services/languageService'; import { ModelService } from 'vs/editor/common/services/modelService'; @@ -1148,7 +1148,17 @@ export class TestFileService implements IFileService { async del(_resource: URI, _options?: { useTrash?: boolean; recursive?: boolean }): Promise { } + createWatcher(resource: URI, options: IWatchOptions): IFileSystemWatcher { + return { + onDidChange: Event.None, + dispose: () => { } + }; + } + + readonly watches: URI[] = []; + watch(_resource: URI, options: IWatchOptionsWithCorrelation): IFileSystemWatcher; + watch(_resource: URI): IDisposable; watch(_resource: URI): IDisposable { this.watches.push(_resource); @@ -1368,7 +1378,7 @@ export class RemoteFileSystemProvider implements IFileSystemProvider { readonly capabilities: FileSystemProviderCapabilities = this.wrappedFsp.capabilities; readonly onDidChangeCapabilities: Event = this.wrappedFsp.onDidChangeCapabilities; - readonly onDidChangeFile: Event = Event.map(this.wrappedFsp.onDidChangeFile, changes => changes.map((c): IFileChange => { + readonly onDidChangeFile: Event = Event.map(this.wrappedFsp.onDidChangeFile, changes => changes.map(c => { return { type: c.type, resource: c.resource.with({ scheme: Schemas.vscodeRemote, authority: this.remoteAuthority }), diff --git a/src/vscode-dts/vscode.proposed.createFileSystemWatcher.d.ts b/src/vscode-dts/vscode.proposed.createFileSystemWatcher.d.ts new file mode 100644 index 0000000000000..d12daf6627108 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.createFileSystemWatcher.d.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://github.com/microsoft/vscode/issues/169724 @bpasero + +declare module 'vscode' { + + export interface FileSystemWatcherOptions { + + /** + * Ignore when files have been created. + */ + readonly ignoreCreateEvents?: boolean; + + /** + * Ignore when files have been changed. + */ + readonly ignoreChangeEvents?: boolean; + + /** + * Ignore when files have been deleted. + */ + readonly ignoreDeleteEvents?: boolean; + + /** + * An optional set of glob patterns to exclude from watching. + * Glob patterns are always matched relative to the watched folder. + */ + readonly excludes?: string[]; + } + + export namespace workspace { + + export function createFileSystemWatcher(pattern: RelativePattern, options?: FileSystemWatcherOptions): FileSystemWatcher; + } +}