/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Emitter, Event, AsyncEmitter, IWaitUntil, IWaitUntilData } from '../../../base/common/event.js'; import { GLOBSTAR, GLOB_SPLIT, IRelativePattern, parse } from '../../../base/common/glob.js'; import { URI } from '../../../base/common/uri.js'; import { ExtHostDocumentsAndEditors } from './extHostDocumentsAndEditors.js'; import type * as vscode from 'vscode'; import { ExtHostFileSystemEventServiceShape, FileSystemEvents, IMainContext, SourceTargetPair, IWorkspaceEditDto, IWillRunFileOperationParticipation, MainContext, IRelativePatternDto } from './extHost.protocol.js'; import * as typeConverter from './extHostTypeConverters.js'; import { Disposable, WorkspaceEdit } from './extHostTypes.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { FileChangeFilter, FileOperation, FileSystemProviderCapabilities, IGlobPatterns } from '../../../platform/files/common/files.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { IExtHostWorkspace } from './extHostWorkspace.js'; import { Lazy } from '../../../base/common/lazy.js'; import { ExtHostConfigProvider } from './extHostConfiguration.js'; import { rtrim } from '../../../base/common/strings.js'; import { normalizeWatcherPattern } from '../../../platform/files/common/watcher.js'; import { ExtHostFileSystemInfo } from './extHostFileSystemInfo.js'; import { Schemas } from '../../../base/common/network.js'; export interface FileSystemWatcherCreateOptions { readonly ignoreCreateEvents?: boolean; readonly ignoreChangeEvents?: boolean; readonly ignoreDeleteEvents?: boolean; } 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(); private _disposable: Disposable; private _config: number; get ignoreCreateEvents(): boolean { return Boolean(this._config & 0b001); } get ignoreChangeEvents(): boolean { return Boolean(this._config & 0b010); } get ignoreDeleteEvents(): boolean { return Boolean(this._config & 0b100); } constructor(mainContext: IMainContext, configuration: ExtHostConfigProvider, fileSystemInfo: ExtHostFileSystemInfo, workspace: IExtHostWorkspace, extension: IExtensionDescription, dispatcher: Event, globPattern: string | IRelativePatternDto, options: FileSystemWatcherCreateOptions) { this._config = 0; if (options.ignoreCreateEvents) { this._config += 0b001; } if (options.ignoreChangeEvents) { this._config += 0b010; } if (options.ignoreDeleteEvents) { this._config += 0b100; } const ignoreCase = typeof globPattern === 'string' ? !((fileSystemInfo.getCapabilities(Schemas.file) ?? 0) & FileSystemProviderCapabilities.PathCaseSensitive) : fileSystemInfo.extUri.ignorePathCasing(URI.revive(globPattern.baseUri)); const parsedPattern = parse(globPattern, { ignoreCase }); // 1.64.x behavior change: given the new support to watch any folder // we start to ignore events outside the workspace when only a string // pattern is provided to avoid sending events to extensions that are // unexpected. // https://github.com/microsoft/vscode/issues/3025 const excludeOutOfWorkspaceEvents = typeof globPattern === 'string'; // 1.84.x introduces new proposed API for a watcher to set exclude // rules. In these cases, we turn the file watcher into correlation // mode and ignore any event that does not match the correlation ID. // // Update (Feb 2025): proposal is discontinued, so the previous // `options.correlate` is always `false`. const excludeUncorrelatedEvents = false; const subscription = dispatcher(events => { if (typeof events.session === 'number' && events.session !== this.session) { return; // ignore events from other file watchers that are in correlation mode } if (excludeUncorrelatedEvents && typeof events.session === 'undefined') { return; // ignore events from other non-correlating file watcher when we are in correlation mode } if (!options.ignoreCreateEvents) { for (const created of events.created) { const uri = URI.revive(created); if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) { this._onDidCreate.fire(uri); } } } if (!options.ignoreChangeEvents) { for (const changed of events.changed) { const uri = URI.revive(changed); if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) { this._onDidChange.fire(uri); } } } if (!options.ignoreDeleteEvents) { for (const deleted of events.deleted) { const uri = URI.revive(deleted); if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) { this._onDidDelete.fire(uri); } } } }); this._disposable = Disposable.from(this.ensureWatching(mainContext, workspace, configuration, extension, globPattern, options, false), this._onDidCreate, this._onDidChange, this._onDidDelete, subscription); } private ensureWatching(mainContext: IMainContext, workspace: IExtHostWorkspace, configuration: ExtHostConfigProvider, extension: IExtensionDescription, globPattern: string | IRelativePatternDto, options: FileSystemWatcherCreateOptions, correlate: boolean | undefined): Disposable { const disposable = Disposable.from(); if (typeof globPattern === 'string') { return disposable; // workspace is already watched by default, no need to watch again! } if (options.ignoreChangeEvents && options.ignoreCreateEvents && options.ignoreDeleteEvents) { return disposable; // no need to watch if we ignore all events } 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 excludes = []; let includes: Array | undefined = undefined; let filter: FileChangeFilter | undefined; // Correlated: adjust filter based on arguments if (correlate) { if (options.ignoreChangeEvents || options.ignoreCreateEvents || options.ignoreDeleteEvents) { filter = FileChangeFilter.UPDATED | FileChangeFilter.ADDED | FileChangeFilter.DELETED; if (options.ignoreChangeEvents) { filter &= ~FileChangeFilter.UPDATED; } if (options.ignoreCreateEvents) { filter &= ~FileChangeFilter.ADDED; } if (options.ignoreDeleteEvents) { filter &= ~FileChangeFilter.DELETED; } } } // Uncorrelated: adjust includes and excludes based on settings else { // 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 (recursive && excludes.length === 0) { const workspaceFolder = workspace.getWorkspaceFolder(URI.revive(globPattern.baseUri)); const watcherExcludes = configuration.getConfiguration('files', workspaceFolder).get('watcherExclude'); if (watcherExcludes) { for (const key in watcherExcludes) { if (key && watcherExcludes[key] === true) { 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 (!recursive) { const workspaceFolder = workspace.getWorkspaceFolder(URI.revive(globPattern.baseUri)); if (workspaceFolder) { const watcherExcludes = configuration.getConfiguration('files', workspaceFolder).get('watcherExclude'); if (watcherExcludes) { for (const key in watcherExcludes) { if (key && watcherExcludes[key] === true) { const includePattern = `${rtrim(key, '/')}/${GLOBSTAR}`; if (!includes) { includes = []; } 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 (!includes || includes.length === 0) { return disposable; } } } } proxy.$watch(extension.identifier.value, this.session, globPattern.baseUri, { recursive, excludes, includes, filter }, Boolean(correlate)); return Disposable.from({ dispose: () => proxy.$unwatch(this.session) }); } dispose() { this._disposable.dispose(); } get onDidCreate(): Event { return this._onDidCreate.event; } get onDidChange(): Event { return this._onDidChange.event; } get onDidDelete(): Event { return this._onDidDelete.event; } } interface IExtensionListener { extension: IExtensionDescription; (e: E): any; } class LazyRevivedFileSystemEvents implements FileSystemEvents { readonly session: number | undefined; private _created = new Lazy(() => this._events.created.map(URI.revive) as URI[]); get created(): URI[] { return this._created.value; } private _changed = new Lazy(() => this._events.changed.map(URI.revive) as URI[]); get changed(): URI[] { return this._changed.value; } private _deleted = new Lazy(() => this._events.deleted.map(URI.revive) as URI[]); get deleted(): URI[] { return this._deleted.value; } constructor(private readonly _events: FileSystemEvents) { this.session = this._events.session; } } export class ExtHostFileSystemEventService implements ExtHostFileSystemEventServiceShape { private readonly _onFileSystemEvent = new Emitter(); private readonly _onDidRenameFile = new Emitter(); private readonly _onDidCreateFile = new Emitter(); private readonly _onDidDeleteFile = new Emitter(); private readonly _onWillRenameFile = new AsyncEmitter(); private readonly _onWillCreateFile = new AsyncEmitter(); private readonly _onWillDeleteFile = new AsyncEmitter(); readonly onDidRenameFile: Event = this._onDidRenameFile.event; readonly onDidCreateFile: Event = this._onDidCreateFile.event; readonly onDidDeleteFile: Event = this._onDidDeleteFile.event; constructor( private readonly _mainContext: IMainContext, private readonly _logService: ILogService, private readonly _extHostDocumentsAndEditors: ExtHostDocumentsAndEditors ) { // } //--- file events createFileSystemWatcher(workspace: IExtHostWorkspace, configProvider: ExtHostConfigProvider, fileSystemInfo: ExtHostFileSystemInfo, extension: IExtensionDescription, globPattern: vscode.GlobPattern, options: FileSystemWatcherCreateOptions): vscode.FileSystemWatcher { return new FileSystemWatcher(this._mainContext, configProvider, fileSystemInfo, 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 { switch (operation) { case FileOperation.MOVE: this._onDidRenameFile.fire(Object.freeze({ files: files.map(f => ({ oldUri: URI.revive(f.source!), newUri: URI.revive(f.target) })) })); break; case FileOperation.DELETE: this._onDidDeleteFile.fire(Object.freeze({ files: files.map(f => URI.revive(f.target)) })); break; case FileOperation.CREATE: case FileOperation.COPY: this._onDidCreateFile.fire(Object.freeze({ files: files.map(f => URI.revive(f.target)) })); break; default: //ignore, dont send } } getOnWillRenameFileEvent(extension: IExtensionDescription): Event { return this._createWillExecuteEvent(extension, this._onWillRenameFile); } getOnWillCreateFileEvent(extension: IExtensionDescription): Event { return this._createWillExecuteEvent(extension, this._onWillCreateFile); } getOnWillDeleteFileEvent(extension: IExtensionDescription): Event { return this._createWillExecuteEvent(extension, this._onWillDeleteFile); } private _createWillExecuteEvent(extension: IExtensionDescription, emitter: AsyncEmitter): Event { return (listener, thisArg, disposables) => { const wrappedListener: IExtensionListener = function wrapped(e: E) { listener.call(thisArg, e); }; wrappedListener.extension = extension; return emitter.event(wrappedListener, undefined, disposables); }; } async $onWillRunFileOperation(operation: FileOperation, files: SourceTargetPair[], timeout: number, token: CancellationToken): Promise { switch (operation) { case FileOperation.MOVE: return await this._fireWillEvent(this._onWillRenameFile, { files: files.map(f => ({ oldUri: URI.revive(f.source!), newUri: URI.revive(f.target) })) }, timeout, token); case FileOperation.DELETE: return await this._fireWillEvent(this._onWillDeleteFile, { files: files.map(f => URI.revive(f.target)) }, timeout, token); case FileOperation.CREATE: case FileOperation.COPY: return await this._fireWillEvent(this._onWillCreateFile, { files: files.map(f => URI.revive(f.target)) }, timeout, token); } return undefined; } private async _fireWillEvent(emitter: AsyncEmitter, data: IWaitUntilData, timeout: number, token: CancellationToken): Promise { const extensionNames = new Set(); const edits: [IExtensionDescription, WorkspaceEdit][] = []; await emitter.fireAsync(data, token, async (thenable: Promise, listener) => { // ignore all results except for WorkspaceEdits. Those are stored in an array. const now = Date.now(); const result = await Promise.resolve(thenable); if (result instanceof WorkspaceEdit) { edits.push([(>listener).extension, result]); extensionNames.add((>listener).extension.displayName ?? (>listener).extension.identifier.value); } if (Date.now() - now > timeout) { this._logService.warn('SLOW file-participant', (>listener).extension.identifier); } }); if (token.isCancellationRequested) { return undefined; } if (edits.length === 0) { return undefined; } // concat all WorkspaceEdits collected via waitUntil-call and send them over to the renderer const dto: IWorkspaceEditDto = { edits: [] }; for (const [, edit] of edits) { const { edits } = typeConverter.WorkspaceEdit.from(edit, { getTextDocumentVersion: uri => this._extHostDocumentsAndEditors.getDocument(uri)?.version, getNotebookDocumentVersion: () => undefined, }); dto.edits = dto.edits.concat(edits); } return { edit: dto, extensionNames: Array.from(extensionNames) }; } }