Path: blob/main/src/vs/platform/files/browser/indexedDBFileSystemProvider.ts
3296 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 { Throttler } from '../../../base/common/async.js';6import { VSBuffer } from '../../../base/common/buffer.js';7import { Emitter, Event } from '../../../base/common/event.js';8import { Disposable, IDisposable } from '../../../base/common/lifecycle.js';9import { ExtUri } from '../../../base/common/resources.js';10import { isString } from '../../../base/common/types.js';11import { URI, UriDto } from '../../../base/common/uri.js';12import { localize } from '../../../nls.js';13import { createFileSystemProviderError, FileChangeType, IFileDeleteOptions, IFileOverwriteOptions, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileChange, IFileSystemProviderWithFileReadWriteCapability, IStat, IWatchOptions } from '../common/files.js';14import { IndexedDB } from '../../../base/browser/indexedDB.js';15import { BroadcastDataChannel } from '../../../base/browser/broadcast.js';1617// Standard FS Errors (expected to be thrown in production when invalid FS operations are requested)18const ERR_FILE_NOT_FOUND = createFileSystemProviderError(localize('fileNotExists', "File does not exist"), FileSystemProviderErrorCode.FileNotFound);19const ERR_FILE_IS_DIR = createFileSystemProviderError(localize('fileIsDirectory', "File is Directory"), FileSystemProviderErrorCode.FileIsADirectory);20const ERR_FILE_NOT_DIR = createFileSystemProviderError(localize('fileNotDirectory', "File is not a directory"), FileSystemProviderErrorCode.FileNotADirectory);21const ERR_DIR_NOT_EMPTY = createFileSystemProviderError(localize('dirIsNotEmpty', "Directory is not empty"), FileSystemProviderErrorCode.Unknown);22const ERR_FILE_EXCEEDS_STORAGE_QUOTA = createFileSystemProviderError(localize('fileExceedsStorageQuota', "File exceeds available storage quota"), FileSystemProviderErrorCode.FileExceedsStorageQuota);2324// Arbitrary Internal Errors25const ERR_UNKNOWN_INTERNAL = (message: string) => createFileSystemProviderError(localize('internal', "Internal error occurred in IndexedDB File System Provider. ({0})", message), FileSystemProviderErrorCode.Unknown);2627type DirEntry = [string, FileType];2829type IndexedDBFileSystemEntry =30| {31path: string;32type: FileType.Directory;33children: Map<string, IndexedDBFileSystemNode>;34}35| {36path: string;37type: FileType.File;38size: number | undefined;39};4041class IndexedDBFileSystemNode {42public type: FileType;4344constructor(private entry: IndexedDBFileSystemEntry) {45this.type = entry.type;46}4748read(path: string): IndexedDBFileSystemEntry | undefined {49return this.doRead(path.split('/').filter(p => p.length));50}5152private doRead(pathParts: string[]): IndexedDBFileSystemEntry | undefined {53if (pathParts.length === 0) { return this.entry; }54if (this.entry.type !== FileType.Directory) {55throw ERR_UNKNOWN_INTERNAL('Internal error reading from IndexedDBFSNode -- expected directory at ' + this.entry.path);56}57const next = this.entry.children.get(pathParts[0]);5859if (!next) { return undefined; }60return next.doRead(pathParts.slice(1));61}6263delete(path: string): void {64const toDelete = path.split('/').filter(p => p.length);65if (toDelete.length === 0) {66if (this.entry.type !== FileType.Directory) {67throw ERR_UNKNOWN_INTERNAL(`Internal error deleting from IndexedDBFSNode. Expected root entry to be directory`);68}69this.entry.children.clear();70} else {71return this.doDelete(toDelete, path);72}73}7475private doDelete(pathParts: string[], originalPath: string): void {76if (pathParts.length === 0) {77throw ERR_UNKNOWN_INTERNAL(`Internal error deleting from IndexedDBFSNode -- got no deletion path parts (encountered while deleting ${originalPath})`);78}79else if (this.entry.type !== FileType.Directory) {80throw ERR_UNKNOWN_INTERNAL('Internal error deleting from IndexedDBFSNode -- expected directory at ' + this.entry.path);81}82else if (pathParts.length === 1) {83this.entry.children.delete(pathParts[0]);84}85else {86const next = this.entry.children.get(pathParts[0]);87if (!next) {88throw ERR_UNKNOWN_INTERNAL('Internal error deleting from IndexedDBFSNode -- expected entry at ' + this.entry.path + '/' + next);89}90next.doDelete(pathParts.slice(1), originalPath);91}92}9394add(path: string, entry: { type: 'file'; size?: number } | { type: 'dir' }) {95this.doAdd(path.split('/').filter(p => p.length), entry, path);96}9798private doAdd(pathParts: string[], entry: { type: 'file'; size?: number } | { type: 'dir' }, originalPath: string) {99if (pathParts.length === 0) {100throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- adding empty path (encountered while adding ${originalPath})`);101}102else if (this.entry.type !== FileType.Directory) {103throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- parent is not a directory (encountered while adding ${originalPath})`);104}105else if (pathParts.length === 1) {106const next = pathParts[0];107const existing = this.entry.children.get(next);108if (entry.type === 'dir') {109if (existing?.entry.type === FileType.File) {110throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- overwriting file with directory: ${this.entry.path}/${next} (encountered while adding ${originalPath})`);111}112this.entry.children.set(next, existing ?? new IndexedDBFileSystemNode({113type: FileType.Directory,114path: this.entry.path + '/' + next,115children: new Map(),116}));117} else {118if (existing?.entry.type === FileType.Directory) {119throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- overwriting directory with file: ${this.entry.path}/${next} (encountered while adding ${originalPath})`);120}121this.entry.children.set(next, new IndexedDBFileSystemNode({122type: FileType.File,123path: this.entry.path + '/' + next,124size: entry.size,125}));126}127}128else if (pathParts.length > 1) {129const next = pathParts[0];130let childNode = this.entry.children.get(next);131if (!childNode) {132childNode = new IndexedDBFileSystemNode({133children: new Map(),134path: this.entry.path + '/' + next,135type: FileType.Directory136});137this.entry.children.set(next, childNode);138}139else if (childNode.type === FileType.File) {140throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- overwriting file entry with directory: ${this.entry.path}/${next} (encountered while adding ${originalPath})`);141}142childNode.doAdd(pathParts.slice(1), entry, originalPath);143}144}145146print(indentation = '') {147console.log(indentation + this.entry.path);148if (this.entry.type === FileType.Directory) {149this.entry.children.forEach(child => child.print(indentation + ' '));150}151}152}153154export class IndexedDBFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability {155156readonly capabilities: FileSystemProviderCapabilities =157FileSystemProviderCapabilities.FileReadWrite158| FileSystemProviderCapabilities.PathCaseSensitive;159readonly onDidChangeCapabilities: Event<void> = Event.None;160161private readonly extUri = new ExtUri(() => false) /* Case Sensitive */;162163private readonly changesBroadcastChannel: BroadcastDataChannel<UriDto<IFileChange>[]> | undefined;164private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());165readonly onDidChangeFile: Event<readonly IFileChange[]> = this._onDidChangeFile.event;166167private readonly mtimes = new Map<string, number>();168169private cachedFiletree: Promise<IndexedDBFileSystemNode> | undefined;170private writeManyThrottler: Throttler;171172constructor(readonly scheme: string, private indexedDB: IndexedDB, private readonly store: string, watchCrossWindowChanges: boolean) {173super();174this.writeManyThrottler = new Throttler();175176if (watchCrossWindowChanges) {177this.changesBroadcastChannel = this._register(new BroadcastDataChannel<UriDto<IFileChange>[]>(`vscode.indexedDB.${scheme}.changes`));178this._register(this.changesBroadcastChannel.onDidReceiveData(changes => {179this._onDidChangeFile.fire(changes.map(c => ({ type: c.type, resource: URI.revive(c.resource) })));180}));181}182}183184watch(resource: URI, opts: IWatchOptions): IDisposable {185return Disposable.None;186}187188async mkdir(resource: URI): Promise<void> {189try {190const resourceStat = await this.stat(resource);191if (resourceStat.type === FileType.File) {192throw ERR_FILE_NOT_DIR;193}194} catch (error) { /* Ignore */ }195(await this.getFiletree()).add(resource.path, { type: 'dir' });196}197198async stat(resource: URI): Promise<IStat> {199const entry = (await this.getFiletree()).read(resource.path);200201if (entry?.type === FileType.File) {202return {203type: FileType.File,204ctime: 0,205mtime: this.mtimes.get(resource.toString()) || 0,206size: entry.size ?? (await this.readFile(resource)).byteLength207};208}209210if (entry?.type === FileType.Directory) {211return {212type: FileType.Directory,213ctime: 0,214mtime: 0,215size: 0216};217}218219throw ERR_FILE_NOT_FOUND;220}221222async readdir(resource: URI): Promise<DirEntry[]> {223try {224const entry = (await this.getFiletree()).read(resource.path);225if (!entry) {226// Dirs aren't saved to disk, so empty dirs will be lost on reload.227// Thus we have two options for what happens when you try to read a dir and nothing is found:228// - Throw FileSystemProviderErrorCode.FileNotFound229// - Return []230// We choose to return [] as creating a dir then reading it (even after reload) should not throw an error.231return [];232}233if (entry.type !== FileType.Directory) {234throw ERR_FILE_NOT_DIR;235}236else {237return [...entry.children.entries()].map(([name, node]) => [name, node.type]);238}239} catch (error) {240throw error;241}242}243244async readFile(resource: URI): Promise<Uint8Array> {245try {246const result = await this.indexedDB.runInTransaction(this.store, 'readonly', objectStore => objectStore.get(resource.path));247if (result === undefined) {248throw ERR_FILE_NOT_FOUND;249}250const buffer = result instanceof Uint8Array ? result : isString(result) ? VSBuffer.fromString(result).buffer : undefined;251if (buffer === undefined) {252throw ERR_UNKNOWN_INTERNAL(`IndexedDB entry at "${resource.path}" in unexpected format`);253}254255// update cache256const fileTree = await this.getFiletree();257fileTree.add(resource.path, { type: 'file', size: buffer.byteLength });258259return buffer;260} catch (error) {261throw error;262}263}264265async writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise<void> {266try {267const existing = await this.stat(resource).catch(() => undefined);268if (existing?.type === FileType.Directory) {269throw ERR_FILE_IS_DIR;270}271await this.bulkWrite([[resource, content]]);272} catch (error) {273throw error;274}275}276277async rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise<void> {278const fileTree = await this.getFiletree();279const fromEntry = fileTree.read(from.path);280if (!fromEntry) {281throw ERR_FILE_NOT_FOUND;282}283284const toEntry = fileTree.read(to.path);285if (toEntry) {286if (!opts.overwrite) {287throw createFileSystemProviderError('file exists already', FileSystemProviderErrorCode.FileExists);288}289if (toEntry.type !== fromEntry.type) {290throw createFileSystemProviderError('Cannot rename files with different types', FileSystemProviderErrorCode.Unknown);291}292// delete the target file if exists293await this.delete(to, { recursive: true, useTrash: false, atomic: false });294}295296const toTargetResource = (path: string): URI => this.extUri.joinPath(to, this.extUri.relativePath(from, from.with({ path })) || '');297298const sourceEntries = await this.tree(from);299const sourceFiles: DirEntry[] = [];300for (const sourceEntry of sourceEntries) {301if (sourceEntry[1] === FileType.File) {302sourceFiles.push(sourceEntry);303} else if (sourceEntry[1] === FileType.Directory) {304// add directories to the tree305fileTree.add(toTargetResource(sourceEntry[0]).path, { type: 'dir' });306}307}308309if (sourceFiles.length) {310const targetFiles: [URI, Uint8Array][] = [];311const sourceFilesContents = await this.indexedDB.runInTransaction(this.store, 'readonly', objectStore => sourceFiles.map(([path]) => objectStore.get(path)));312for (let index = 0; index < sourceFiles.length; index++) {313const content = sourceFilesContents[index] instanceof Uint8Array ? sourceFilesContents[index] : isString(sourceFilesContents[index]) ? VSBuffer.fromString(sourceFilesContents[index]).buffer : undefined;314if (content) {315targetFiles.push([toTargetResource(sourceFiles[index][0]), content]);316}317}318await this.bulkWrite(targetFiles);319}320321await this.delete(from, { recursive: true, useTrash: false, atomic: false });322}323324async delete(resource: URI, opts: IFileDeleteOptions): Promise<void> {325let stat: IStat;326try {327stat = await this.stat(resource);328} catch (e) {329if (e.code === FileSystemProviderErrorCode.FileNotFound) {330return;331}332throw e;333}334335let toDelete: string[];336if (opts.recursive) {337const tree = await this.tree(resource);338toDelete = tree.map(([path]) => path);339} else {340if (stat.type === FileType.Directory && (await this.readdir(resource)).length) {341throw ERR_DIR_NOT_EMPTY;342}343toDelete = [resource.path];344}345await this.deleteKeys(toDelete);346(await this.getFiletree()).delete(resource.path);347toDelete.forEach(key => this.mtimes.delete(key));348this.triggerChanges(toDelete.map(path => ({ resource: resource.with({ path }), type: FileChangeType.DELETED })));349}350351private async tree(resource: URI): Promise<DirEntry[]> {352const stat = await this.stat(resource);353const allEntries: DirEntry[] = [[resource.path, stat.type]];354if (stat.type === FileType.Directory) {355const dirEntries = await this.readdir(resource);356for (const [key, type] of dirEntries) {357const childResource = this.extUri.joinPath(resource, key);358allEntries.push([childResource.path, type]);359if (type === FileType.Directory) {360const childEntries = await this.tree(childResource);361allEntries.push(...childEntries);362}363}364}365return allEntries;366}367368private triggerChanges(changes: IFileChange[]): void {369if (changes.length) {370this._onDidChangeFile.fire(changes);371372this.changesBroadcastChannel?.postData(changes);373}374}375376private getFiletree(): Promise<IndexedDBFileSystemNode> {377if (!this.cachedFiletree) {378this.cachedFiletree = (async () => {379const rootNode = new IndexedDBFileSystemNode({380children: new Map(),381path: '',382type: FileType.Directory383});384const result = await this.indexedDB.runInTransaction(this.store, 'readonly', objectStore => objectStore.getAllKeys());385const keys = result.map(key => key.toString());386keys.forEach(key => rootNode.add(key, { type: 'file' }));387return rootNode;388})();389}390return this.cachedFiletree;391}392393private async bulkWrite(files: [URI, Uint8Array][]): Promise<void> {394files.forEach(([resource, content]) => this.fileWriteBatch.push({ content, resource }));395await this.writeManyThrottler.queue(() => this.writeMany());396397const fileTree = await this.getFiletree();398for (const [resource, content] of files) {399fileTree.add(resource.path, { type: 'file', size: content.byteLength });400this.mtimes.set(resource.toString(), Date.now());401}402403this.triggerChanges(files.map(([resource]) => ({ resource, type: FileChangeType.UPDATED })));404}405406private fileWriteBatch: { resource: URI; content: Uint8Array }[] = [];407private async writeMany() {408if (this.fileWriteBatch.length) {409const fileBatch = this.fileWriteBatch.splice(0, this.fileWriteBatch.length);410try {411await this.indexedDB.runInTransaction(this.store, 'readwrite', objectStore => fileBatch.map(entry => {412return objectStore.put(entry.content, entry.resource.path);413}));414} catch (ex) {415if (ex instanceof DOMException && ex.name === 'QuotaExceededError') {416throw ERR_FILE_EXCEEDS_STORAGE_QUOTA;417}418419throw ex;420}421}422}423424private async deleteKeys(keys: string[]): Promise<void> {425if (keys.length) {426await this.indexedDB.runInTransaction(this.store, 'readwrite', objectStore => keys.map(key => objectStore.delete(key)));427}428}429430async reset(): Promise<void> {431await this.indexedDB.runInTransaction(this.store, 'readwrite', objectStore => objectStore.clear());432}433434}435436437