Path: blob/main/src/vs/platform/files/browser/htmlFileSystemProvider.ts
5240 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { localize } from '../../../nls.js';6import { URI } from '../../../base/common/uri.js';7import { VSBuffer } from '../../../base/common/buffer.js';8import { CancellationToken } from '../../../base/common/cancellation.js';9import { Emitter, Event } from '../../../base/common/event.js';10import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';11import { Schemas } from '../../../base/common/network.js';12import { basename, extname, normalize } from '../../../base/common/path.js';13import { isLinux } from '../../../base/common/platform.js';14import { extUri, extUriIgnorePathCase, joinPath } from '../../../base/common/resources.js';15import { newWriteableStream, ReadableStreamEvents } from '../../../base/common/stream.js';16import { createFileSystemProviderError, IFileDeleteOptions, IFileOverwriteOptions, IFileReadStreamOptions, FileSystemProviderCapabilities, FileSystemProviderError, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IStat, IWatchOptions, IFileChange, FileChangeType } from '../common/files.js';17import { FileSystemObserverRecord, WebFileSystemAccess, WebFileSystemObserver } from './webFileSystemAccess.js';18import { IndexedDB } from '../../../base/browser/indexedDB.js';19import { ILogService, LogLevel } from '../../log/common/log.js';2021export class HTMLFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithFileReadStreamCapability {2223//#region Events (unsupported)2425readonly onDidChangeCapabilities = Event.None;2627//#endregion2829//#region File Capabilities3031private extUri = isLinux ? extUri : extUriIgnorePathCase;3233private _capabilities: FileSystemProviderCapabilities | undefined;34get capabilities(): FileSystemProviderCapabilities {35if (!this._capabilities) {36this._capabilities =37FileSystemProviderCapabilities.FileReadWrite |38FileSystemProviderCapabilities.FileReadStream;3940if (isLinux) {41this._capabilities |= FileSystemProviderCapabilities.PathCaseSensitive;42}43}4445return this._capabilities;46}4748//#endregion495051constructor(52private indexedDB: IndexedDB | undefined,53private readonly store: string,54private logService: ILogService55) {56super();57}5859//#region File Metadata Resolving6061async stat(resource: URI): Promise<IStat> {62try {63const handle = await this.getHandle(resource);64if (!handle) {65throw this.createFileSystemProviderError(resource, 'No such file or directory, stat', FileSystemProviderErrorCode.FileNotFound);66}6768if (WebFileSystemAccess.isFileSystemFileHandle(handle)) {69const file = await handle.getFile();7071return {72type: FileType.File,73mtime: file.lastModified,74ctime: 0,75size: file.size76};77}7879return {80type: FileType.Directory,81mtime: 0,82ctime: 0,83size: 084};85} catch (error) {86throw this.toFileSystemProviderError(error);87}88}8990async readdir(resource: URI): Promise<[string, FileType][]> {91try {92const handle = await this.getDirectoryHandle(resource);93if (!handle) {94throw this.createFileSystemProviderError(resource, 'No such file or directory, readdir', FileSystemProviderErrorCode.FileNotFound);95}9697const result: [string, FileType][] = [];9899for await (const [name, child] of handle) {100result.push([name, WebFileSystemAccess.isFileSystemFileHandle(child) ? FileType.File : FileType.Directory]);101}102103return result;104} catch (error) {105throw this.toFileSystemProviderError(error);106}107}108109//#endregion110111//#region File Reading/Writing112113readFileStream(resource: URI, opts: IFileReadStreamOptions, token: CancellationToken): ReadableStreamEvents<Uint8Array> {114const stream = newWriteableStream<Uint8Array>(data => VSBuffer.concat(data.map(data => VSBuffer.wrap(data))).buffer, {115// Set a highWaterMark to prevent the stream116// for file upload to produce large buffers117// in-memory118highWaterMark: 10119});120121(async () => {122try {123const handle = await this.getFileHandle(resource);124if (!handle) {125throw this.createFileSystemProviderError(resource, 'No such file or directory, readFile', FileSystemProviderErrorCode.FileNotFound);126}127128const file = await handle.getFile();129130// Partial file: implemented simply via `readFile`131if (typeof opts.length === 'number' || typeof opts.position === 'number') {132let buffer = new Uint8Array(await file.arrayBuffer());133134if (typeof opts?.position === 'number') {135buffer = buffer.slice(opts.position);136}137138if (typeof opts?.length === 'number') {139buffer = buffer.slice(0, opts.length);140}141142stream.end(buffer);143}144145// Entire file146else {147const reader: ReadableStreamDefaultReader<Uint8Array> = file.stream().getReader();148149let res = await reader.read();150while (!res.done) {151if (token.isCancellationRequested) {152break;153}154155// Write buffer into stream but make sure to wait156// in case the `highWaterMark` is reached157await stream.write(res.value);158159if (token.isCancellationRequested) {160break;161}162163res = await reader.read();164}165stream.end(undefined);166}167} catch (error) {168stream.error(this.toFileSystemProviderError(error));169stream.end();170}171})();172173return stream;174}175176async readFile(resource: URI): Promise<Uint8Array> {177try {178const handle = await this.getFileHandle(resource);179if (!handle) {180throw this.createFileSystemProviderError(resource, 'No such file or directory, readFile', FileSystemProviderErrorCode.FileNotFound);181}182183const file = await handle.getFile();184185return new Uint8Array(await file.arrayBuffer());186} catch (error) {187throw this.toFileSystemProviderError(error);188}189}190191async writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise<void> {192try {193let handle = await this.getFileHandle(resource);194195// Validate target unless { create: true, overwrite: true }196if (!opts.create || !opts.overwrite) {197if (handle) {198if (!opts.overwrite) {199throw this.createFileSystemProviderError(resource, 'File already exists, writeFile', FileSystemProviderErrorCode.FileExists);200}201} else {202if (!opts.create) {203throw this.createFileSystemProviderError(resource, 'No such file, writeFile', FileSystemProviderErrorCode.FileNotFound);204}205}206}207208// Create target as needed209if (!handle) {210const parent = await this.getDirectoryHandle(this.extUri.dirname(resource));211if (!parent) {212throw this.createFileSystemProviderError(resource, 'No such parent directory, writeFile', FileSystemProviderErrorCode.FileNotFound);213}214215handle = await parent.getFileHandle(this.extUri.basename(resource), { create: true });216if (!handle) {217throw this.createFileSystemProviderError(resource, 'Unable to create file , writeFile', FileSystemProviderErrorCode.Unknown);218}219}220221// Write to target overwriting any existing contents222const writable = await handle.createWritable();223await writable.write(content as Uint8Array<ArrayBuffer>);224await writable.close();225} catch (error) {226throw this.toFileSystemProviderError(error);227}228}229230//#endregion231232//#region Move/Copy/Delete/Create Folder233234async mkdir(resource: URI): Promise<void> {235try {236const parent = await this.getDirectoryHandle(this.extUri.dirname(resource));237if (!parent) {238throw this.createFileSystemProviderError(resource, 'No such parent directory, mkdir', FileSystemProviderErrorCode.FileNotFound);239}240241await parent.getDirectoryHandle(this.extUri.basename(resource), { create: true });242} catch (error) {243throw this.toFileSystemProviderError(error);244}245}246247async delete(resource: URI, opts: IFileDeleteOptions): Promise<void> {248try {249const parent = await this.getDirectoryHandle(this.extUri.dirname(resource));250if (!parent) {251throw this.createFileSystemProviderError(resource, 'No such parent directory, delete', FileSystemProviderErrorCode.FileNotFound);252}253254return parent.removeEntry(this.extUri.basename(resource), { recursive: opts.recursive });255} catch (error) {256throw this.toFileSystemProviderError(error);257}258}259260async rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise<void> {261try {262if (this.extUri.isEqual(from, to)) {263return; // no-op if the paths are the same264}265266// Implement file rename by write + delete267const fileHandle = await this.getFileHandle(from);268if (fileHandle) {269const file = await fileHandle.getFile();270const contents = new Uint8Array(await file.arrayBuffer());271272await this.writeFile(to, contents, { create: true, overwrite: opts.overwrite, unlock: false, atomic: false });273await this.delete(from, { recursive: false, useTrash: false, atomic: false });274}275276// File API does not support any real rename otherwise277else {278throw this.createFileSystemProviderError(from, localize('fileSystemRenameError', "Rename is only supported for files."), FileSystemProviderErrorCode.Unavailable);279}280} catch (error) {281throw this.toFileSystemProviderError(error);282}283}284285//#endregion286287//#region File Watching (unsupported)288289private readonly _onDidChangeFileEmitter = this._register(new Emitter<readonly IFileChange[]>());290readonly onDidChangeFile = this._onDidChangeFileEmitter.event;291292watch(resource: URI, opts: IWatchOptions): IDisposable {293const disposables = new DisposableStore();294295this.doWatch(resource, opts, disposables).catch(error => this.logService.error(`[File Watcher ('FileSystemObserver')] Error: ${error} (${resource})`));296297return disposables;298}299300private async doWatch(resource: URI, opts: IWatchOptions, disposables: DisposableStore): Promise<void> {301if (!WebFileSystemObserver.supported(globalThis)) {302return;303}304305const handle = await this.getHandle(resource);306if (!handle || disposables.isDisposed) {307return;308}309310// eslint-disable-next-line local/code-no-any-casts, @typescript-eslint/no-explicit-any311const observer = new (globalThis as any).FileSystemObserver((records: FileSystemObserverRecord[]) => {312if (disposables.isDisposed) {313return;314}315316const events: IFileChange[] = [];317for (const record of records) {318if (this.logService.getLevel() === LogLevel.Trace) {319this.logService.trace(`[File Watcher ('FileSystemObserver')] [${record.type}] ${joinPath(resource, ...record.relativePathComponents)}`);320}321322switch (record.type) {323case 'appeared':324events.push({ resource: joinPath(resource, ...record.relativePathComponents), type: FileChangeType.ADDED });325break;326case 'disappeared':327events.push({ resource: joinPath(resource, ...record.relativePathComponents), type: FileChangeType.DELETED });328break;329case 'modified':330events.push({ resource: joinPath(resource, ...record.relativePathComponents), type: FileChangeType.UPDATED });331break;332case 'errored':333this.logService.trace(`[File Watcher ('FileSystemObserver')] errored, disposing observer (${resource})`);334disposables.dispose();335}336}337338if (events.length) {339this._onDidChangeFileEmitter.fire(events);340}341});342343try {344await observer.observe(handle, opts.recursive ? { recursive: true } : undefined);345} finally {346if (disposables.isDisposed) {347observer.disconnect();348} else {349disposables.add(toDisposable(() => observer.disconnect()));350}351}352}353354//#endregion355356//#region File/Directoy Handle Registry357358private readonly _files = new Map<string, FileSystemFileHandle>();359private readonly _directories = new Map<string, FileSystemDirectoryHandle>();360361registerFileHandle(handle: FileSystemFileHandle): Promise<URI> {362return this.registerHandle(handle, this._files);363}364365registerDirectoryHandle(handle: FileSystemDirectoryHandle): Promise<URI> {366return this.registerHandle(handle, this._directories);367}368369get directories(): Iterable<FileSystemDirectoryHandle> {370return this._directories.values();371}372373private async registerHandle(handle: FileSystemHandle, map: Map<string, FileSystemHandle>): Promise<URI> {374let handleId = `/${handle.name}`;375376// Compute a valid handle ID in case this exists already377if (map.has(handleId) && !await map.get(handleId)?.isSameEntry(handle)) {378const fileExt = extname(handle.name);379const fileName = basename(handle.name, fileExt);380381let handleIdCounter = 1;382do {383handleId = `/${fileName}-${handleIdCounter++}${fileExt}`;384} while (map.has(handleId) && !await map.get(handleId)?.isSameEntry(handle));385}386387map.set(handleId, handle);388389// Remember in IndexDB for future lookup390try {391await this.indexedDB?.runInTransaction(this.store, 'readwrite', objectStore => objectStore.put(handle, handleId));392} catch (error) {393this.logService.error(error);394}395396return URI.from({ scheme: Schemas.file, path: handleId });397}398399async getHandle(resource: URI): Promise<FileSystemHandle | undefined> {400401// First: try to find a well known handle first402let handle = await this.doGetHandle(resource);403404// Second: walk up parent directories and resolve handle if possible405if (!handle) {406const parent = await this.getDirectoryHandle(this.extUri.dirname(resource));407if (parent) {408const name = extUri.basename(resource);409try {410handle = await parent.getFileHandle(name);411} catch (error) {412try {413handle = await parent.getDirectoryHandle(name);414} catch (error) {415// Ignore416}417}418}419}420421return handle;422}423424private async getFileHandle(resource: URI): Promise<FileSystemFileHandle | undefined> {425const handle = await this.doGetHandle(resource);426if (handle instanceof FileSystemFileHandle) {427return handle;428}429430const parent = await this.getDirectoryHandle(this.extUri.dirname(resource));431432try {433return await parent?.getFileHandle(extUri.basename(resource));434} catch (error) {435return undefined; // guard against possible DOMException436}437}438439private async getDirectoryHandle(resource: URI): Promise<FileSystemDirectoryHandle | undefined> {440const handle = await this.doGetHandle(resource);441if (handle instanceof FileSystemDirectoryHandle) {442return handle;443}444445const parentUri = this.extUri.dirname(resource);446if (this.extUri.isEqual(parentUri, resource)) {447return undefined; // return when root is reached to prevent infinite recursion448}449450const parent = await this.getDirectoryHandle(parentUri);451452try {453return await parent?.getDirectoryHandle(extUri.basename(resource));454} catch (error) {455return undefined; // guard against possible DOMException456}457}458459private async doGetHandle(resource: URI): Promise<FileSystemHandle | undefined> {460461// We store file system handles with the `handle.name`462// and as such require the resource to be on the root463if (this.extUri.dirname(resource).path !== '/') {464return undefined;465}466467const handleId = resource.path.replace(/\/$/, ''); // remove potential slash from the end of the path468469// First: check if we have a known handle stored in memory470const inMemoryHandle = this._files.get(handleId) ?? this._directories.get(handleId);471if (inMemoryHandle) {472return inMemoryHandle;473}474475// Second: check if we have a persisted handle in IndexedDB476const persistedHandle = await this.indexedDB?.runInTransaction(this.store, 'readonly', store => store.get(handleId));477if (WebFileSystemAccess.isFileSystemHandle(persistedHandle)) {478let hasPermissions = await persistedHandle.queryPermission() === 'granted';479try {480if (!hasPermissions) {481hasPermissions = await persistedHandle.requestPermission() === 'granted';482}483} catch (error) {484this.logService.error(error); // this can fail with a DOMException485}486487if (hasPermissions) {488if (WebFileSystemAccess.isFileSystemFileHandle(persistedHandle)) {489this._files.set(handleId, persistedHandle);490} else if (WebFileSystemAccess.isFileSystemDirectoryHandle(persistedHandle)) {491this._directories.set(handleId, persistedHandle);492}493494return persistedHandle;495}496}497498// Third: fail with an error499throw this.createFileSystemProviderError(resource, 'No file system handle registered', FileSystemProviderErrorCode.Unavailable);500}501502//#endregion503504private toFileSystemProviderError(error: Error): FileSystemProviderError {505if (error instanceof FileSystemProviderError) {506return error; // avoid double conversion507}508509let code = FileSystemProviderErrorCode.Unknown;510if (error.name === 'NotAllowedError') {511error = new Error(localize('fileSystemNotAllowedError', "Insufficient permissions. Please retry and allow the operation."));512code = FileSystemProviderErrorCode.Unavailable;513}514515return createFileSystemProviderError(error, code);516}517518private createFileSystemProviderError(resource: URI, msg: string, code: FileSystemProviderErrorCode): FileSystemProviderError {519return createFileSystemProviderError(new Error(`${msg} (${normalize(resource.path)})`), code);520}521}522523524