1846 lines
83 KiB
TypeScript
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();
|
|
}
|
|
}
|