// ***************************************************************************** // Copyright (C) 2020 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ // based on https://github.com/microsoft/vscode/blob/04c36be045a94fee58e5f8992d3e3fd980294a84/src/vs/platform/files/common/fileService.ts // and https://github.com/microsoft/vscode/blob/04c36be045a94fee58e5f8992d3e3fd980294a84/src/vs/workbench/services/textfile/browser/textFileService.ts // and https://github.com/microsoft/vscode/blob/04c36be045a94fee58e5f8992d3e3fd980294a84/src/vs/workbench/services/textfile/electron-browser/nativeTextFileService.ts // and https://github.com/microsoft/vscode/blob/04c36be045a94fee58e5f8992d3e3fd980294a84/src/vs/workbench/services/workingCopy/common/workingCopyFileService.ts // and https://github.com/microsoft/vscode/blob/04c36be045a94fee58e5f8992d3e3fd980294a84/src/vs/workbench/services/workingCopy/common/workingCopyFileOperationParticipant.ts /* eslint-disable max-len */ /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable no-null/no-null */ /* eslint-disable @typescript-eslint/tslint/config */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { injectable, inject, named, postConstruct } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { timeout, Deferred } from '@theia/core/lib/common/promise-util'; import { CancellationToken, CancellationTokenSource } from '@theia/core/lib/common/cancellation'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { WaitUntilEvent, Emitter, AsyncEmitter, Event } from '@theia/core/lib/common/event'; import { ContributionProvider } from '@theia/core/lib/common/contribution-provider'; import { TernarySearchTree } from '@theia/core/lib/common/ternary-search-tree'; import { ensureFileSystemProviderError, etag, ETAG_DISABLED, FileChangesEvent, FileOperation, FileOperationError, FileOperationEvent, FileOperationResult, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, hasFileFolderCopyCapability, hasOpenReadWriteCloseCapability, hasReadWriteCapability, CreateFileOptions, FileContent, FileStat, FileStatWithMetadata, FileStreamContent, FileSystemProvider, FileSystemProviderWithFileReadWriteCapability, FileSystemProviderWithOpenReadWriteCloseCapability, ReadFileOptions, ResolveFileOptions, ResolveMetadataFileOptions, Stat, WatchOptions, WriteFileOptions, toFileOperationResult, toFileSystemProviderErrorCode, ResolveFileResult, ResolveFileResultWithMetadata, MoveFileOptions, CopyFileOptions, BaseStatWithMetadata, FileDeleteOptions, FileOperationOptions, hasAccessCapability, hasUpdateCapability, hasFileReadStreamCapability, FileSystemProviderWithFileReadStreamCapability, ReadOnlyMessageFileSystemProvider } from '../common/files'; import { BinaryBuffer, BinaryBufferReadable, BinaryBufferReadableStream, BinaryBufferReadableBufferedStream, BinaryBufferWriteableStream } from '@theia/core/lib/common/buffer'; import { ReadableStream, isReadableStream, isReadableBufferedStream, transform, consumeStream, peekStream, peekReadable, Readable } from '@theia/core/lib/common/stream'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { FileSystemPreferences } from '../common/filesystem-preferences'; import { ProgressService } from '@theia/core/lib/common/progress-service'; import { DelegatingFileSystemProvider } from '../common/delegating-file-system-provider'; import type { TextDocumentContentChangeEvent } from '@theia/core/shared/vscode-languageserver-protocol'; import { EncodingRegistry } from '@theia/core/lib/browser/encoding-registry'; import { UTF8, UTF8_with_bom } from '@theia/core/lib/common/encodings'; import { EncodingService, ResourceEncoding, DecodeStreamResult } from '@theia/core/lib/common/encoding-service'; import { Mutable } from '@theia/core/lib/common/types'; import { readFileIntoStream } from '../common/io'; import { FileSystemWatcherErrorHandler } from './filesystem-watcher-error-handler'; import { FileSystemUtils } from '../common/filesystem-utils'; import { nls } from '@theia/core'; import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; export interface FileOperationParticipant { /** * Participate in a file operation of a working copy. Allows to * change the working copy before it is being saved to disk. */ participate( target: URI, source: URI | undefined, operation: FileOperation, timeout: number, token: CancellationToken ): Promise; } export interface ReadEncodingOptions { /** * The optional encoding parameter allows to specify the desired encoding when resolving * the contents of the file. */ encoding?: string; /** * The optional guessEncoding parameter allows to guess encoding from content of the file. */ autoGuessEncoding?: boolean; } export interface WriteEncodingOptions { /** * The encoding to use when updating a file. */ encoding?: string; /** * If set to true, will enforce the selected encoding and not perform any detection using BOMs. */ overwriteEncoding?: boolean; } export interface ReadTextFileOptions extends ReadEncodingOptions, ReadFileOptions { /** * The optional acceptTextOnly parameter allows to fail this request early if the file * contents are not textual. */ acceptTextOnly?: boolean; } interface BaseTextFileContent extends BaseStatWithMetadata { /** * The encoding of the content if known. */ encoding: string; } export interface TextFileContent extends BaseTextFileContent { /** * The content of a text file. */ value: string; } export interface TextFileStreamContent extends BaseTextFileContent { /** * The line grouped content of a text file. */ value: ReadableStream; } export interface CreateTextFileOptions extends WriteEncodingOptions, CreateFileOptions { } export interface WriteTextFileOptions extends WriteEncodingOptions, WriteFileOptions { } export interface UpdateTextFileOptions extends WriteEncodingOptions, WriteFileOptions { readEncoding: string } export interface UserFileOperationEvent extends WaitUntilEvent { /** * An identifier to correlate the operation through the * different event types (before, after, error). */ readonly correlationId: number; /** * The file operation that is taking place. */ readonly operation: FileOperation; /** * The resource the event is about. */ readonly target: URI; /** * A property that is defined for move operations. */ readonly source?: URI; } export const FileServiceContribution = Symbol('FileServiceContribution'); /** * A {@link FileServiceContribution} can be used to add custom {@link FileSystemProvider}s. * For this, the contribution has to listen to the {@link FileSystemProviderActivationEvent} and register * the custom {@link FileSystemProvider}s according to the scheme when this event is fired. * * ### Example usage * ```ts * export class MyFileServiceContribution implements FileServiceContribution { * registerFileSystemProviders(service: FileService): void { * service.onWillActivateFileSystemProvider(event => { * if (event.scheme === 'mySyncProviderScheme') { * service.registerProvider('mySyncProviderScheme', this.mySyncProvider); * } * if (event.scheme === 'myAsyncProviderScheme') { * event.waitUntil((async () => { * const myAsyncProvider = await this.createAsyncProvider(); * service.registerProvider('myAsyncProviderScheme', myAsyncProvider); * })()); * } * }); * * } *``` */ export interface FileServiceContribution { /** * Register custom file system providers for the given {@link FileService}. * @param service The file service for which the providers should be registered. */ registerFileSystemProviders(service: FileService): void; } /** * Represents the `FileSystemProviderRegistration` event. * This event is fired by the {@link FileService} if a {@link FileSystemProvider} is * registered to or unregistered from the service. */ export interface FileSystemProviderRegistrationEvent { /** `True` if a new provider has been registered, `false` if a provider has been unregistered. */ added: boolean; /** The (uri) scheme for which the provider was (previously) registered */ scheme: string; /** The affected file system provider for which this event was fired. */ provider?: FileSystemProvider; } /** * Represents the `FileSystemProviderCapabilitiesChange` event. * This event is fired by the {@link FileService} if the capabilities of one of its managed * {@link FileSystemProvider}s have changed. */ export interface FileSystemProviderCapabilitiesChangeEvent { /** The affected file system provider for which this event was fired. */ provider: FileSystemProvider; /** The (uri) scheme for which the provider is registered */ scheme: string; } export interface FileSystemProviderReadOnlyMessageChangeEvent { /** The affected file system provider for which this event was fired. */ provider: FileSystemProvider; /** The uri for which the provider is registered */ scheme: string; /** The new read only message */ message: MarkdownString | undefined; } /** * Represents the `FileSystemProviderActivation` event. * This event is fired by the {@link FileService} if it wants to activate the * {@link FileSystemProvider} for a specific scheme. */ export interface FileSystemProviderActivationEvent extends WaitUntilEvent { /** The (uri) scheme for which the provider should be activated */ scheme: string; } export const enum TextFileOperationResult { FILE_IS_BINARY } export class TextFileOperationError extends FileOperationError { constructor( message: string, public textFileOperationResult: TextFileOperationResult, override options?: ReadTextFileOptions & WriteTextFileOptions ) { super(message, FileOperationResult.FILE_OTHER_ERROR); Object.setPrototypeOf(this, TextFileOperationError.prototype); } } /** * The {@link FileService} is the common facade responsible for all interactions with file systems. * It manages all registered {@link FileSystemProvider}s and * forwards calls to the responsible {@link FileSystemProvider}, determined by the scheme. * For additional documentation regarding the provided functions see also {@link FileSystemProvider}. */ @injectable() export class FileService { private readonly BUFFER_SIZE = 64 * 1024; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(FileSystemPreferences) protected readonly preferences: FileSystemPreferences; @inject(ProgressService) protected readonly progressService: ProgressService; @inject(EncodingRegistry) protected readonly encodingRegistry: EncodingRegistry; @inject(EncodingService) protected readonly encodingService: EncodingService; @inject(ContributionProvider) @named(FileServiceContribution) protected readonly contributions: ContributionProvider; @inject(FileSystemWatcherErrorHandler) protected readonly watcherErrorHandler: FileSystemWatcherErrorHandler; @postConstruct() protected init(): void { for (const contribution of this.contributions.getContributions()) { contribution.registerFileSystemProviders(this); } } // #region Events private correlationIds = 0; private readonly onWillRunUserOperationEmitter = new AsyncEmitter(); /** * An event that is emitted when file operation is being performed. * This event is triggered by user gestures. */ readonly onWillRunUserOperation = this.onWillRunUserOperationEmitter.event; private readonly onDidFailUserOperationEmitter = new AsyncEmitter(); /** * An event that is emitted when file operation is failed. * This event is triggered by user gestures. */ readonly onDidFailUserOperation = this.onDidFailUserOperationEmitter.event; private readonly onDidRunUserOperationEmitter = new AsyncEmitter(); /** * An event that is emitted when file operation is finished. * This event is triggered by user gestures. */ readonly onDidRunUserOperation = this.onDidRunUserOperationEmitter.event; // #endregion // #region File System Provider private onDidChangeFileSystemProviderRegistrationsEmitter = new Emitter(); readonly onDidChangeFileSystemProviderRegistrations = this.onDidChangeFileSystemProviderRegistrationsEmitter.event; private onWillActivateFileSystemProviderEmitter = new Emitter(); /** * See `FileServiceContribution.registerProviders`. */ readonly onWillActivateFileSystemProvider = this.onWillActivateFileSystemProviderEmitter.event; private onDidChangeFileSystemProviderCapabilitiesEmitter = new Emitter(); readonly onDidChangeFileSystemProviderCapabilities = this.onDidChangeFileSystemProviderCapabilitiesEmitter.event; private onDidChangeFileSystemProviderReadOnlyMessageEmitter = new Emitter(); readonly onDidChangeFileSystemProviderReadOnlyMessage = this.onDidChangeFileSystemProviderReadOnlyMessageEmitter.event; private readonly providers = new Map(); private readonly activations = new Map>(); /** * Registers a new {@link FileSystemProvider} for the given scheme. * @param scheme The (uri) scheme for which the provider should be registered. * @param provider The file system provider that should be registered. * * @returns A `Disposable` that can be invoked to unregister the given provider. */ registerProvider(scheme: string, provider: FileSystemProvider): Disposable { if (this.providers.has(scheme)) { throw new Error(`A filesystem provider for the scheme '${scheme}' is already registered.`); } this.providers.set(scheme, provider); this.onDidChangeFileSystemProviderRegistrationsEmitter.fire({ added: true, scheme, provider }); const providerDisposables = new DisposableCollection(); providerDisposables.push(provider.onDidChangeFile(changes => this.onDidFilesChangeEmitter.fire(new FileChangesEvent(changes)))); providerDisposables.push(provider.onFileWatchError(() => this.handleFileWatchError())); providerDisposables.push(provider.onDidChangeCapabilities(() => this.onDidChangeFileSystemProviderCapabilitiesEmitter.fire({ provider, scheme }))); if (ReadOnlyMessageFileSystemProvider.is(provider)) { providerDisposables.push(provider.onDidChangeReadOnlyMessage(message => this.onDidChangeFileSystemProviderReadOnlyMessageEmitter.fire({ provider, scheme, message }))); } return Disposable.create(() => { this.onDidChangeFileSystemProviderRegistrationsEmitter.fire({ added: false, scheme, provider }); this.providers.delete(scheme); providerDisposables.dispose(); }); } /** * Try to activate the registered provider for the given scheme * @param scheme The uri scheme for which the responsible provider should be activated. * * @returns A promise of the activated file system provider. Only resolves if a provider is available for this scheme, gets rejected otherwise. */ async activateProvider(scheme: string): Promise { let provider = this.providers.get(scheme); if (provider) { return provider; } let activation = this.activations.get(scheme); if (!activation) { const deferredActivation = new Deferred(); this.activations.set(scheme, activation = deferredActivation.promise); WaitUntilEvent.fire(this.onWillActivateFileSystemProviderEmitter, { scheme }).then(() => { provider = this.providers.get(scheme); if (!provider) { const error = new Error(); error.name = 'ENOPRO'; error.message = `No file system provider found for scheme ${scheme}`; throw error; } else { deferredActivation.resolve(provider); } }).catch(e => deferredActivation.reject(e)); } return activation; } hasProvider(scheme: string): boolean { return this.providers.has(scheme); } /** * Tests if the service (i.e. any of its registered {@link FileSystemProvider}s) can handle the given resource. * @param resource `URI` of the resource to test. * * @returns `true` if the resource can be handled, `false` otherwise. */ canHandleResource(resource: URI): boolean { return this.providers.has(resource.scheme); } getReadOnlyMessage(resource: URI): MarkdownString | undefined { const provider = this.providers.get(resource.scheme); if (ReadOnlyMessageFileSystemProvider.is(provider)) { return provider.readOnlyMessage; } return undefined; } /** * Tests if the service (i.e the {@link FileSystemProvider} registered for the given uri scheme) provides the given capability. * @param resource `URI` of the resource to test. * @param capability The required capability. * * @returns `true` if the resource can be handled and the required capability can be provided. */ hasCapability(resource: URI, capability: FileSystemProviderCapabilities): boolean { const provider = this.providers.get(resource.scheme); return !!(provider && (provider.capabilities & capability)); } /** * List the schemes and capabilities for registered file system providers */ listCapabilities(): { scheme: string; capabilities: FileSystemProviderCapabilities }[] { return Array.from(this.providers.entries()).map(([scheme, provider]) => ({ scheme, capabilities: provider.capabilities })); } protected async withProvider(resource: URI): Promise { // Assert path is absolute if (!resource.path.isAbsolute) { throw new FileOperationError(nls.localizeByDefault("Unable to resolve filesystem provider with relative file path '{0}'", this.resourceForError(resource)), FileOperationResult.FILE_INVALID_PATH); } return this.activateProvider(resource.scheme); } private async withReadProvider(resource: URI): Promise { const provider = await this.withProvider(resource); if (hasOpenReadWriteCloseCapability(provider) || hasReadWriteCapability(provider)) { return provider; } throw new Error(`Filesystem provider for scheme '${resource.scheme}' neither has FileReadWrite, FileReadStream nor FileOpenReadWriteClose capability which is needed for the read operation.`); } private async withWriteProvider(resource: URI): Promise { const provider = await this.withProvider(resource); if (hasOpenReadWriteCloseCapability(provider) || hasReadWriteCapability(provider)) { return provider; } throw new Error(`Filesystem provider for scheme '${resource.scheme}' neither has FileReadWrite nor FileOpenReadWriteClose capability which is needed for the write operation.`); } // #endregion private onDidRunOperationEmitter = new Emitter(); /** * An event that is emitted when operation is finished. * This event is triggered by user gestures and programmatically. */ readonly onDidRunOperation = this.onDidRunOperationEmitter.event; /** * Try to resolve file information and metadata for the given resource. * @param resource `URI` of the resource that should be resolved. * @param options Options to customize the resolution process. * * @return A promise that resolves if the resource could be successfully resolved. */ resolve(resource: URI, options: ResolveMetadataFileOptions): Promise; resolve(resource: URI, options?: ResolveFileOptions | undefined): Promise; async resolve(resource: any, options?: any) { try { return await this.doResolveFile(resource, options); } catch (error) { // Specially handle file not found case as file operation result if (toFileSystemProviderErrorCode(error) === FileSystemProviderErrorCode.FileNotFound) { throw new FileOperationError(nls.localizeByDefault("Unable to resolve nonexistent file '{0}'", this.resourceForError(resource)), FileOperationResult.FILE_NOT_FOUND); } // Bubble up any other error as is throw ensureFileSystemProviderError(error); } } private async doResolveFile(resource: URI, options: ResolveMetadataFileOptions): Promise; private async doResolveFile(resource: URI, options?: ResolveFileOptions): Promise; private async doResolveFile(resource: URI, options?: ResolveFileOptions): Promise { const provider = await this.withProvider(resource); const resolveTo = options?.resolveTo; const resolveSingleChildDescendants = options?.resolveSingleChildDescendants; const resolveMetadata = options?.resolveMetadata; const stat = await provider.stat(resource); let trie: TernarySearchTree | undefined; return this.toFileStat(provider, resource, stat, undefined, !!resolveMetadata, (stat, siblings) => { // lazy trie to check for recursive resolving if (!trie) { trie = TernarySearchTree.forUris(!!(provider.capabilities & FileSystemProviderCapabilities.PathCaseSensitive)); trie.set(resource, true); if (Array.isArray(resolveTo) && resolveTo.length) { resolveTo.forEach(uri => trie!.set(uri, true)); } } // check for recursive resolving if (Boolean(trie.findSuperstr(stat.resource) || trie.get(stat.resource))) { return true; } // check for resolving single child folders if (stat.isDirectory && resolveSingleChildDescendants) { return siblings === 1; } return false; }); } private async toFileStat(provider: FileSystemProvider, resource: URI, stat: Stat | { type: FileType } & Partial, siblings: number | undefined, resolveMetadata: boolean, recurse: (stat: FileStat, siblings?: number) => boolean): Promise; private async toFileStat(provider: FileSystemProvider, resource: URI, stat: Stat, siblings: number | undefined, resolveMetadata: true, recurse: (stat: FileStat, siblings?: number) => boolean): Promise; private async toFileStat(provider: FileSystemProvider, resource: URI, stat: Stat | { type: FileType } & Partial, siblings: number | undefined, resolveMetadata: boolean, recurse: (stat: FileStat, siblings?: number) => boolean): Promise { const fileStat = FileStat.fromStat(resource, stat); // check to recurse for directories if (fileStat.isDirectory && recurse(fileStat, siblings)) { try { const entries = await provider.readdir(resource); const resolvedEntries = await Promise.all(entries.map(async ([name, type]) => { try { const childResource = resource.resolve(name); const childStat = resolveMetadata ? await provider.stat(childResource) : { type }; return await this.toFileStat(provider, childResource, childStat, entries.length, resolveMetadata, recurse); } catch (error) { console.trace(error); return null; // can happen e.g. due to permission errors } })); // make sure to get rid of null values that signal a failure to resolve a particular entry fileStat.children = resolvedEntries.filter(e => !!e) as FileStat[]; } catch (error) { console.trace(error); fileStat.children = []; // gracefully handle errors, we may not have permissions to read } return fileStat; } return fileStat; } /** * Try to resolve file information and metadata for all given resource. * @param toResolve An array of all the resources (and corresponding resolution options) that should be resolved. * * @returns A promise of all resolved resources. The promise is not rejected if any of the given resources cannot be resolved. * Instead this is reflected with the `success` flag of the corresponding {@link ResolveFileResult}. */ async resolveAll(toResolve: { resource: URI, options?: ResolveFileOptions }[]): Promise; async resolveAll(toResolve: { resource: URI, options: ResolveMetadataFileOptions }[]): Promise; async resolveAll(toResolve: { resource: URI; options?: ResolveFileOptions; }[]): Promise { return Promise.all(toResolve.map(async entry => { try { return { stat: await this.doResolveFile(entry.resource, entry.options), success: true }; } catch (error) { console.trace(error); return { stat: undefined, success: false }; } })); } /** * Tests if the given resource exists in the filesystem. * @param resource `URI` of the resource which should be tested. * @throws Will throw an error if no {@link FileSystemProvider} is registered for the given resource. * * @returns A promise that resolves to `true` if the resource exists. */ async exists(resource: URI): Promise { const provider = await this.withProvider(resource); try { const stat = await provider.stat(resource); return !!stat; } catch (error) { return false; } } /** * Tests a user's permissions for the given resource. * @param resource `URI` of the resource which should be tested. * @param mode An optional integer that specifies the accessibility checks to be performed. * Check `FileAccess.Constants` for possible values of mode. * It is possible to create a mask consisting of the bitwise `OR` of two or more values (e.g. FileAccess.Constants.W_OK | FileAccess.Constants.R_OK). * If `mode` is not defined, `FileAccess.Constants.F_OK` will be used instead. */ async access(resource: URI, mode?: number): Promise { const provider = await this.withProvider(resource); if (!hasAccessCapability(provider)) { return false; } try { await provider.access(resource, mode); return true; } catch (error) { return false; } } /** * Resolves the fs path of the given URI. * * USE WITH CAUTION: You should always prefer URIs to paths if possible, as they are * portable and platform independent. Paths should only be used in cases you directly * interact with the OS, e.g. when running a command on the shell. * * If you need to display human readable simple or long names then use `LabelProvider` instead. * @param resource `URI` of the resource that should be resolved. * @throws Will throw an error if no {@link FileSystemProvider} is registered for the given resource. * * @returns A promise of the resolved fs path. */ async fsPath(resource: URI): Promise { const provider = await this.withProvider(resource); if (!hasAccessCapability(provider)) { return resource.path.toString(); } return provider.fsPath(resource); } // #region Text File Reading/Writing async create(resource: URI, value?: string | Readable, options?: CreateTextFileOptions): Promise { if (options?.fromUserGesture === false) { return this.doCreate(resource, value, options); } await this.runFileOperationParticipants(resource, undefined, FileOperation.CREATE); const event = { correlationId: this.correlationIds++, operation: FileOperation.CREATE, target: resource }; await this.onWillRunUserOperationEmitter.fire(event); let stat: FileStatWithMetadata; try { stat = await this.doCreate(resource, value, options); } catch (error) { await this.onDidFailUserOperationEmitter.fire(event); throw error; } await this.onDidRunUserOperationEmitter.fire(event); return stat; } protected async doCreate(resource: URI, value?: string | Readable, options?: CreateTextFileOptions): Promise { const encoding = await this.getWriteEncoding(resource, options); const encoded = await this.encodingService.encodeStream(value, encoding); return this.createFile(resource, encoded, options); } async write(resource: URI, value: string | Readable, options?: WriteTextFileOptions): Promise { const encoding = await this.getWriteEncoding(resource, options); const encoded = await this.encodingService.encodeStream(value, encoding); return Object.assign(await this.writeFile(resource, encoded, options), { encoding: encoding.encoding }); } async read(resource: URI, options?: ReadTextFileOptions): Promise { const [bufferStream, decoder] = await this.doRead(resource, { ...options, preferUnbuffered: this.shouldReadUnbuffered(options) }); return { ...bufferStream, encoding: decoder.detected.encoding || UTF8, value: await consumeStream(decoder.stream, strings => strings.join('')) }; } async readStream(resource: URI, options?: ReadTextFileOptions): Promise { const [bufferStream, decoder] = await this.doRead(resource, options); return { ...bufferStream, encoding: decoder.detected.encoding || UTF8, value: decoder.stream }; } private async doRead(resource: URI, options?: ReadTextFileOptions & { preferUnbuffered?: boolean }): Promise<[FileStreamContent, DecodeStreamResult]> { options = this.resolveReadOptions(options); // read stream raw (either buffered or unbuffered) let bufferStream: FileStreamContent; if (options?.preferUnbuffered) { const content = await this.readFile(resource, options); bufferStream = { ...content, value: BinaryBufferReadableStream.fromBuffer(content.value) }; } else { bufferStream = await this.readFileStream(resource, options); } const decoder = await this.encodingService.decodeStream(bufferStream.value, { guessEncoding: options.autoGuessEncoding, overwriteEncoding: detectedEncoding => this.getReadEncoding(resource, options, detectedEncoding) }); // validate binary if (options?.acceptTextOnly && decoder.detected.seemsBinary) { throw new TextFileOperationError(nls.localizeByDefault('File seems to be binary and cannot be opened as text'), TextFileOperationResult.FILE_IS_BINARY, options); } return [bufferStream, decoder]; } protected resolveReadOptions(options?: ReadTextFileOptions): ReadTextFileOptions { options = { ...options, autoGuessEncoding: typeof options?.autoGuessEncoding === 'boolean' ? options.autoGuessEncoding : this.preferences['files.autoGuessEncoding'] }; const limits: Mutable = options.limits = options.limits || {}; if (typeof limits.size !== 'number') { limits.size = this.preferences['files.maxFileSizeMB'] * 1024 * 1024; } return options; } async update(resource: URI, changes: TextDocumentContentChangeEvent[], options: UpdateTextFileOptions): Promise { const provider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(resource), resource); try { await this.validateWriteFile(provider, resource, options); if (hasUpdateCapability(provider)) { const encoding = await this.getEncodingForResource(resource, options ? options.encoding : undefined);; const stat = await provider.updateFile(resource, changes, { readEncoding: options.readEncoding, writeEncoding: encoding, overwriteEncoding: options.overwriteEncoding || false }); return Object.assign(FileStat.fromStat(resource, stat), { encoding: stat.encoding }); } else { throw new Error('incremental file update is not supported'); } } catch (error) { this.rethrowAsFileOperationError("Unable to write file '{0}' ({1})", resource, error, options); } } // #endregion // #region File Reading/Writing async createFile(resource: URI, bufferOrReadableOrStream: BinaryBuffer | BinaryBufferReadable | BinaryBufferReadableStream = BinaryBuffer.fromString(''), options?: CreateFileOptions): Promise { // validate overwrite if (!options?.overwrite && await this.exists(resource)) { throw new FileOperationError(nls.localizeByDefault("Unable to create file '{0}' that already exists when overwrite flag is not set", this.resourceForError(resource)), FileOperationResult.FILE_MODIFIED_SINCE, options); } // do write into file (this will create it too) const fileStat = await this.writeFile(resource, bufferOrReadableOrStream); // events this.onDidRunOperationEmitter.fire(new FileOperationEvent(resource, FileOperation.CREATE, fileStat)); return fileStat; } async writeFile(resource: URI, bufferOrReadableOrStream: BinaryBuffer | BinaryBufferReadable | BinaryBufferReadableStream, options?: WriteFileOptions): Promise { const provider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(resource), resource); try { // validate write const stat = await this.validateWriteFile(provider, resource, options); // mkdir recursively as needed if (!stat) { await this.mkdirp(provider, resource.parent); } // optimization: if the provider has unbuffered write capability and the data // to write is a Readable, we consume up to 3 chunks and try to write the data // unbuffered to reduce the overhead. If the Readable has more data to provide // we continue to write buffered. let bufferOrReadableOrStreamOrBufferedStream: BinaryBuffer | BinaryBufferReadable | BinaryBufferReadableStream | BinaryBufferReadableBufferedStream; if (hasReadWriteCapability(provider) && !(bufferOrReadableOrStream instanceof BinaryBuffer)) { if (isReadableStream(bufferOrReadableOrStream)) { const bufferedStream = await peekStream(bufferOrReadableOrStream, 3); if (bufferedStream.ended) { bufferOrReadableOrStreamOrBufferedStream = BinaryBuffer.concat(bufferedStream.buffer); } else { bufferOrReadableOrStreamOrBufferedStream = bufferedStream; } } else { bufferOrReadableOrStreamOrBufferedStream = peekReadable(bufferOrReadableOrStream, data => BinaryBuffer.concat(data), 3); } } else { bufferOrReadableOrStreamOrBufferedStream = bufferOrReadableOrStream; } // write file: unbuffered (only if data to write is a buffer, or the provider has no buffered write capability) if (!hasOpenReadWriteCloseCapability(provider) || (hasReadWriteCapability(provider) && bufferOrReadableOrStreamOrBufferedStream instanceof BinaryBuffer)) { await this.doWriteUnbuffered(provider, resource, bufferOrReadableOrStreamOrBufferedStream); } // write file: buffered else { await this.doWriteBuffered(provider, resource, bufferOrReadableOrStreamOrBufferedStream instanceof BinaryBuffer ? BinaryBufferReadable.fromBuffer(bufferOrReadableOrStreamOrBufferedStream) : bufferOrReadableOrStreamOrBufferedStream); } } catch (error) { this.rethrowAsFileOperationError("Unable to write file '{0}' ({1})", resource, error, options); } return this.resolve(resource, { resolveMetadata: true }); } private async validateWriteFile(provider: FileSystemProvider, resource: URI, options?: WriteFileOptions): Promise { let stat: Stat | undefined = undefined; try { stat = await provider.stat(resource); } catch (error) { return undefined; // file might not exist } // file cannot be directory if ((stat.type & FileType.Directory) !== 0) { throw new FileOperationError(nls.localizeByDefault("Unable to write file '{0}' that is actually a directory", this.resourceForError(resource)), FileOperationResult.FILE_IS_DIRECTORY, options); } if (this.modifiedSince(stat, options)) { throw new FileOperationError(nls.localizeByDefault('File Modified Since'), FileOperationResult.FILE_MODIFIED_SINCE, options); } return stat; } /** * Dirty write prevention: if the file on disk has been changed and does not match our expected * mtime and etag, we bail out to prevent dirty writing. * * First, we check for a mtime that is in the future before we do more checks. The assumption is * that only the mtime is an indicator for a file that has changed on disk. * * Second, if the mtime has advanced, we compare the size of the file on disk with our previous * one using the etag() function. Relying only on the mtime check has proven to produce false * positives due to file system weirdness (especially around remote file systems). As such, the * check for size is a weaker check because it can return a false negative if the file has changed * but to the same length. This is a compromise we take to avoid having to produce checksums of * the file content for comparison which would be much slower to compute. */ protected modifiedSince(stat: Stat, options?: WriteFileOptions): boolean { return !!options && typeof options.mtime === 'number' && typeof options.etag === 'string' && options.etag !== ETAG_DISABLED && typeof stat.mtime === 'number' && typeof stat.size === 'number' && options.mtime < stat.mtime && options.etag !== etag({ mtime: options.mtime /* not using stat.mtime for a reason, see above */, size: stat.size }); } protected shouldReadUnbuffered(options?: ReadFileOptions): boolean { // optimization: since we know that the caller does not // care about buffering, we indicate this to the reader. // this reduces all the overhead the buffered reading // has (open, read, close) if the provider supports // unbuffered reading. // // However, if we read only part of the file we still // want buffered reading as otherwise we need to read // the whole file and cut out the specified part later. return options?.position === undefined && options?.length === undefined; } async readFile(resource: URI, options?: ReadFileOptions): Promise { const provider = await this.withReadProvider(resource); const stream = await this.doReadAsFileStream(provider, resource, { ...options, preferUnbuffered: this.shouldReadUnbuffered(options) }); return { ...stream, value: await BinaryBufferReadableStream.toBuffer(stream.value) }; } async readFileStream(resource: URI, options?: ReadFileOptions): Promise { const provider = await this.withReadProvider(resource); return this.doReadAsFileStream(provider, resource, options); } private async doReadAsFileStream(provider: FileSystemProviderWithFileReadWriteCapability | FileSystemProviderWithOpenReadWriteCloseCapability, resource: URI, options?: ReadFileOptions & { preferUnbuffered?: boolean }): Promise { // install a cancellation token that gets cancelled // when any error occurs. this allows us to resolve // the content of the file while resolving metadata // but still cancel the operation in certain cases. const cancellableSource = new CancellationTokenSource(); // validate read operation const statPromise = this.validateReadFile(resource, options).then(stat => stat, error => { cancellableSource.cancel(); throw error; }); try { // if the etag is provided, we await the result of the validation // due to the likelyhood of hitting a NOT_MODIFIED_SINCE result. // otherwise, we let it run in parallel to the file reading for // optimal startup performance. if (options && typeof options.etag === 'string' && options.etag !== ETAG_DISABLED) { await statPromise; } let fileStreamPromise: Promise; // read unbuffered (only if either preferred, or the provider has no buffered read capability) if (!(hasOpenReadWriteCloseCapability(provider) || hasFileReadStreamCapability(provider)) || (hasReadWriteCapability(provider) && options?.preferUnbuffered)) { fileStreamPromise = this.readFileUnbuffered(provider, resource, options); } // read streamed (always prefer over primitive buffered read) else if (hasFileReadStreamCapability(provider)) { fileStreamPromise = Promise.resolve(this.readFileStreamed(provider, resource, cancellableSource.token, options)); } // read buffered else { fileStreamPromise = Promise.resolve(this.readFileBuffered(provider, resource, cancellableSource.token, options)); } const [fileStat, fileStream] = await Promise.all([statPromise, fileStreamPromise]); return { ...fileStat, value: fileStream }; } catch (error) { this.rethrowAsFileOperationError("Unable to read file '{0}' ({1})", resource, error, options); } } private readFileStreamed(provider: FileSystemProviderWithFileReadStreamCapability, resource: URI, token: CancellationToken, options: ReadFileOptions = Object.create(null)): BinaryBufferReadableStream { const fileStream = provider.readFileStream(resource, options, token); return transform(fileStream, { data: data => data instanceof BinaryBuffer ? data : BinaryBuffer.wrap(data), error: error => this.asFileOperationError("Unable to read file '{0}' ({1})", resource, error, options) }, data => BinaryBuffer.concat(data)); } private readFileBuffered(provider: FileSystemProviderWithOpenReadWriteCloseCapability, resource: URI, token: CancellationToken, options: ReadFileOptions = Object.create(null)): BinaryBufferReadableStream { const stream = BinaryBufferWriteableStream.create(); readFileIntoStream(provider, resource, stream, data => data, { ...options, bufferSize: this.BUFFER_SIZE, errorTransformer: error => this.asFileOperationError("Unable to read file '{0}' ({1})", resource, error, options) }, token); return stream; } protected rethrowAsFileOperationError(message: string, resource: URI, error: Error, options?: ReadFileOptions & WriteFileOptions & CreateFileOptions): never { throw this.asFileOperationError(message, resource, error, options); } protected asFileOperationError(message: string, resource: URI, error: Error, options?: ReadFileOptions & WriteFileOptions & CreateFileOptions): FileOperationError { const fileOperationError = new FileOperationError(nls.localizeByDefault(message, this.resourceForError(resource), ensureFileSystemProviderError(error).toString()), toFileOperationResult(error), options); fileOperationError.stack = `${fileOperationError.stack}\nCaused by: ${error.stack}`; return fileOperationError; } private async readFileUnbuffered(provider: FileSystemProviderWithFileReadWriteCapability, resource: URI, options?: ReadFileOptions): Promise { let buffer = await provider.readFile(resource); // respect position option if (options && typeof options.position === 'number') { buffer = buffer.slice(options.position); } // respect length option if (options && typeof options.length === 'number') { buffer = buffer.slice(0, options.length); } // Throw if file is too large to load this.validateReadFileLimits(resource, buffer.byteLength, options); return BinaryBufferReadableStream.fromBuffer(BinaryBuffer.wrap(buffer)); } private async validateReadFile(resource: URI, options?: ReadFileOptions): Promise { const stat = await this.resolve(resource, { resolveMetadata: true }); // Throw if resource is a directory if (stat.isDirectory) { throw new FileOperationError(nls.localizeByDefault("Unable to read file '{0}' that is actually a directory", this.resourceForError(resource)), FileOperationResult.FILE_IS_DIRECTORY, options); } // Throw if file not modified since (unless disabled) if (options && typeof options.etag === 'string' && options.etag !== ETAG_DISABLED && options.etag === stat.etag) { throw new FileOperationError(nls.localizeByDefault('File not modified since'), FileOperationResult.FILE_NOT_MODIFIED_SINCE, options); } // Throw if file is too large to load this.validateReadFileLimits(resource, stat.size, options); return stat; } private validateReadFileLimits(resource: URI, size: number, options?: ReadFileOptions): void { if (options?.limits) { let tooLargeErrorResult: FileOperationResult | undefined = undefined; if (typeof options.limits.memory === 'number' && size > options.limits.memory) { tooLargeErrorResult = FileOperationResult.FILE_EXCEEDS_MEMORY_LIMIT; } if (typeof options.limits.size === 'number' && size > options.limits.size) { tooLargeErrorResult = FileOperationResult.FILE_TOO_LARGE; } if (typeof tooLargeErrorResult === 'number') { throw new FileOperationError(nls.localizeByDefault("Unable to read file '{0}' that is too large to open", this.resourceForError(resource)), tooLargeErrorResult); } } } // #endregion // #region Move/Copy/Delete/Create Folder async move(source: URI, target: URI, options?: MoveFileOptions): Promise { if (options?.fromUserGesture === false) { return this.doMove(source, target, options.overwrite); } await this.runFileOperationParticipants(target, source, FileOperation.MOVE); const event = { correlationId: this.correlationIds++, operation: FileOperation.MOVE, target, source }; await this.onWillRunUserOperationEmitter.fire(event); let stat: FileStatWithMetadata; try { stat = await this.doMove(source, target, options?.overwrite); } catch (error) { await this.onDidFailUserOperationEmitter.fire(event); throw error; } await this.onDidRunUserOperationEmitter.fire(event); return stat; } protected async doMove(source: URI, target: URI, overwrite?: boolean): Promise { const sourceProvider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(source), source); const targetProvider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(target), target); // move const mode = await this.doMoveCopy(sourceProvider, source, targetProvider, target, 'move', !!overwrite); // resolve and send events const fileStat = await this.resolve(target, { resolveMetadata: true }); this.onDidRunOperationEmitter.fire(new FileOperationEvent(source, mode === 'move' ? FileOperation.MOVE : FileOperation.COPY, fileStat)); return fileStat; } async copy(source: URI, target: URI, options?: CopyFileOptions): Promise { if (options?.fromUserGesture === false) { return this.doCopy(source, target, options.overwrite); } await this.runFileOperationParticipants(target, source, FileOperation.COPY); const event = { correlationId: this.correlationIds++, operation: FileOperation.COPY, target, source }; await this.onWillRunUserOperationEmitter.fire(event); let stat: FileStatWithMetadata; try { stat = await this.doCopy(source, target, options?.overwrite); } catch (error) { await this.onDidFailUserOperationEmitter.fire(event); throw error; } await this.onDidRunUserOperationEmitter.fire(event); return stat; } protected async doCopy(source: URI, target: URI, overwrite?: boolean): Promise { const sourceProvider = await this.withReadProvider(source); const targetProvider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(target), target); // copy const mode = await this.doMoveCopy(sourceProvider, source, targetProvider, target, 'copy', !!overwrite); // resolve and send events const fileStat = await this.resolve(target, { resolveMetadata: true }); this.onDidRunOperationEmitter.fire(new FileOperationEvent(source, mode === 'copy' ? FileOperation.COPY : FileOperation.MOVE, fileStat)); return fileStat; } private async doMoveCopy(sourceProvider: FileSystemProvider, source: URI, targetProvider: FileSystemProvider, target: URI, mode: 'move' | 'copy', overwrite: boolean): Promise<'move' | 'copy'> { if (source.toString() === target.toString()) { return mode; // simulate node.js behaviour here and do a no-op if paths match } // validation const { exists, isSameResourceWithDifferentPathCase } = await this.doValidateMoveCopy(sourceProvider, source, targetProvider, target, mode, overwrite); // if target exists get valid target if (exists && !overwrite) { const parent = await this.resolve(target.parent); const targetFileStat = await this.resolve(target); target = FileSystemUtils.generateUniqueResourceURI(parent, target, targetFileStat.isDirectory, isSameResourceWithDifferentPathCase ? 'copy' : undefined); } // delete as needed (unless target is same resource with different path case) if (exists && !isSameResourceWithDifferentPathCase && overwrite) { await this.delete(target, { recursive: true }); } // create parent folders await this.mkdirp(targetProvider, target.parent); // copy source => target if (mode === 'copy') { // same provider with fast copy: leverage copy() functionality if (sourceProvider === targetProvider && hasFileFolderCopyCapability(sourceProvider)) { await sourceProvider.copy(source, target, { overwrite }); } // when copying via buffer/unbuffered, we have to manually // traverse the source if it is a folder and not a file else { const sourceFile = await this.resolve(source); if (sourceFile.isDirectory) { await this.doCopyFolder(sourceProvider, sourceFile, targetProvider, target); } else { await this.doCopyFile(sourceProvider, source, targetProvider, target); } } return mode; } // move source => target else { // same provider: leverage rename() functionality if (sourceProvider === targetProvider) { await sourceProvider.rename(source, target, { overwrite }); return mode; } // across providers: copy to target & delete at source else { await this.doMoveCopy(sourceProvider, source, targetProvider, target, 'copy', overwrite); await this.delete(source, { recursive: true }); return 'copy'; } } } private async doCopyFile(sourceProvider: FileSystemProvider, source: URI, targetProvider: FileSystemProvider, target: URI): Promise { // copy: source (buffered) => target (buffered) if (hasOpenReadWriteCloseCapability(sourceProvider) && hasOpenReadWriteCloseCapability(targetProvider)) { return this.doPipeBuffered(sourceProvider, source, targetProvider, target); } // copy: source (buffered) => target (unbuffered) if (hasOpenReadWriteCloseCapability(sourceProvider) && hasReadWriteCapability(targetProvider)) { return this.doPipeBufferedToUnbuffered(sourceProvider, source, targetProvider, target); } // copy: source (unbuffered) => target (buffered) if (hasReadWriteCapability(sourceProvider) && hasOpenReadWriteCloseCapability(targetProvider)) { return this.doPipeUnbufferedToBuffered(sourceProvider, source, targetProvider, target); } // copy: source (unbuffered) => target (unbuffered) if (hasReadWriteCapability(sourceProvider) && hasReadWriteCapability(targetProvider)) { return this.doPipeUnbuffered(sourceProvider, source, targetProvider, target); } } private async doCopyFolder(sourceProvider: FileSystemProvider, sourceFolder: FileStat, targetProvider: FileSystemProvider, targetFolder: URI): Promise { // create folder in target await targetProvider.mkdir(targetFolder); // create children in target if (Array.isArray(sourceFolder.children)) { await Promise.all(sourceFolder.children.map(async sourceChild => { const targetChild = targetFolder.resolve(sourceChild.name); if (sourceChild.isDirectory) { return this.doCopyFolder(sourceProvider, await this.resolve(sourceChild.resource), targetProvider, targetChild); } else { return this.doCopyFile(sourceProvider, sourceChild.resource, targetProvider, targetChild); } })); } } private async doValidateMoveCopy(sourceProvider: FileSystemProvider, source: URI, targetProvider: FileSystemProvider, target: URI, mode: 'move' | 'copy', overwrite?: boolean): Promise<{ exists: boolean, isSameResourceWithDifferentPathCase: boolean }> { let isSameResourceWithDifferentPathCase = false; // Check if source is equal or parent to target (requires providers to be the same) if (sourceProvider === targetProvider) { const isPathCaseSensitive = !!(sourceProvider.capabilities & FileSystemProviderCapabilities.PathCaseSensitive); if (!isPathCaseSensitive) { isSameResourceWithDifferentPathCase = source.toString().toLowerCase() === target.toString().toLowerCase(); } if (isSameResourceWithDifferentPathCase && mode === 'copy') { throw new Error(nls.localizeByDefault("Unable to move/copy when source '{0}' is parent of target '{1}'.", this.resourceForError(source), this.resourceForError(target))); } if (!isSameResourceWithDifferentPathCase && target.isEqualOrParent(source, isPathCaseSensitive)) { throw new Error(nls.localizeByDefault("Unable to move/copy when source '{0}' is parent of target '{1}'.", this.resourceForError(source), this.resourceForError(target))); } } // Extra checks if target exists and this is not a rename const exists = await this.exists(target); if (exists && !isSameResourceWithDifferentPathCase) { // Special case: if the target is a parent of the source, we cannot delete // it as it would delete the source as well. In this case we have to throw if (sourceProvider === targetProvider) { const isPathCaseSensitive = !!(sourceProvider.capabilities & FileSystemProviderCapabilities.PathCaseSensitive); if (source.isEqualOrParent(target, isPathCaseSensitive)) { throw new Error(nls.localizeByDefault("Unable to move/copy '{0}' into '{1}' since a file would replace the folder it is contained in.", this.resourceForError(source), this.resourceForError(target))); } } } return { exists, isSameResourceWithDifferentPathCase }; } async createFolder(resource: URI, options: FileOperationOptions = {}): Promise { const { fromUserGesture = true, } = options; const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(resource), resource); // mkdir recursively await this.mkdirp(provider, resource); // events const fileStat = await this.resolve(resource, { resolveMetadata: true }); if (fromUserGesture) { this.onDidRunUserOperationEmitter.fire({ correlationId: this.correlationIds++, operation: FileOperation.CREATE, target: resource }); } else { this.onDidRunOperationEmitter.fire(new FileOperationEvent(resource, FileOperation.CREATE, fileStat)); } return fileStat; } private async mkdirp(provider: FileSystemProvider, directory: URI): Promise { const directoriesToCreate: string[] = []; // mkdir until we reach root while (!directory.path.isRoot) { try { const stat = await provider.stat(directory); if ((stat.type & FileType.Directory) === 0) { throw new Error(nls.localizeByDefault("Unable to create folder '{0}' that already exists but is not a directory", this.resourceForError(directory))); } break; // we have hit a directory that exists -> good } catch (error) { // Bubble up any other error that is not file not found if (toFileSystemProviderErrorCode(error) !== FileSystemProviderErrorCode.FileNotFound) { throw error; } // Upon error, remember directories that need to be created directoriesToCreate.push(directory.path.base); // Continue up directory = directory.parent; } } // Create directories as needed for (let i = directoriesToCreate.length - 1; i >= 0; i--) { directory = directory.resolve(directoriesToCreate[i]); try { await provider.mkdir(directory); } catch (error) { if (toFileSystemProviderErrorCode(error) !== FileSystemProviderErrorCode.FileExists) { // For mkdirp() we tolerate that the mkdir() call fails // in case the folder already exists. This follows node.js // own implementation of fs.mkdir({ recursive: true }) and // reduces the chances of race conditions leading to errors // if multiple calls try to create the same folders // As such, we only throw an error here if it is other than // the fact that the file already exists. // (see also https://github.com/microsoft/vscode/issues/89834) throw error; } } } } async delete(resource: URI, options?: FileOperationOptions & Partial): Promise { if (options?.fromUserGesture === false) { return this.doDelete(resource, options); } await this.runFileOperationParticipants(resource, undefined, FileOperation.DELETE); const event = { correlationId: this.correlationIds++, operation: FileOperation.DELETE, target: resource }; await this.onWillRunUserOperationEmitter.fire(event); try { await this.doDelete(resource, options); } catch (error) { await this.onDidFailUserOperationEmitter.fire(event); throw error; } await this.onDidRunUserOperationEmitter.fire(event); } protected async doDelete(resource: URI, options?: Partial): Promise { const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(resource), resource); // Validate trash support const useTrash = !!options?.useTrash; if (useTrash && !(provider.capabilities & FileSystemProviderCapabilities.Trash)) { throw new Error(nls.localizeByDefault("Unable to delete file '{0}' via trash because provider does not support it.", this.resourceForError(resource))); } // Validate delete const exists = await this.exists(resource); if (!exists) { throw new FileOperationError(nls.localizeByDefault("Unable to delete nonexistent file '{0}'", this.resourceForError(resource)), FileOperationResult.FILE_NOT_FOUND); } // Validate recursive const recursive = !!options?.recursive; if (!recursive && exists) { const stat = await this.resolve(resource); if (stat.isDirectory && Array.isArray(stat.children) && stat.children.length > 0) { throw new Error(nls.localizeByDefault("Unable to delete non-empty folder '{0}'.", this.resourceForError(resource))); } } // Delete through provider await provider.delete(resource, { recursive, useTrash }); // Events this.onDidRunOperationEmitter.fire(new FileOperationEvent(resource, FileOperation.DELETE)); } // #endregion // #region File Watching private onDidFilesChangeEmitter = new Emitter(); /** * An event that is emitted when files are changed on the disk. */ get onDidFilesChange(): Event { return this.onDidFilesChangeEmitter.event; } private activeWatchers = new Map(); watch(resource: URI, options: WatchOptions = { recursive: false, excludes: [] }): Disposable { const resolvedOptions: WatchOptions = { ...options, // always ignore temporary upload files excludes: options.excludes.concat('**/theia_upload_*') }; let watchDisposed = false; let watchDisposable = Disposable.create(() => watchDisposed = true); // Watch and wire in disposable which is async but // check if we got disposed meanwhile and forward this.doWatch(resource, resolvedOptions).then(disposable => { if (watchDisposed) { disposable.dispose(); } else { watchDisposable = disposable; } }, error => console.error(error)); return Disposable.create(() => watchDisposable.dispose()); } async doWatch(resource: URI, options: WatchOptions): Promise { const provider = await this.withProvider(resource); const key = this.toWatchKey(provider, resource, options); // Only start watching if we are the first for the given key const watcher = this.activeWatchers.get(key) || { count: 0, disposable: provider.watch(resource, options) }; if (!this.activeWatchers.has(key)) { this.activeWatchers.set(key, watcher); } // Increment usage counter watcher.count += 1; return Disposable.create(() => { // Unref watcher.count--; // Dispose only when last user is reached if (watcher.count === 0) { watcher.disposable.dispose(); this.activeWatchers.delete(key); } }); } private toWatchKey(provider: FileSystemProvider, resource: URI, options: WatchOptions): string { return [ this.toMapKey(provider, resource), // lowercase path if the provider is case insensitive String(options.recursive), // use recursive: true | false as part of the key options.excludes.join() // use excludes as part of the key ].join(); } // #endregion // #region Helpers private writeQueues: Map> = new Map(); private ensureWriteQueue(provider: FileSystemProvider, resource: URI, task: () => Promise): Promise { // ensure to never write to the same resource without finishing // the one write. this ensures a write finishes consistently // (even with error) before another write is done. const queueKey = this.toMapKey(provider, resource); const writeQueue = (this.writeQueues.get(queueKey) || Promise.resolve()).then(task, task); this.writeQueues.set(queueKey, writeQueue); return writeQueue; } private toMapKey(provider: FileSystemProvider, resource: URI): string { const isPathCaseSensitive = !!(provider.capabilities & FileSystemProviderCapabilities.PathCaseSensitive); return isPathCaseSensitive ? resource.toString() : resource.toString().toLowerCase(); } private async doWriteBuffered(provider: FileSystemProviderWithOpenReadWriteCloseCapability, resource: URI, readableOrStreamOrBufferedStream: BinaryBufferReadable | BinaryBufferReadableStream | BinaryBufferReadableBufferedStream): Promise { return this.ensureWriteQueue(provider, resource, async () => { // open handle const handle = await provider.open(resource, { create: true }); // write into handle until all bytes from buffer have been written try { if (isReadableStream(readableOrStreamOrBufferedStream) || isReadableBufferedStream(readableOrStreamOrBufferedStream)) { await this.doWriteStreamBufferedQueued(provider, handle, readableOrStreamOrBufferedStream); } else { await this.doWriteReadableBufferedQueued(provider, handle, readableOrStreamOrBufferedStream); } } catch (error) { throw ensureFileSystemProviderError(error); } finally { // close handle always await provider.close(handle); } }); } private async doWriteStreamBufferedQueued(provider: FileSystemProviderWithOpenReadWriteCloseCapability, handle: number, streamOrBufferedStream: BinaryBufferReadableStream | BinaryBufferReadableBufferedStream): Promise { let posInFile = 0; let stream: BinaryBufferReadableStream; // Buffered stream: consume the buffer first by writing // it to the target before reading from the stream. if (isReadableBufferedStream(streamOrBufferedStream)) { if (streamOrBufferedStream.buffer.length > 0) { const chunk = BinaryBuffer.concat(streamOrBufferedStream.buffer); await this.doWriteBuffer(provider, handle, chunk, chunk.byteLength, posInFile, 0); posInFile += chunk.byteLength; } // If the stream has been consumed, return early if (streamOrBufferedStream.ended) { return; } stream = streamOrBufferedStream.stream; } // Unbuffered stream - just take as is else { stream = streamOrBufferedStream; } return new Promise(async (resolve, reject) => { stream.on('data', async chunk => { // pause stream to perform async write operation stream.pause(); try { await this.doWriteBuffer(provider, handle, chunk, chunk.byteLength, posInFile, 0); } catch (error) { return reject(error); } posInFile += chunk.byteLength; // resume stream now that we have successfully written // run this on the next tick to prevent increasing the // execution stack because resume() may call the event // handler again before finishing. setTimeout(() => stream.resume()); }); stream.on('error', error => reject(error)); stream.on('end', () => resolve()); }); } private async doWriteReadableBufferedQueued(provider: FileSystemProviderWithOpenReadWriteCloseCapability, handle: number, readable: BinaryBufferReadable): Promise { let posInFile = 0; let chunk: BinaryBuffer | null; while ((chunk = readable.read()) !== null) { await this.doWriteBuffer(provider, handle, chunk, chunk.byteLength, posInFile, 0); posInFile += chunk.byteLength; } } private async doWriteBuffer(provider: FileSystemProviderWithOpenReadWriteCloseCapability, handle: number, buffer: BinaryBuffer, length: number, posInFile: number, posInBuffer: number): Promise { let totalBytesWritten = 0; while (totalBytesWritten < length) { const bytesWritten = await provider.write(handle, posInFile + totalBytesWritten, buffer.buffer, posInBuffer + totalBytesWritten, length - totalBytesWritten); totalBytesWritten += bytesWritten; } } private async doWriteUnbuffered(provider: FileSystemProviderWithFileReadWriteCapability, resource: URI, bufferOrReadableOrStreamOrBufferedStream: BinaryBuffer | BinaryBufferReadable | BinaryBufferReadableStream | BinaryBufferReadableBufferedStream): Promise { return this.ensureWriteQueue(provider, resource, () => this.doWriteUnbufferedQueued(provider, resource, bufferOrReadableOrStreamOrBufferedStream)); } private async doWriteUnbufferedQueued(provider: FileSystemProviderWithFileReadWriteCapability, resource: URI, bufferOrReadableOrStreamOrBufferedStream: BinaryBuffer | BinaryBufferReadable | BinaryBufferReadableStream | BinaryBufferReadableBufferedStream): Promise { let buffer: BinaryBuffer; if (bufferOrReadableOrStreamOrBufferedStream instanceof BinaryBuffer) { buffer = bufferOrReadableOrStreamOrBufferedStream; } else if (isReadableStream(bufferOrReadableOrStreamOrBufferedStream)) { buffer = await BinaryBufferReadableStream.toBuffer(bufferOrReadableOrStreamOrBufferedStream); } else if (isReadableBufferedStream(bufferOrReadableOrStreamOrBufferedStream)) { buffer = await BinaryBufferReadableBufferedStream.toBuffer(bufferOrReadableOrStreamOrBufferedStream); } else { buffer = BinaryBufferReadable.toBuffer(bufferOrReadableOrStreamOrBufferedStream); } return provider.writeFile(resource, buffer.buffer, { create: true, overwrite: true }); } private async doPipeBuffered(sourceProvider: FileSystemProviderWithOpenReadWriteCloseCapability, source: URI, targetProvider: FileSystemProviderWithOpenReadWriteCloseCapability, target: URI): Promise { return this.ensureWriteQueue(targetProvider, target, () => this.doPipeBufferedQueued(sourceProvider, source, targetProvider, target)); } private async doPipeBufferedQueued(sourceProvider: FileSystemProviderWithOpenReadWriteCloseCapability, source: URI, targetProvider: FileSystemProviderWithOpenReadWriteCloseCapability, target: URI): Promise { let sourceHandle: number | undefined = undefined; let targetHandle: number | undefined = undefined; try { // Open handles sourceHandle = await sourceProvider.open(source, { create: false }); targetHandle = await targetProvider.open(target, { create: true }); const buffer = BinaryBuffer.alloc(this.BUFFER_SIZE); let posInFile = 0; let posInBuffer = 0; let bytesRead = 0; do { // read from source (sourceHandle) at current position (posInFile) into buffer (buffer) at // buffer position (posInBuffer) up to the size of the buffer (buffer.byteLength). bytesRead = await sourceProvider.read(sourceHandle, posInFile, buffer.buffer, posInBuffer, buffer.byteLength - posInBuffer); // write into target (targetHandle) at current position (posInFile) from buffer (buffer) at // buffer position (posInBuffer) all bytes we read (bytesRead). await this.doWriteBuffer(targetProvider, targetHandle, buffer, bytesRead, posInFile, posInBuffer); posInFile += bytesRead; posInBuffer += bytesRead; // when buffer full, fill it again from the beginning if (posInBuffer === buffer.byteLength) { posInBuffer = 0; } } while (bytesRead > 0); } catch (error) { throw ensureFileSystemProviderError(error); } finally { await Promise.all([ typeof sourceHandle === 'number' ? sourceProvider.close(sourceHandle) : Promise.resolve(), typeof targetHandle === 'number' ? targetProvider.close(targetHandle) : Promise.resolve(), ]); } } private async doPipeUnbuffered(sourceProvider: FileSystemProviderWithFileReadWriteCapability, source: URI, targetProvider: FileSystemProviderWithFileReadWriteCapability, target: URI): Promise { return this.ensureWriteQueue(targetProvider, target, () => this.doPipeUnbufferedQueued(sourceProvider, source, targetProvider, target)); } private async doPipeUnbufferedQueued(sourceProvider: FileSystemProviderWithFileReadWriteCapability, source: URI, targetProvider: FileSystemProviderWithFileReadWriteCapability, target: URI): Promise { return targetProvider.writeFile(target, await sourceProvider.readFile(source), { create: true, overwrite: true }); } private async doPipeUnbufferedToBuffered(sourceProvider: FileSystemProviderWithFileReadWriteCapability, source: URI, targetProvider: FileSystemProviderWithOpenReadWriteCloseCapability, target: URI): Promise { return this.ensureWriteQueue(targetProvider, target, () => this.doPipeUnbufferedToBufferedQueued(sourceProvider, source, targetProvider, target)); } private async doPipeUnbufferedToBufferedQueued(sourceProvider: FileSystemProviderWithFileReadWriteCapability, source: URI, targetProvider: FileSystemProviderWithOpenReadWriteCloseCapability, target: URI): Promise { // Open handle const targetHandle = await targetProvider.open(target, { create: true }); // Read entire buffer from source and write buffered try { const buffer = await sourceProvider.readFile(source); await this.doWriteBuffer(targetProvider, targetHandle, BinaryBuffer.wrap(buffer), buffer.byteLength, 0, 0); } catch (error) { throw ensureFileSystemProviderError(error); } finally { await targetProvider.close(targetHandle); } } private async doPipeBufferedToUnbuffered(sourceProvider: FileSystemProviderWithOpenReadWriteCloseCapability, source: URI, targetProvider: FileSystemProviderWithFileReadWriteCapability, target: URI): Promise { // Read buffer via stream buffered const buffer = await BinaryBufferReadableStream.toBuffer(this.readFileBuffered(sourceProvider, source, CancellationToken.None)); // Write buffer into target at once await this.doWriteUnbuffered(targetProvider, target, buffer); } protected throwIfFileSystemIsReadonly(provider: T, resource: URI): T { if (provider.capabilities & FileSystemProviderCapabilities.Readonly) { throw new FileOperationError(nls.localizeByDefault("Unable to modify read-only file '{0}'", this.resourceForError(resource)), FileOperationResult.FILE_PERMISSION_DENIED); } return provider; } private resourceForError(resource: URI): string { return this.labelProvider.getLongName(resource); } // #endregion // #region File operation participants private readonly participants: FileOperationParticipant[] = []; addFileOperationParticipant(participant: FileOperationParticipant): Disposable { this.participants.push(participant); return Disposable.create(() => { const index = this.participants.indexOf(participant); if (index > -1) { this.participants.splice(index, 1); } }); } async runFileOperationParticipants(target: URI, source: URI | undefined, operation: FileOperation): Promise { const participantsTimeout = this.preferences['files.participants.timeout']; if (participantsTimeout <= 0 || this.participants.length === 0) { return; } const cancellationTokenSource = new CancellationTokenSource(); return this.progressService.withProgress( this.progressLabel(operation), 'notification', async () => { for (const participant of this.participants) { if (cancellationTokenSource.token.isCancellationRequested) { break; } try { const promise = participant.participate(target, source, operation, participantsTimeout, cancellationTokenSource.token); await Promise.race([ promise, timeout(participantsTimeout, cancellationTokenSource.token).then(() => cancellationTokenSource.dispose(), () => { /* no-op if cancelled */ }) ]); } catch (err) { console.warn(err); } } }, () => { cancellationTokenSource.cancel(); }); } private progressLabel(operation: FileOperation): string { switch (operation) { case FileOperation.CREATE: return nls.localizeByDefault("Running 'File Create' participants..."); case FileOperation.MOVE: return nls.localizeByDefault("Running 'File Rename' participants..."); case FileOperation.COPY: return nls.localizeByDefault("Running 'File Copy' participants..."); case FileOperation.DELETE: return nls.localizeByDefault("Running 'File Delete' participants..."); } } // #endregion // #region encoding protected async getWriteEncoding(resource: URI, options?: WriteEncodingOptions): Promise { const encoding = await this.getEncodingForResource(resource, options ? options.encoding : undefined); return this.encodingService.toResourceEncoding(encoding, { overwriteEncoding: options?.overwriteEncoding, read: async length => { const buffer = await BinaryBufferReadableStream.toBuffer((await this.readFileStream(resource, { length })).value); return buffer.buffer; } }); } protected getReadEncoding(resource: URI, options?: ReadEncodingOptions, detectedEncoding?: string): Promise { let preferredEncoding: string | undefined; // Encoding passed in as option if (options?.encoding) { if (detectedEncoding === UTF8_with_bom && options.encoding === UTF8) { preferredEncoding = UTF8_with_bom; // indicate the file has BOM if we are to resolve with UTF 8 } else { preferredEncoding = options.encoding; // give passed in encoding highest priority } } else if (detectedEncoding) { preferredEncoding = detectedEncoding; } return this.getEncodingForResource(resource, preferredEncoding); } protected async getEncodingForResource(resource: URI, preferredEncoding?: string): Promise { resource = await this.toUnderlyingResource(resource); return this.encodingRegistry.getEncodingForResource(resource, preferredEncoding); } /** * Converts to an underlying fs provider resource format. * * For example converting `user-storage` resources to `file` resources under a user home: * user-storage:/user/settings.json => file://home/.theia/settings.json */ async toUnderlyingResource(resource: URI): Promise { let provider = await this.withProvider(resource); while (provider instanceof DelegatingFileSystemProvider) { resource = provider.toUnderlyingResource(resource); provider = await this.withProvider(resource); } return resource; } // #endregion protected handleFileWatchError(): void { this.watcherErrorHandler.handleError(); } }