Files
theia-code-os/packages/filesystem/src/browser/file-service.ts
mawkone 8bb5110148
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled
deploy: current vibn theia state
Made-with: Cursor
2026-02-27 12:01:08 -08:00

1846 lines
83 KiB
TypeScript

// *****************************************************************************
// 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<void>;
}
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<string>;
}
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<FileServiceContribution>;
@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<UserFileOperationEvent>();
/**
* 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<UserFileOperationEvent>();
/**
* 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<UserFileOperationEvent>();
/**
* 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<FileSystemProviderRegistrationEvent>();
readonly onDidChangeFileSystemProviderRegistrations = this.onDidChangeFileSystemProviderRegistrationsEmitter.event;
private onWillActivateFileSystemProviderEmitter = new Emitter<FileSystemProviderActivationEvent>();
/**
* See `FileServiceContribution.registerProviders`.
*/
readonly onWillActivateFileSystemProvider = this.onWillActivateFileSystemProviderEmitter.event;
private onDidChangeFileSystemProviderCapabilitiesEmitter = new Emitter<FileSystemProviderCapabilitiesChangeEvent>();
readonly onDidChangeFileSystemProviderCapabilities = this.onDidChangeFileSystemProviderCapabilitiesEmitter.event;
private onDidChangeFileSystemProviderReadOnlyMessageEmitter = new Emitter<FileSystemProviderReadOnlyMessageChangeEvent>();
readonly onDidChangeFileSystemProviderReadOnlyMessage = this.onDidChangeFileSystemProviderReadOnlyMessageEmitter.event;
private readonly providers = new Map<string, FileSystemProvider>();
private readonly activations = new Map<string, Promise<FileSystemProvider>>();
/**
* 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<FileSystemProvider> {
let provider = this.providers.get(scheme);
if (provider) {
return provider;
}
let activation = this.activations.get(scheme);
if (!activation) {
const deferredActivation = new Deferred<FileSystemProvider>();
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<FileSystemProvider> {
// 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<FileSystemProviderWithFileReadWriteCapability | FileSystemProviderWithOpenReadWriteCloseCapability> {
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<FileSystemProviderWithFileReadWriteCapability | FileSystemProviderWithOpenReadWriteCloseCapability> {
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<FileOperationEvent>();
/**
* 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<FileStatWithMetadata>;
resolve(resource: URI, options?: ResolveFileOptions | undefined): Promise<FileStat>;
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<FileStatWithMetadata>;
private async doResolveFile(resource: URI, options?: ResolveFileOptions): Promise<FileStat>;
private async doResolveFile(resource: URI, options?: ResolveFileOptions): Promise<FileStat> {
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<URI, boolean> | undefined;
return this.toFileStat(provider, resource, stat, undefined, !!resolveMetadata, (stat, siblings) => {
// lazy trie to check for recursive resolving
if (!trie) {
trie = TernarySearchTree.forUris<true>(!!(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<Stat>, siblings: number | undefined, resolveMetadata: boolean, recurse: (stat: FileStat, siblings?: number) => boolean): Promise<FileStat>;
private async toFileStat(provider: FileSystemProvider, resource: URI, stat: Stat, siblings: number | undefined, resolveMetadata: true, recurse: (stat: FileStat, siblings?: number) => boolean): Promise<FileStatWithMetadata>;
private async toFileStat(provider: FileSystemProvider, resource: URI, stat: Stat | { type: FileType } & Partial<Stat>, siblings: number | undefined, resolveMetadata: boolean, recurse: (stat: FileStat, siblings?: number) => boolean): Promise<FileStat> {
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<ResolveFileResult[]>;
async resolveAll(toResolve: { resource: URI, options: ResolveMetadataFileOptions }[]): Promise<ResolveFileResultWithMetadata[]>;
async resolveAll(toResolve: { resource: URI; options?: ResolveFileOptions; }[]): Promise<ResolveFileResult[]> {
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<boolean> {
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<boolean> {
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<string> {
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<string>, options?: CreateTextFileOptions): Promise<FileStatWithMetadata> {
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<string>, options?: CreateTextFileOptions): Promise<FileStatWithMetadata> {
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<string>, options?: WriteTextFileOptions): Promise<FileStatWithMetadata & { encoding: string }> {
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<TextFileContent> {
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<TextFileStreamContent> {
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<ReadTextFileOptions['limits']> = 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<FileStatWithMetadata & { encoding: string }> {
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<FileStatWithMetadata> {
// 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<FileStatWithMetadata> {
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<Stat | undefined> {
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<FileContent> {
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<FileStreamContent> {
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<FileStreamContent> {
// 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<BinaryBufferReadableStream>;
// 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<BinaryBufferReadableStream> {
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<FileStatWithMetadata> {
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<FileStatWithMetadata> {
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<FileStatWithMetadata> {
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<FileStatWithMetadata> {
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<FileStatWithMetadata> {
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<void> {
// 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<void> {
// 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<FileStatWithMetadata> {
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<void> {
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<FileDeleteOptions>): Promise<void> {
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<FileDeleteOptions>): Promise<void> {
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<FileChangesEvent>();
/**
* An event that is emitted when files are changed on the disk.
*/
get onDidFilesChange(): Event<FileChangesEvent> {
return this.onDidFilesChangeEmitter.event;
}
private activeWatchers = new Map<string, { disposable: Disposable, count: number }>();
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<Disposable> {
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<string, Promise<void>> = new Map();
private ensureWriteQueue(provider: FileSystemProvider, resource: URI, task: () => Promise<void>): Promise<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
return this.ensureWriteQueue(provider, resource, () => this.doWriteUnbufferedQueued(provider, resource, bufferOrReadableOrStreamOrBufferedStream));
}
private async doWriteUnbufferedQueued(provider: FileSystemProviderWithFileReadWriteCapability, resource: URI, bufferOrReadableOrStreamOrBufferedStream: BinaryBuffer | BinaryBufferReadable | BinaryBufferReadableStream | BinaryBufferReadableBufferedStream): Promise<void> {
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<void> {
return this.ensureWriteQueue(targetProvider, target, () => this.doPipeBufferedQueued(sourceProvider, source, targetProvider, target));
}
private async doPipeBufferedQueued(sourceProvider: FileSystemProviderWithOpenReadWriteCloseCapability, source: URI, targetProvider: FileSystemProviderWithOpenReadWriteCloseCapability, target: URI): Promise<void> {
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<void> {
return this.ensureWriteQueue(targetProvider, target, () => this.doPipeUnbufferedQueued(sourceProvider, source, targetProvider, target));
}
private async doPipeUnbufferedQueued(sourceProvider: FileSystemProviderWithFileReadWriteCapability, source: URI, targetProvider: FileSystemProviderWithFileReadWriteCapability, target: URI): Promise<void> {
return targetProvider.writeFile(target, await sourceProvider.readFile(source), { create: true, overwrite: true });
}
private async doPipeUnbufferedToBuffered(sourceProvider: FileSystemProviderWithFileReadWriteCapability, source: URI, targetProvider: FileSystemProviderWithOpenReadWriteCloseCapability, target: URI): Promise<void> {
return this.ensureWriteQueue(targetProvider, target, () => this.doPipeUnbufferedToBufferedQueued(sourceProvider, source, targetProvider, target));
}
private async doPipeUnbufferedToBufferedQueued(sourceProvider: FileSystemProviderWithFileReadWriteCapability, source: URI, targetProvider: FileSystemProviderWithOpenReadWriteCloseCapability, target: URI): Promise<void> {
// 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<void> {
// 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<T extends FileSystemProvider>(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<void> {
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<ResourceEncoding> {
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<string> {
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<string> {
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<URI> {
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();
}
}