@theia/filesystem
Version:
Theia - FileSystem Extension
548 lines (469 loc) • 22.9 kB
text/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
// *****************************************************************************
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Emitter } from '@theia/core/lib/common/event';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import {
FileWriteOptions, FileOpenOptions, FileChangeType,
FileSystemProviderCapabilities, FileChange, Stat, FileOverwriteOptions, WatchOptions, FileType, FileSystemProvider, FileDeleteOptions,
hasOpenReadWriteCloseCapability, hasFileFolderCopyCapability, hasReadWriteCapability, hasAccessCapability,
FileSystemProviderError, FileSystemProviderErrorCode, FileUpdateOptions, hasUpdateCapability, FileUpdateResult, FileReadStreamOptions, hasFileReadStreamCapability,
ReadOnlyMessageFileSystemProvider
} from './files';
import { RpcServer, RpcProxy, RpcProxyFactory } from '@theia/core/lib/common/messaging/proxy-factory';
import { ApplicationError } from '@theia/core/lib/common/application-error';
import { Deferred } from '@theia/core/lib/common/promise-util';
import type { TextDocumentContentChangeEvent } from '@theia/core/shared/vscode-languageserver-protocol';
import { newWriteableStream, ReadableStreamEvents } from '@theia/core/lib/common/stream';
import { CancellationToken, cancelled } from '@theia/core/lib/common/cancellation';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
export const remoteFileSystemPath = '/services/remote-filesystem';
export const RemoteFileSystemServer = Symbol('RemoteFileSystemServer');
export interface RemoteFileSystemServer extends RpcServer<RemoteFileSystemClient> {
getCapabilities(): Promise<FileSystemProviderCapabilities>
stat(resource: string): Promise<Stat>;
getReadOnlyMessage(): Promise<MarkdownString | undefined>;
access(resource: string, mode?: number): Promise<void>;
fsPath(resource: string): Promise<string>;
open(resource: string, opts: FileOpenOptions): Promise<number>;
close(fd: number): Promise<void>;
read(fd: number, pos: number, length: number): Promise<{ bytes: Uint8Array; bytesRead: number; }>;
readFileStream(resource: string, handle: number, opts: FileReadStreamOptions, token: CancellationToken): Promise<void>;
readFile(resource: string): Promise<Uint8Array>;
write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number>;
writeFile(resource: string, content: Uint8Array, opts: FileWriteOptions): Promise<void>;
delete(resource: string, opts: FileDeleteOptions): Promise<void>;
mkdir(resource: string): Promise<void>;
readdir(resource: string): Promise<[string, FileType][]>;
rename(source: string, target: string, opts: FileOverwriteOptions): Promise<void>;
copy(source: string, target: string, opts: FileOverwriteOptions): Promise<void>;
watch(watcher: number, resource: string, opts: WatchOptions): Promise<void>;
unwatch(watcher: number): Promise<void>;
updateFile(resource: string, changes: TextDocumentContentChangeEvent[], opts: FileUpdateOptions): Promise<FileUpdateResult>;
}
export interface RemoteFileChange {
readonly type: FileChangeType;
readonly resource: string;
}
export interface RemoteFileStreamError extends Error {
code?: FileSystemProviderErrorCode
}
export interface RemoteFileSystemClient {
notifyDidChangeFile(event: { changes: RemoteFileChange[] }): void;
notifyFileWatchError(): void;
notifyDidChangeCapabilities(capabilities: FileSystemProviderCapabilities): void;
notifyDidChangeReadOnlyMessage(readOnlyMessage: MarkdownString | undefined): void;
onFileStreamData(handle: number, data: Uint8Array): void;
onFileStreamEnd(handle: number, error: RemoteFileStreamError | undefined): void;
}
export const RemoteFileSystemProviderError = ApplicationError.declare(-33005,
(message: string, data: { code: FileSystemProviderErrorCode, name: string }, stack: string) =>
({ message, data, stack })
);
export class RemoteFileSystemProxyFactory<T extends object> extends RpcProxyFactory<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected override serializeError(e: any): any {
if (e instanceof FileSystemProviderError) {
const { code, name } = e;
return super.serializeError(RemoteFileSystemProviderError(e.message, { code, name }, e.stack));
}
return super.serializeError(e);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected override deserializeError(capturedError: Error, e: any): any {
const error = super.deserializeError(capturedError, e);
if (RemoteFileSystemProviderError.is(error)) {
const fileOperationError = new FileSystemProviderError(error.message, error.data.code);
fileOperationError.name = error.data.name;
fileOperationError.stack = error.stack;
return fileOperationError;
}
return e;
}
}
/**
* Frontend component.
*
* Wraps the remote filesystem provider living on the backend.
*/
export class RemoteFileSystemProvider implements Required<FileSystemProvider>, Disposable, ReadOnlyMessageFileSystemProvider {
private readonly onDidChangeFileEmitter = new Emitter<readonly FileChange[]>();
readonly onDidChangeFile = this.onDidChangeFileEmitter.event;
private readonly onFileWatchErrorEmitter = new Emitter<void>();
readonly onFileWatchError = this.onFileWatchErrorEmitter.event;
private readonly onDidChangeCapabilitiesEmitter = new Emitter<void>();
readonly onDidChangeCapabilities = this.onDidChangeCapabilitiesEmitter.event;
private readonly onDidChangeReadOnlyMessageEmitter = new Emitter<MarkdownString | undefined>();
readonly onDidChangeReadOnlyMessage = this.onDidChangeReadOnlyMessageEmitter.event;
private readonly onFileStreamDataEmitter = new Emitter<[number, Uint8Array]>();
private readonly onFileStreamData = this.onFileStreamDataEmitter.event;
private readonly onFileStreamEndEmitter = new Emitter<[number, Error | FileSystemProviderError | undefined]>();
private readonly onFileStreamEnd = this.onFileStreamEndEmitter.event;
protected readonly toDispose = new DisposableCollection(
this.onDidChangeFileEmitter,
this.onDidChangeCapabilitiesEmitter,
this.onDidChangeReadOnlyMessageEmitter,
this.onFileStreamDataEmitter,
this.onFileStreamEndEmitter
);
protected watcherSequence = 0;
/**
* We'll track the currently allocated watchers, in order to re-allocate them
* with the same options once we reconnect to the backend after a disconnection.
*/
protected readonly watchOptions = new Map<number, {
uri: string;
options: WatchOptions
}>();
private _capabilities: FileSystemProviderCapabilities = FileSystemProviderCapabilities.None;
get capabilities(): FileSystemProviderCapabilities { return this._capabilities; }
private _readOnlyMessage: MarkdownString | undefined = undefined;
get readOnlyMessage(): MarkdownString | undefined {
return this._readOnlyMessage;
}
protected readonly readyDeferred = new Deferred<void>();
readonly ready = this.readyDeferred.promise;
protected streamHandleSeq = 0;
/**
* Wrapped remote filesystem.
*/
protected readonly server: RpcProxy<RemoteFileSystemServer>;
protected init(): void {
this.server.getCapabilities().then(capabilities => {
this._capabilities = capabilities;
this.readyDeferred.resolve();
}, this.readyDeferred.reject);
this.server.getReadOnlyMessage().then(readOnlyMessage => {
this._readOnlyMessage = readOnlyMessage;
});
this.server.setClient({
notifyDidChangeFile: ({ changes }) => {
this.onDidChangeFileEmitter.fire(changes.map(event => ({ resource: new URI(event.resource), type: event.type })));
},
notifyFileWatchError: () => {
this.onFileWatchErrorEmitter.fire();
},
notifyDidChangeCapabilities: capabilities => this.setCapabilities(capabilities),
notifyDidChangeReadOnlyMessage: readOnlyMessage => this.setReadOnlyMessage(readOnlyMessage),
onFileStreamData: (handle, data) => this.onFileStreamDataEmitter.fire([handle, data]),
onFileStreamEnd: (handle, error) => this.onFileStreamEndEmitter.fire([handle, error])
});
const onInitialized = this.server.onDidOpenConnection(() => {
// skip reconnection on the first connection
onInitialized.dispose();
this.toDispose.push(this.server.onDidOpenConnection(() => this.reconnect()));
});
}
dispose(): void {
this.toDispose.dispose();
}
protected setCapabilities(capabilities: FileSystemProviderCapabilities): void {
this._capabilities = capabilities;
this.onDidChangeCapabilitiesEmitter.fire(undefined);
}
protected setReadOnlyMessage(readOnlyMessage: MarkdownString | undefined): void {
this._readOnlyMessage = readOnlyMessage;
this.onDidChangeReadOnlyMessageEmitter.fire(readOnlyMessage);
}
// --- forwarding calls
stat(resource: URI): Promise<Stat> {
return this.server.stat(resource.toString());
}
access(resource: URI, mode?: number): Promise<void> {
return this.server.access(resource.toString(), mode);
}
fsPath(resource: URI): Promise<string> {
return this.server.fsPath(resource.toString());
}
open(resource: URI, opts: FileOpenOptions): Promise<number> {
return this.server.open(resource.toString(), opts);
}
close(fd: number): Promise<void> {
return this.server.close(fd);
}
async read(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
const { bytes, bytesRead } = await this.server.read(fd, pos, length);
// copy back the data that was written into the buffer on the remote
// side. we need to do this because buffers are not referenced by
// pointer, but only by value and as such cannot be directly written
// to from the other process.
data.set(bytes.slice(0, bytesRead), offset);
return bytesRead;
}
async readFile(resource: URI): Promise<Uint8Array> {
const bytes = await this.server.readFile(resource.toString());
return bytes;
}
readFileStream(resource: URI, opts: FileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {
const capturedError = new Error();
const stream = newWriteableStream<Uint8Array>(data => BinaryBuffer.concat(data.map(item => BinaryBuffer.wrap(item))).buffer);
const streamHandle = this.streamHandleSeq++;
const toDispose = new DisposableCollection(
token.onCancellationRequested(() => stream.end(cancelled())),
this.onFileStreamData(([handle, data]) => {
if (streamHandle === handle) {
stream.write(data);
}
}),
this.onFileStreamEnd(([handle, error]) => {
if (streamHandle === handle) {
if (error) {
const code = ('code' in error && error.code) || FileSystemProviderErrorCode.Unknown;
const fileOperationError = new FileSystemProviderError(error.message, code);
fileOperationError.name = error.name;
const capturedStack = capturedError.stack || '';
fileOperationError.stack = `${capturedStack}\nCaused by: ${error.stack}`;
stream.end(fileOperationError);
} else {
stream.end();
}
}
})
);
stream.on('end', () => toDispose.dispose());
this.server.readFileStream(resource.toString(), streamHandle, opts, token).then(() => {
if (token.isCancellationRequested) {
stream.end(cancelled());
}
}, error => stream.end(error));
return stream;
}
write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
return this.server.write(fd, pos, data, offset, length);
}
writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
return this.server.writeFile(resource.toString(), content, opts);
}
delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
return this.server.delete(resource.toString(), opts);
}
mkdir(resource: URI): Promise<void> {
return this.server.mkdir(resource.toString());
}
readdir(resource: URI): Promise<[string, FileType][]> {
return this.server.readdir(resource.toString());
}
rename(resource: URI, target: URI, opts: FileOverwriteOptions): Promise<void> {
return this.server.rename(resource.toString(), target.toString(), opts);
}
copy(resource: URI, target: URI, opts: FileOverwriteOptions): Promise<void> {
return this.server.copy(resource.toString(), target.toString(), opts);
}
updateFile(resource: URI, changes: TextDocumentContentChangeEvent[], opts: FileUpdateOptions): Promise<FileUpdateResult> {
return this.server.updateFile(resource.toString(), changes, opts);
}
watch(resource: URI, options: WatchOptions): Disposable {
const watcherId = this.watcherSequence++;
const uri = resource.toString();
this.watchOptions.set(watcherId, { uri, options });
this.server.watch(watcherId, uri, options);
const toUnwatch = Disposable.create(() => {
this.watchOptions.delete(watcherId);
this.server.unwatch(watcherId);
});
this.toDispose.push(toUnwatch);
return toUnwatch;
}
/**
* When a frontend disconnects (e.g. bad connection) the backend resources will be cleared.
*
* This means that we need to re-allocate the watchers when a frontend reconnects.
*/
protected reconnect(): void {
for (const [watcher, { uri, options }] of this.watchOptions.entries()) {
this.server.watch(watcher, uri, options);
}
}
}
/**
* Backend component.
*
* JSON-RPC server exposing a wrapped file system provider remotely.
*/
export class FileSystemProviderServer implements RemoteFileSystemServer {
private readonly BUFFER_SIZE = 64 * 1024;
/**
* Mapping of `watcherId` to a disposable watcher handle.
*/
protected watchers = new Map<number, Disposable>();
protected readonly toDispose = new DisposableCollection();
dispose(): void {
this.toDispose.dispose();
}
protected client: RemoteFileSystemClient | undefined;
setClient(client: RemoteFileSystemClient | undefined): void {
this.client = client;
}
/**
* Wrapped file system provider.
*/
protected readonly provider: FileSystemProvider & Partial<Disposable>;
protected init(): void {
if (this.provider.dispose) {
this.toDispose.push(Disposable.create(() => this.provider.dispose!()));
}
this.toDispose.push(this.provider.onDidChangeCapabilities(() => {
if (this.client) {
this.client.notifyDidChangeCapabilities(this.provider.capabilities);
}
}));
if (ReadOnlyMessageFileSystemProvider.is(this.provider)) {
const providerWithReadOnlyMessage: ReadOnlyMessageFileSystemProvider = this.provider;
this.toDispose.push(this.provider.onDidChangeReadOnlyMessage(() => {
if (this.client) {
this.client.notifyDidChangeReadOnlyMessage(providerWithReadOnlyMessage.readOnlyMessage);
}
}));
}
this.toDispose.push(this.provider.onDidChangeFile(changes => {
if (this.client) {
this.client.notifyDidChangeFile({
changes: changes.map(({ resource, type }) => ({ resource: resource.toString(), type }))
});
}
}));
this.toDispose.push(this.provider.onFileWatchError(() => {
if (this.client) {
this.client.notifyFileWatchError();
}
}));
}
async getCapabilities(): Promise<FileSystemProviderCapabilities> {
return this.provider.capabilities;
}
async getReadOnlyMessage(): Promise<MarkdownString | undefined> {
if (ReadOnlyMessageFileSystemProvider.is(this.provider)) {
return this.provider.readOnlyMessage;
} else {
return undefined;
}
}
stat(resource: string): Promise<Stat> {
return this.provider.stat(new URI(resource));
}
access(resource: string, mode?: number): Promise<void> {
if (hasAccessCapability(this.provider)) {
return this.provider.access(new URI(resource), mode);
}
throw new Error('not supported');
}
async fsPath(resource: string): Promise<string> {
if (hasAccessCapability(this.provider)) {
return this.provider.fsPath(new URI(resource));
}
throw new Error('not supported');
}
open(resource: string, opts: FileOpenOptions): Promise<number> {
if (hasOpenReadWriteCloseCapability(this.provider)) {
return this.provider.open(new URI(resource), opts);
}
throw new Error('not supported');
}
close(fd: number): Promise<void> {
if (hasOpenReadWriteCloseCapability(this.provider)) {
return this.provider.close(fd);
}
throw new Error('not supported');
}
async read(fd: number, pos: number, length: number): Promise<{ bytes: Uint8Array; bytesRead: number; }> {
if (hasOpenReadWriteCloseCapability(this.provider)) {
const buffer = BinaryBuffer.alloc(this.BUFFER_SIZE);
const bytes = buffer.buffer;
const bytesRead = await this.provider.read(fd, pos, bytes, 0, length);
return { bytes, bytesRead };
}
throw new Error('not supported');
}
write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise<number> {
if (hasOpenReadWriteCloseCapability(this.provider)) {
return this.provider.write(fd, pos, data, offset, length);
}
throw new Error('not supported');
}
async readFile(resource: string): Promise<Uint8Array> {
if (hasReadWriteCapability(this.provider)) {
return this.provider.readFile(new URI(resource));
}
throw new Error('not supported');
}
writeFile(resource: string, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
if (hasReadWriteCapability(this.provider)) {
return this.provider.writeFile(new URI(resource), content, opts);
}
throw new Error('not supported');
}
delete(resource: string, opts: FileDeleteOptions): Promise<void> {
return this.provider.delete(new URI(resource), opts);
}
mkdir(resource: string): Promise<void> {
return this.provider.mkdir(new URI(resource));
}
readdir(resource: string): Promise<[string, FileType][]> {
return this.provider.readdir(new URI(resource));
}
rename(source: string, target: string, opts: FileOverwriteOptions): Promise<void> {
return this.provider.rename(new URI(source), new URI(target), opts);
}
copy(source: string, target: string, opts: FileOverwriteOptions): Promise<void> {
if (hasFileFolderCopyCapability(this.provider)) {
return this.provider.copy(new URI(source), new URI(target), opts);
}
throw new Error('not supported');
}
updateFile(resource: string, changes: TextDocumentContentChangeEvent[], opts: FileUpdateOptions): Promise<FileUpdateResult> {
if (hasUpdateCapability(this.provider)) {
return this.provider.updateFile(new URI(resource), changes, opts);
}
throw new Error('not supported');
}
async watch(requestedWatcherId: number, resource: string, opts: WatchOptions): Promise<void> {
if (this.watchers.has(requestedWatcherId)) {
throw new Error('watcher id is already allocated!');
}
const watcher = this.provider.watch(new URI(resource), opts);
this.watchers.set(requestedWatcherId, watcher);
this.toDispose.push(Disposable.create(() => this.unwatch(requestedWatcherId)));
}
async unwatch(watcherId: number): Promise<void> {
const watcher = this.watchers.get(watcherId);
if (watcher) {
this.watchers.delete(watcherId);
watcher.dispose();
}
}
async readFileStream(resource: string, handle: number, opts: FileReadStreamOptions, token: CancellationToken): Promise<void> {
if (hasFileReadStreamCapability(this.provider)) {
const stream = this.provider.readFileStream(new URI(resource), opts, token);
stream.on('data', data => this.client?.onFileStreamData(handle, data));
stream.on('error', error => {
const code = error instanceof FileSystemProviderError ? error.code : undefined;
const { name, message, stack } = error;
this.client?.onFileStreamEnd(handle, { code, name, message, stack });
});
stream.on('end', () => this.client?.onFileStreamEnd(handle, undefined));
} else {
throw new Error('not supported');
}
}
}