Path: blob/main/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts
3296 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 { DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js';6import { FileOperation, IFileService, IWatchOptions } from '../../../platform/files/common/files.js';7import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';8import { ExtHostContext, ExtHostFileSystemEventServiceShape, MainContext, MainThreadFileSystemEventServiceShape } from '../common/extHost.protocol.js';9import { localize } from '../../../nls.js';10import { IWorkingCopyFileOperationParticipant, IWorkingCopyFileService, SourceTargetPair, IFileOperationUndoRedoInfo } from '../../services/workingCopy/common/workingCopyFileService.js';11import { IBulkEditService } from '../../../editor/browser/services/bulkEditService.js';12import { IProgressService, ProgressLocation } from '../../../platform/progress/common/progress.js';13import { raceCancellation } from '../../../base/common/async.js';14import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';15import { IDialogService } from '../../../platform/dialogs/common/dialogs.js';16import Severity from '../../../base/common/severity.js';17import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js';18import { Action2, registerAction2 } from '../../../platform/actions/common/actions.js';19import { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js';20import { ILogService } from '../../../platform/log/common/log.js';21import { IEnvironmentService } from '../../../platform/environment/common/environment.js';22import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js';23import { reviveWorkspaceEditDto } from './mainThreadBulkEdits.js';24import { UriComponents, URI } from '../../../base/common/uri.js';2526@extHostNamedCustomer(MainContext.MainThreadFileSystemEventService)27export class MainThreadFileSystemEventService implements MainThreadFileSystemEventServiceShape {2829static readonly MementoKeyAdditionalEdits = `file.particpants.additionalEdits`;3031private readonly _proxy: ExtHostFileSystemEventServiceShape;3233private readonly _listener = new DisposableStore();34private readonly _watches = new DisposableMap<number>();3536constructor(37extHostContext: IExtHostContext,38@IFileService private readonly _fileService: IFileService,39@IWorkingCopyFileService workingCopyFileService: IWorkingCopyFileService,40@IBulkEditService bulkEditService: IBulkEditService,41@IProgressService progressService: IProgressService,42@IDialogService dialogService: IDialogService,43@IStorageService storageService: IStorageService,44@ILogService logService: ILogService,45@IEnvironmentService envService: IEnvironmentService,46@IUriIdentityService uriIdentService: IUriIdentityService,47@ILogService private readonly _logService: ILogService,48) {49this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostFileSystemEventService);5051this._listener.add(_fileService.onDidFilesChange(event => {52this._proxy.$onFileEvent({53created: event.rawAdded,54changed: event.rawUpdated,55deleted: event.rawDeleted56});57}));5859const that = this;60const fileOperationParticipant = new class implements IWorkingCopyFileOperationParticipant {61async participate(files: SourceTargetPair[], operation: FileOperation, undoInfo: IFileOperationUndoRedoInfo | undefined, timeout: number, token: CancellationToken) {62if (undoInfo?.isUndoing) {63return;64}6566const cts = new CancellationTokenSource(token);67const timer = setTimeout(() => cts.cancel(), timeout);6869const data = await progressService.withProgress({70location: ProgressLocation.Notification,71title: this._progressLabel(operation),72cancellable: true,73delay: Math.min(timeout / 2, 3000)74}, () => {75// race extension host event delivery against timeout AND user-cancel76const onWillEvent = that._proxy.$onWillRunFileOperation(operation, files, timeout, cts.token);77return raceCancellation(onWillEvent, cts.token);78}, () => {79// user-cancel80cts.cancel();8182}).finally(() => {83cts.dispose();84clearTimeout(timer);85});8687if (!data || data.edit.edits.length === 0) {88// cancelled, no reply, or no edits89return;90}9192const needsConfirmation = data.edit.edits.some(edit => edit.metadata?.needsConfirmation);93let showPreview = storageService.getBoolean(MainThreadFileSystemEventService.MementoKeyAdditionalEdits, StorageScope.PROFILE);9495if (envService.extensionTestsLocationURI) {96// don't show dialog in tests97showPreview = false;98}99100if (showPreview === undefined) {101// show a user facing message102103let message: string;104if (data.extensionNames.length === 1) {105if (operation === FileOperation.CREATE) {106message = localize('ask.1.create', "Extension '{0}' wants to make refactoring changes with this file creation", data.extensionNames[0]);107} else if (operation === FileOperation.COPY) {108message = localize('ask.1.copy', "Extension '{0}' wants to make refactoring changes with this file copy", data.extensionNames[0]);109} else if (operation === FileOperation.MOVE) {110message = localize('ask.1.move', "Extension '{0}' wants to make refactoring changes with this file move", data.extensionNames[0]);111} else /* if (operation === FileOperation.DELETE) */ {112message = localize('ask.1.delete', "Extension '{0}' wants to make refactoring changes with this file deletion", data.extensionNames[0]);113}114} else {115if (operation === FileOperation.CREATE) {116message = localize({ key: 'ask.N.create', comment: ['{0} is a number, e.g "3 extensions want..."'] }, "{0} extensions want to make refactoring changes with this file creation", data.extensionNames.length);117} else if (operation === FileOperation.COPY) {118message = localize({ key: 'ask.N.copy', comment: ['{0} is a number, e.g "3 extensions want..."'] }, "{0} extensions want to make refactoring changes with this file copy", data.extensionNames.length);119} else if (operation === FileOperation.MOVE) {120message = localize({ key: 'ask.N.move', comment: ['{0} is a number, e.g "3 extensions want..."'] }, "{0} extensions want to make refactoring changes with this file move", data.extensionNames.length);121} else /* if (operation === FileOperation.DELETE) */ {122message = localize({ key: 'ask.N.delete', comment: ['{0} is a number, e.g "3 extensions want..."'] }, "{0} extensions want to make refactoring changes with this file deletion", data.extensionNames.length);123}124}125126if (needsConfirmation) {127// edit which needs confirmation -> always show dialog128const { confirmed } = await dialogService.confirm({129type: Severity.Info,130message,131primaryButton: localize('preview', "Show &&Preview"),132cancelButton: localize('cancel', "Skip Changes")133});134showPreview = true;135if (!confirmed) {136// no changes wanted137return;138}139} else {140// choice141enum Choice {142OK = 0,143Preview = 1,144Cancel = 2145}146const { result, checkboxChecked } = await dialogService.prompt<Choice>({147type: Severity.Info,148message,149buttons: [150{151label: localize({ key: 'ok', comment: ['&& denotes a mnemonic'] }, "&&OK"),152run: () => Choice.OK153},154{155label: localize({ key: 'preview', comment: ['&& denotes a mnemonic'] }, "Show &&Preview"),156run: () => Choice.Preview157}158],159cancelButton: {160label: localize('cancel', "Skip Changes"),161run: () => Choice.Cancel162},163checkbox: { label: localize('again', "Do not ask me again") }164});165if (result === Choice.Cancel) {166// no changes wanted, don't persist cancel option167return;168}169showPreview = result === Choice.Preview;170if (checkboxChecked) {171storageService.store(MainThreadFileSystemEventService.MementoKeyAdditionalEdits, showPreview, StorageScope.PROFILE, StorageTarget.USER);172}173}174}175176logService.info('[onWill-handler] applying additional workspace edit from extensions', data.extensionNames);177178await bulkEditService.apply(179reviveWorkspaceEditDto(data.edit, uriIdentService),180{ undoRedoGroupId: undoInfo?.undoRedoGroupId, showPreview }181);182}183184private _progressLabel(operation: FileOperation): string {185switch (operation) {186case FileOperation.CREATE:187return localize('msg-create', "Running 'File Create' participants...");188case FileOperation.MOVE:189return localize('msg-rename', "Running 'File Rename' participants...");190case FileOperation.COPY:191return localize('msg-copy', "Running 'File Copy' participants...");192case FileOperation.DELETE:193return localize('msg-delete', "Running 'File Delete' participants...");194case FileOperation.WRITE:195return localize('msg-write', "Running 'File Write' participants...");196}197}198};199200// BEFORE file operation201this._listener.add(workingCopyFileService.addFileOperationParticipant(fileOperationParticipant));202203// AFTER file operation204this._listener.add(workingCopyFileService.onDidRunWorkingCopyFileOperation(e => this._proxy.$onDidRunFileOperation(e.operation, e.files)));205}206207async $watch(extensionId: string, session: number, resource: UriComponents, unvalidatedOpts: IWatchOptions, correlate: boolean): Promise<void> {208const uri = URI.revive(resource);209210const opts: IWatchOptions = {211...unvalidatedOpts212};213214// Convert a recursive watcher to a flat watcher if the path215// turns out to not be a folder. Recursive watching is only216// possible on folders, so we help all file watchers by checking217// early.218if (opts.recursive) {219try {220const stat = await this._fileService.stat(uri);221if (!stat.isDirectory) {222opts.recursive = false;223}224} catch (error) {225// ignore226}227}228229// Correlated file watching: use an exclusive `createWatcher()`230// Note: currently not enabled for extensions (but leaving in in case of future usage)231if (correlate && !opts.recursive) {232this._logService.trace(`MainThreadFileSystemEventService#$watch(): request to start watching correlated (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session}, excludes: ${JSON.stringify(opts.excludes)}, includes: ${JSON.stringify(opts.includes)})`);233234const watcherDisposables = new DisposableStore();235const subscription = watcherDisposables.add(this._fileService.createWatcher(uri, { ...opts, recursive: false }));236watcherDisposables.add(subscription.onDidChange(event => {237this._proxy.$onFileEvent({238session,239created: event.rawAdded,240changed: event.rawUpdated,241deleted: event.rawDeleted242});243}));244245this._watches.set(session, watcherDisposables);246}247248// Uncorrelated file watching: via shared `watch()`249else {250this._logService.trace(`MainThreadFileSystemEventService#$watch(): request to start watching uncorrelated (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session}, excludes: ${JSON.stringify(opts.excludes)}, includes: ${JSON.stringify(opts.includes)})`);251252const subscription = this._fileService.watch(uri, opts);253this._watches.set(session, subscription);254}255}256257$unwatch(session: number): void {258if (this._watches.has(session)) {259this._logService.trace(`MainThreadFileSystemEventService#$unwatch(): request to stop watching (session: ${session})`);260this._watches.deleteAndDispose(session);261}262}263264dispose(): void {265this._listener.dispose();266this._watches.dispose();267}268}269270registerAction2(class ResetMemento extends Action2 {271constructor() {272super({273id: 'files.participants.resetChoice',274title: {275value: localize('label', "Reset choice for 'File operation needs preview'"),276original: `Reset choice for 'File operation needs preview'`277},278f1: true279});280}281run(accessor: ServicesAccessor) {282accessor.get(IStorageService).remove(MainThreadFileSystemEventService.MementoKeyAdditionalEdits, StorageScope.PROFILE);283}284});285286287