UNPKG

@theia/filesystem

Version:
548 lines (469 loc) • 22.9 kB
// ***************************************************************************** // 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. */ @injectable() 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. */ @inject(RemoteFileSystemServer) protected readonly server: RpcProxy<RemoteFileSystemServer>; @postConstruct() 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. */ @injectable() 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. */ @inject(FileSystemProvider) protected readonly provider: FileSystemProvider & Partial<Disposable>; @postConstruct() 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'); } } }