Path: blob/main/src/vs/workbench/api/common/extHostFileSystemEventService.ts
5221 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Emitter, Event, AsyncEmitter, IWaitUntil, IWaitUntilData } from '../../../base/common/event.js';6import { GLOBSTAR, GLOB_SPLIT, IRelativePattern, parse } from '../../../base/common/glob.js';7import { URI } from '../../../base/common/uri.js';8import { ExtHostDocumentsAndEditors } from './extHostDocumentsAndEditors.js';9import type * as vscode from 'vscode';10import { ExtHostFileSystemEventServiceShape, FileSystemEvents, IMainContext, SourceTargetPair, IWorkspaceEditDto, IWillRunFileOperationParticipation, MainContext, IRelativePatternDto } from './extHost.protocol.js';11import * as typeConverter from './extHostTypeConverters.js';12import { Disposable, WorkspaceEdit } from './extHostTypes.js';13import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js';14import { FileChangeFilter, FileOperation, FileSystemProviderCapabilities, IGlobPatterns } from '../../../platform/files/common/files.js';15import { CancellationToken } from '../../../base/common/cancellation.js';16import { ILogService } from '../../../platform/log/common/log.js';17import { IExtHostWorkspace } from './extHostWorkspace.js';18import { Lazy } from '../../../base/common/lazy.js';19import { ExtHostConfigProvider } from './extHostConfiguration.js';20import { rtrim } from '../../../base/common/strings.js';21import { normalizeWatcherPattern } from '../../../platform/files/common/watcher.js';22import { ExtHostFileSystemInfo } from './extHostFileSystemInfo.js';23import { Schemas } from '../../../base/common/network.js';2425export interface FileSystemWatcherCreateOptions {26readonly ignoreCreateEvents?: boolean;27readonly ignoreChangeEvents?: boolean;28readonly ignoreDeleteEvents?: boolean;29}3031class FileSystemWatcher implements vscode.FileSystemWatcher {3233private readonly session = Math.random();3435private readonly _onDidCreate = new Emitter<vscode.Uri>();36private readonly _onDidChange = new Emitter<vscode.Uri>();37private readonly _onDidDelete = new Emitter<vscode.Uri>();3839private _disposable: Disposable;40private _config: number;4142get ignoreCreateEvents(): boolean {43return Boolean(this._config & 0b001);44}4546get ignoreChangeEvents(): boolean {47return Boolean(this._config & 0b010);48}4950get ignoreDeleteEvents(): boolean {51return Boolean(this._config & 0b100);52}5354constructor(mainContext: IMainContext, configuration: ExtHostConfigProvider, fileSystemInfo: ExtHostFileSystemInfo, workspace: IExtHostWorkspace, extension: IExtensionDescription, dispatcher: Event<FileSystemEvents>, globPattern: string | IRelativePatternDto, options: FileSystemWatcherCreateOptions) {55this._config = 0;56if (options.ignoreCreateEvents) {57this._config += 0b001;58}59if (options.ignoreChangeEvents) {60this._config += 0b010;61}62if (options.ignoreDeleteEvents) {63this._config += 0b100;64}6566const ignoreCase = typeof globPattern === 'string' ?67!((fileSystemInfo.getCapabilities(Schemas.file) ?? 0) & FileSystemProviderCapabilities.PathCaseSensitive) :68fileSystemInfo.extUri.ignorePathCasing(URI.revive(globPattern.baseUri));6970const parsedPattern = parse(globPattern, { ignoreCase });7172// 1.64.x behavior change: given the new support to watch any folder73// we start to ignore events outside the workspace when only a string74// pattern is provided to avoid sending events to extensions that are75// unexpected.76// https://github.com/microsoft/vscode/issues/302577const excludeOutOfWorkspaceEvents = typeof globPattern === 'string';7879// 1.84.x introduces new proposed API for a watcher to set exclude80// rules. In these cases, we turn the file watcher into correlation81// mode and ignore any event that does not match the correlation ID.82//83// Update (Feb 2025): proposal is discontinued, so the previous84// `options.correlate` is always `false`.85const excludeUncorrelatedEvents = false;8687const subscription = dispatcher(events => {88if (typeof events.session === 'number' && events.session !== this.session) {89return; // ignore events from other file watchers that are in correlation mode90}9192if (excludeUncorrelatedEvents && typeof events.session === 'undefined') {93return; // ignore events from other non-correlating file watcher when we are in correlation mode94}9596if (!options.ignoreCreateEvents) {97for (const created of events.created) {98const uri = URI.revive(created);99if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) {100this._onDidCreate.fire(uri);101}102}103}104if (!options.ignoreChangeEvents) {105for (const changed of events.changed) {106const uri = URI.revive(changed);107if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) {108this._onDidChange.fire(uri);109}110}111}112if (!options.ignoreDeleteEvents) {113for (const deleted of events.deleted) {114const uri = URI.revive(deleted);115if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) {116this._onDidDelete.fire(uri);117}118}119}120});121122this._disposable = Disposable.from(this.ensureWatching(mainContext, workspace, configuration, extension, globPattern, options, false), this._onDidCreate, this._onDidChange, this._onDidDelete, subscription);123}124125private ensureWatching(mainContext: IMainContext, workspace: IExtHostWorkspace, configuration: ExtHostConfigProvider, extension: IExtensionDescription, globPattern: string | IRelativePatternDto, options: FileSystemWatcherCreateOptions, correlate: boolean | undefined): Disposable {126const disposable = Disposable.from();127128if (typeof globPattern === 'string') {129return disposable; // workspace is already watched by default, no need to watch again!130}131132if (options.ignoreChangeEvents && options.ignoreCreateEvents && options.ignoreDeleteEvents) {133return disposable; // no need to watch if we ignore all events134}135136const proxy = mainContext.getProxy(MainContext.MainThreadFileSystemEventService);137138let recursive = false;139if (globPattern.pattern.includes(GLOBSTAR) || globPattern.pattern.includes(GLOB_SPLIT)) {140recursive = true; // only watch recursively if pattern indicates the need for it141}142143const excludes = [];144let includes: Array<string | IRelativePattern> | undefined = undefined;145let filter: FileChangeFilter | undefined;146147// Correlated: adjust filter based on arguments148if (correlate) {149if (options.ignoreChangeEvents || options.ignoreCreateEvents || options.ignoreDeleteEvents) {150filter = FileChangeFilter.UPDATED | FileChangeFilter.ADDED | FileChangeFilter.DELETED;151152if (options.ignoreChangeEvents) {153filter &= ~FileChangeFilter.UPDATED;154}155156if (options.ignoreCreateEvents) {157filter &= ~FileChangeFilter.ADDED;158}159160if (options.ignoreDeleteEvents) {161filter &= ~FileChangeFilter.DELETED;162}163}164}165166// Uncorrelated: adjust includes and excludes based on settings167else {168169// Automatically add `files.watcherExclude` patterns when watching170// recursively to give users a chance to configure exclude rules171// for reducing the overhead of watching recursively172if (recursive && excludes.length === 0) {173const workspaceFolder = workspace.getWorkspaceFolder(URI.revive(globPattern.baseUri));174const watcherExcludes = configuration.getConfiguration('files', workspaceFolder).get<IGlobPatterns>('watcherExclude');175if (watcherExcludes) {176for (const key in watcherExcludes) {177if (key && watcherExcludes[key] === true) {178excludes.push(key);179}180}181}182}183184// Non-recursive watching inside the workspace will overlap with185// our standard workspace watchers. To prevent duplicate events,186// we only want to include events for files that are otherwise187// excluded via `files.watcherExclude`. As such, we configure188// to include each configured exclude pattern so that only those189// events are reported that are otherwise excluded.190// However, we cannot just use the pattern as is, because a pattern191// such as `bar` for a exclude, will work to exclude any of192// `<workspace path>/bar` but will not work as include for files within193// `bar` unless a suffix of `/**` if added.194// (https://github.com/microsoft/vscode/issues/148245)195else if (!recursive) {196const workspaceFolder = workspace.getWorkspaceFolder(URI.revive(globPattern.baseUri));197if (workspaceFolder) {198const watcherExcludes = configuration.getConfiguration('files', workspaceFolder).get<IGlobPatterns>('watcherExclude');199if (watcherExcludes) {200for (const key in watcherExcludes) {201if (key && watcherExcludes[key] === true) {202const includePattern = `${rtrim(key, '/')}/${GLOBSTAR}`;203if (!includes) {204includes = [];205}206207includes.push(normalizeWatcherPattern(workspaceFolder.uri.fsPath, includePattern));208}209}210}211212// Still ignore watch request if there are actually no configured213// exclude rules, because in that case our default recursive watcher214// should be able to take care of all events.215if (!includes || includes.length === 0) {216return disposable;217}218}219}220}221222proxy.$watch(extension.identifier.value, this.session, globPattern.baseUri, { recursive, excludes, includes, filter }, Boolean(correlate));223224return Disposable.from({ dispose: () => proxy.$unwatch(this.session) });225}226227dispose() {228this._disposable.dispose();229}230231get onDidCreate(): Event<vscode.Uri> {232return this._onDidCreate.event;233}234235get onDidChange(): Event<vscode.Uri> {236return this._onDidChange.event;237}238239get onDidDelete(): Event<vscode.Uri> {240return this._onDidDelete.event;241}242}243244interface IExtensionListener<E> {245extension: IExtensionDescription;246(e: E): any;247}248249class LazyRevivedFileSystemEvents implements FileSystemEvents {250251readonly session: number | undefined;252253private _created = new Lazy(() => this._events.created.map(URI.revive) as URI[]);254get created(): URI[] { return this._created.value; }255256private _changed = new Lazy(() => this._events.changed.map(URI.revive) as URI[]);257get changed(): URI[] { return this._changed.value; }258259private _deleted = new Lazy(() => this._events.deleted.map(URI.revive) as URI[]);260get deleted(): URI[] { return this._deleted.value; }261262constructor(private readonly _events: FileSystemEvents) {263this.session = this._events.session;264}265}266267export class ExtHostFileSystemEventService implements ExtHostFileSystemEventServiceShape {268269private readonly _onFileSystemEvent = new Emitter<FileSystemEvents>();270271private readonly _onDidRenameFile = new Emitter<vscode.FileRenameEvent>();272private readonly _onDidCreateFile = new Emitter<vscode.FileCreateEvent>();273private readonly _onDidDeleteFile = new Emitter<vscode.FileDeleteEvent>();274private readonly _onWillRenameFile = new AsyncEmitter<vscode.FileWillRenameEvent>();275private readonly _onWillCreateFile = new AsyncEmitter<vscode.FileWillCreateEvent>();276private readonly _onWillDeleteFile = new AsyncEmitter<vscode.FileWillDeleteEvent>();277278readonly onDidRenameFile: Event<vscode.FileRenameEvent> = this._onDidRenameFile.event;279readonly onDidCreateFile: Event<vscode.FileCreateEvent> = this._onDidCreateFile.event;280readonly onDidDeleteFile: Event<vscode.FileDeleteEvent> = this._onDidDeleteFile.event;281282constructor(283private readonly _mainContext: IMainContext,284private readonly _logService: ILogService,285private readonly _extHostDocumentsAndEditors: ExtHostDocumentsAndEditors286) {287//288}289290//--- file events291292createFileSystemWatcher(workspace: IExtHostWorkspace, configProvider: ExtHostConfigProvider, fileSystemInfo: ExtHostFileSystemInfo, extension: IExtensionDescription, globPattern: vscode.GlobPattern, options: FileSystemWatcherCreateOptions): vscode.FileSystemWatcher {293return new FileSystemWatcher(this._mainContext, configProvider, fileSystemInfo, workspace, extension, this._onFileSystemEvent.event, typeConverter.GlobPattern.from(globPattern), options);294}295296$onFileEvent(events: FileSystemEvents) {297this._onFileSystemEvent.fire(new LazyRevivedFileSystemEvents(events));298}299300//--- file operations301302$onDidRunFileOperation(operation: FileOperation, files: SourceTargetPair[]): void {303switch (operation) {304case FileOperation.MOVE:305this._onDidRenameFile.fire(Object.freeze({ files: files.map(f => ({ oldUri: URI.revive(f.source!), newUri: URI.revive(f.target) })) }));306break;307case FileOperation.DELETE:308this._onDidDeleteFile.fire(Object.freeze({ files: files.map(f => URI.revive(f.target)) }));309break;310case FileOperation.CREATE:311case FileOperation.COPY:312this._onDidCreateFile.fire(Object.freeze({ files: files.map(f => URI.revive(f.target)) }));313break;314default:315//ignore, dont send316}317}318319320getOnWillRenameFileEvent(extension: IExtensionDescription): Event<vscode.FileWillRenameEvent> {321return this._createWillExecuteEvent(extension, this._onWillRenameFile);322}323324getOnWillCreateFileEvent(extension: IExtensionDescription): Event<vscode.FileWillCreateEvent> {325return this._createWillExecuteEvent(extension, this._onWillCreateFile);326}327328getOnWillDeleteFileEvent(extension: IExtensionDescription): Event<vscode.FileWillDeleteEvent> {329return this._createWillExecuteEvent(extension, this._onWillDeleteFile);330}331332private _createWillExecuteEvent<E extends IWaitUntil>(extension: IExtensionDescription, emitter: AsyncEmitter<E>): Event<E> {333return (listener, thisArg, disposables) => {334const wrappedListener: IExtensionListener<E> = function wrapped(e: E) { listener.call(thisArg, e); };335wrappedListener.extension = extension;336return emitter.event(wrappedListener, undefined, disposables);337};338}339340async $onWillRunFileOperation(operation: FileOperation, files: SourceTargetPair[], timeout: number, token: CancellationToken): Promise<IWillRunFileOperationParticipation | undefined> {341switch (operation) {342case FileOperation.MOVE:343return await this._fireWillEvent(this._onWillRenameFile, { files: files.map(f => ({ oldUri: URI.revive(f.source!), newUri: URI.revive(f.target) })) }, timeout, token);344case FileOperation.DELETE:345return await this._fireWillEvent(this._onWillDeleteFile, { files: files.map(f => URI.revive(f.target)) }, timeout, token);346case FileOperation.CREATE:347case FileOperation.COPY:348return await this._fireWillEvent(this._onWillCreateFile, { files: files.map(f => URI.revive(f.target)) }, timeout, token);349}350return undefined;351}352353private async _fireWillEvent<E extends IWaitUntil>(emitter: AsyncEmitter<E>, data: IWaitUntilData<E>, timeout: number, token: CancellationToken): Promise<IWillRunFileOperationParticipation | undefined> {354355const extensionNames = new Set<string>();356const edits: [IExtensionDescription, WorkspaceEdit][] = [];357358await emitter.fireAsync(data, token, async (thenable: Promise<unknown>, listener) => {359// ignore all results except for WorkspaceEdits. Those are stored in an array.360const now = Date.now();361const result = await Promise.resolve(thenable);362if (result instanceof WorkspaceEdit) {363edits.push([(<IExtensionListener<E>>listener).extension, result]);364extensionNames.add((<IExtensionListener<E>>listener).extension.displayName ?? (<IExtensionListener<E>>listener).extension.identifier.value);365}366367if (Date.now() - now > timeout) {368this._logService.warn('SLOW file-participant', (<IExtensionListener<E>>listener).extension.identifier);369}370});371372if (token.isCancellationRequested) {373return undefined;374}375376if (edits.length === 0) {377return undefined;378}379380// concat all WorkspaceEdits collected via waitUntil-call and send them over to the renderer381const dto: IWorkspaceEditDto = { edits: [] };382for (const [, edit] of edits) {383const { edits } = typeConverter.WorkspaceEdit.from(edit, {384getTextDocumentVersion: uri => this._extHostDocumentsAndEditors.getDocument(uri)?.version,385getNotebookDocumentVersion: () => undefined,386});387dto.edits = dto.edits.concat(edits);388}389return { edit: dto, extensionNames: Array.from(extensionNames) };390}391}392393394