Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/files/browser/indexedDBFileSystemProvider.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Throttler } from '../../../base/common/async.js';
7
import { VSBuffer } from '../../../base/common/buffer.js';
8
import { Emitter, Event } from '../../../base/common/event.js';
9
import { Disposable, IDisposable } from '../../../base/common/lifecycle.js';
10
import { ExtUri } from '../../../base/common/resources.js';
11
import { isString } from '../../../base/common/types.js';
12
import { URI, UriDto } from '../../../base/common/uri.js';
13
import { localize } from '../../../nls.js';
14
import { createFileSystemProviderError, FileChangeType, IFileDeleteOptions, IFileOverwriteOptions, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileChange, IFileSystemProviderWithFileReadWriteCapability, IStat, IWatchOptions } from '../common/files.js';
15
import { IndexedDB } from '../../../base/browser/indexedDB.js';
16
import { BroadcastDataChannel } from '../../../base/browser/broadcast.js';
17
18
// Standard FS Errors (expected to be thrown in production when invalid FS operations are requested)
19
const ERR_FILE_NOT_FOUND = createFileSystemProviderError(localize('fileNotExists', "File does not exist"), FileSystemProviderErrorCode.FileNotFound);
20
const ERR_FILE_IS_DIR = createFileSystemProviderError(localize('fileIsDirectory', "File is Directory"), FileSystemProviderErrorCode.FileIsADirectory);
21
const ERR_FILE_NOT_DIR = createFileSystemProviderError(localize('fileNotDirectory', "File is not a directory"), FileSystemProviderErrorCode.FileNotADirectory);
22
const ERR_DIR_NOT_EMPTY = createFileSystemProviderError(localize('dirIsNotEmpty', "Directory is not empty"), FileSystemProviderErrorCode.Unknown);
23
const ERR_FILE_EXCEEDS_STORAGE_QUOTA = createFileSystemProviderError(localize('fileExceedsStorageQuota', "File exceeds available storage quota"), FileSystemProviderErrorCode.FileExceedsStorageQuota);
24
25
// Arbitrary Internal Errors
26
const ERR_UNKNOWN_INTERNAL = (message: string) => createFileSystemProviderError(localize('internal', "Internal error occurred in IndexedDB File System Provider. ({0})", message), FileSystemProviderErrorCode.Unknown);
27
28
type DirEntry = [string, FileType];
29
30
type IndexedDBFileSystemEntry =
31
| {
32
path: string;
33
type: FileType.Directory;
34
children: Map<string, IndexedDBFileSystemNode>;
35
}
36
| {
37
path: string;
38
type: FileType.File;
39
size: number | undefined;
40
};
41
42
class IndexedDBFileSystemNode {
43
public type: FileType;
44
45
constructor(private entry: IndexedDBFileSystemEntry) {
46
this.type = entry.type;
47
}
48
49
read(path: string): IndexedDBFileSystemEntry | undefined {
50
return this.doRead(path.split('/').filter(p => p.length));
51
}
52
53
private doRead(pathParts: string[]): IndexedDBFileSystemEntry | undefined {
54
if (pathParts.length === 0) { return this.entry; }
55
if (this.entry.type !== FileType.Directory) {
56
throw ERR_UNKNOWN_INTERNAL('Internal error reading from IndexedDBFSNode -- expected directory at ' + this.entry.path);
57
}
58
const next = this.entry.children.get(pathParts[0]);
59
60
if (!next) { return undefined; }
61
return next.doRead(pathParts.slice(1));
62
}
63
64
delete(path: string): void {
65
const toDelete = path.split('/').filter(p => p.length);
66
if (toDelete.length === 0) {
67
if (this.entry.type !== FileType.Directory) {
68
throw ERR_UNKNOWN_INTERNAL(`Internal error deleting from IndexedDBFSNode. Expected root entry to be directory`);
69
}
70
this.entry.children.clear();
71
} else {
72
return this.doDelete(toDelete, path);
73
}
74
}
75
76
private doDelete(pathParts: string[], originalPath: string): void {
77
if (pathParts.length === 0) {
78
throw ERR_UNKNOWN_INTERNAL(`Internal error deleting from IndexedDBFSNode -- got no deletion path parts (encountered while deleting ${originalPath})`);
79
}
80
else if (this.entry.type !== FileType.Directory) {
81
throw ERR_UNKNOWN_INTERNAL('Internal error deleting from IndexedDBFSNode -- expected directory at ' + this.entry.path);
82
}
83
else if (pathParts.length === 1) {
84
this.entry.children.delete(pathParts[0]);
85
}
86
else {
87
const next = this.entry.children.get(pathParts[0]);
88
if (!next) {
89
throw ERR_UNKNOWN_INTERNAL('Internal error deleting from IndexedDBFSNode -- expected entry at ' + this.entry.path + '/' + next);
90
}
91
next.doDelete(pathParts.slice(1), originalPath);
92
}
93
}
94
95
add(path: string, entry: { type: 'file'; size?: number } | { type: 'dir' }) {
96
this.doAdd(path.split('/').filter(p => p.length), entry, path);
97
}
98
99
private doAdd(pathParts: string[], entry: { type: 'file'; size?: number } | { type: 'dir' }, originalPath: string) {
100
if (pathParts.length === 0) {
101
throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- adding empty path (encountered while adding ${originalPath})`);
102
}
103
else if (this.entry.type !== FileType.Directory) {
104
throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- parent is not a directory (encountered while adding ${originalPath})`);
105
}
106
else if (pathParts.length === 1) {
107
const next = pathParts[0];
108
const existing = this.entry.children.get(next);
109
if (entry.type === 'dir') {
110
if (existing?.entry.type === FileType.File) {
111
throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- overwriting file with directory: ${this.entry.path}/${next} (encountered while adding ${originalPath})`);
112
}
113
this.entry.children.set(next, existing ?? new IndexedDBFileSystemNode({
114
type: FileType.Directory,
115
path: this.entry.path + '/' + next,
116
children: new Map(),
117
}));
118
} else {
119
if (existing?.entry.type === FileType.Directory) {
120
throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- overwriting directory with file: ${this.entry.path}/${next} (encountered while adding ${originalPath})`);
121
}
122
this.entry.children.set(next, new IndexedDBFileSystemNode({
123
type: FileType.File,
124
path: this.entry.path + '/' + next,
125
size: entry.size,
126
}));
127
}
128
}
129
else if (pathParts.length > 1) {
130
const next = pathParts[0];
131
let childNode = this.entry.children.get(next);
132
if (!childNode) {
133
childNode = new IndexedDBFileSystemNode({
134
children: new Map(),
135
path: this.entry.path + '/' + next,
136
type: FileType.Directory
137
});
138
this.entry.children.set(next, childNode);
139
}
140
else if (childNode.type === FileType.File) {
141
throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- overwriting file entry with directory: ${this.entry.path}/${next} (encountered while adding ${originalPath})`);
142
}
143
childNode.doAdd(pathParts.slice(1), entry, originalPath);
144
}
145
}
146
147
print(indentation = '') {
148
console.log(indentation + this.entry.path);
149
if (this.entry.type === FileType.Directory) {
150
this.entry.children.forEach(child => child.print(indentation + ' '));
151
}
152
}
153
}
154
155
export class IndexedDBFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability {
156
157
readonly capabilities: FileSystemProviderCapabilities =
158
FileSystemProviderCapabilities.FileReadWrite
159
| FileSystemProviderCapabilities.PathCaseSensitive;
160
readonly onDidChangeCapabilities: Event<void> = Event.None;
161
162
private readonly extUri = new ExtUri(() => false) /* Case Sensitive */;
163
164
private readonly changesBroadcastChannel: BroadcastDataChannel<UriDto<IFileChange>[]> | undefined;
165
private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());
166
readonly onDidChangeFile: Event<readonly IFileChange[]> = this._onDidChangeFile.event;
167
168
private readonly mtimes = new Map<string, number>();
169
170
private cachedFiletree: Promise<IndexedDBFileSystemNode> | undefined;
171
private writeManyThrottler: Throttler;
172
173
constructor(readonly scheme: string, private indexedDB: IndexedDB, private readonly store: string, watchCrossWindowChanges: boolean) {
174
super();
175
this.writeManyThrottler = new Throttler();
176
177
if (watchCrossWindowChanges) {
178
this.changesBroadcastChannel = this._register(new BroadcastDataChannel<UriDto<IFileChange>[]>(`vscode.indexedDB.${scheme}.changes`));
179
this._register(this.changesBroadcastChannel.onDidReceiveData(changes => {
180
this._onDidChangeFile.fire(changes.map(c => ({ type: c.type, resource: URI.revive(c.resource) })));
181
}));
182
}
183
}
184
185
watch(resource: URI, opts: IWatchOptions): IDisposable {
186
return Disposable.None;
187
}
188
189
async mkdir(resource: URI): Promise<void> {
190
try {
191
const resourceStat = await this.stat(resource);
192
if (resourceStat.type === FileType.File) {
193
throw ERR_FILE_NOT_DIR;
194
}
195
} catch (error) { /* Ignore */ }
196
(await this.getFiletree()).add(resource.path, { type: 'dir' });
197
}
198
199
async stat(resource: URI): Promise<IStat> {
200
const entry = (await this.getFiletree()).read(resource.path);
201
202
if (entry?.type === FileType.File) {
203
return {
204
type: FileType.File,
205
ctime: 0,
206
mtime: this.mtimes.get(resource.toString()) || 0,
207
size: entry.size ?? (await this.readFile(resource)).byteLength
208
};
209
}
210
211
if (entry?.type === FileType.Directory) {
212
return {
213
type: FileType.Directory,
214
ctime: 0,
215
mtime: 0,
216
size: 0
217
};
218
}
219
220
throw ERR_FILE_NOT_FOUND;
221
}
222
223
async readdir(resource: URI): Promise<DirEntry[]> {
224
try {
225
const entry = (await this.getFiletree()).read(resource.path);
226
if (!entry) {
227
// Dirs aren't saved to disk, so empty dirs will be lost on reload.
228
// Thus we have two options for what happens when you try to read a dir and nothing is found:
229
// - Throw FileSystemProviderErrorCode.FileNotFound
230
// - Return []
231
// We choose to return [] as creating a dir then reading it (even after reload) should not throw an error.
232
return [];
233
}
234
if (entry.type !== FileType.Directory) {
235
throw ERR_FILE_NOT_DIR;
236
}
237
else {
238
return [...entry.children.entries()].map(([name, node]) => [name, node.type]);
239
}
240
} catch (error) {
241
throw error;
242
}
243
}
244
245
async readFile(resource: URI): Promise<Uint8Array> {
246
try {
247
const result = await this.indexedDB.runInTransaction(this.store, 'readonly', objectStore => objectStore.get(resource.path));
248
if (result === undefined) {
249
throw ERR_FILE_NOT_FOUND;
250
}
251
const buffer = result instanceof Uint8Array ? result : isString(result) ? VSBuffer.fromString(result).buffer : undefined;
252
if (buffer === undefined) {
253
throw ERR_UNKNOWN_INTERNAL(`IndexedDB entry at "${resource.path}" in unexpected format`);
254
}
255
256
// update cache
257
const fileTree = await this.getFiletree();
258
fileTree.add(resource.path, { type: 'file', size: buffer.byteLength });
259
260
return buffer;
261
} catch (error) {
262
throw error;
263
}
264
}
265
266
async writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise<void> {
267
try {
268
const existing = await this.stat(resource).catch(() => undefined);
269
if (existing?.type === FileType.Directory) {
270
throw ERR_FILE_IS_DIR;
271
}
272
await this.bulkWrite([[resource, content]]);
273
} catch (error) {
274
throw error;
275
}
276
}
277
278
async rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise<void> {
279
const fileTree = await this.getFiletree();
280
const fromEntry = fileTree.read(from.path);
281
if (!fromEntry) {
282
throw ERR_FILE_NOT_FOUND;
283
}
284
285
const toEntry = fileTree.read(to.path);
286
if (toEntry) {
287
if (!opts.overwrite) {
288
throw createFileSystemProviderError('file exists already', FileSystemProviderErrorCode.FileExists);
289
}
290
if (toEntry.type !== fromEntry.type) {
291
throw createFileSystemProviderError('Cannot rename files with different types', FileSystemProviderErrorCode.Unknown);
292
}
293
// delete the target file if exists
294
await this.delete(to, { recursive: true, useTrash: false, atomic: false });
295
}
296
297
const toTargetResource = (path: string): URI => this.extUri.joinPath(to, this.extUri.relativePath(from, from.with({ path })) || '');
298
299
const sourceEntries = await this.tree(from);
300
const sourceFiles: DirEntry[] = [];
301
for (const sourceEntry of sourceEntries) {
302
if (sourceEntry[1] === FileType.File) {
303
sourceFiles.push(sourceEntry);
304
} else if (sourceEntry[1] === FileType.Directory) {
305
// add directories to the tree
306
fileTree.add(toTargetResource(sourceEntry[0]).path, { type: 'dir' });
307
}
308
}
309
310
if (sourceFiles.length) {
311
const targetFiles: [URI, Uint8Array][] = [];
312
const sourceFilesContents = await this.indexedDB.runInTransaction(this.store, 'readonly', objectStore => sourceFiles.map(([path]) => objectStore.get(path)));
313
for (let index = 0; index < sourceFiles.length; index++) {
314
const content = sourceFilesContents[index] instanceof Uint8Array ? sourceFilesContents[index] : isString(sourceFilesContents[index]) ? VSBuffer.fromString(sourceFilesContents[index]).buffer : undefined;
315
if (content) {
316
targetFiles.push([toTargetResource(sourceFiles[index][0]), content]);
317
}
318
}
319
await this.bulkWrite(targetFiles);
320
}
321
322
await this.delete(from, { recursive: true, useTrash: false, atomic: false });
323
}
324
325
async delete(resource: URI, opts: IFileDeleteOptions): Promise<void> {
326
let stat: IStat;
327
try {
328
stat = await this.stat(resource);
329
} catch (e) {
330
if (e.code === FileSystemProviderErrorCode.FileNotFound) {
331
return;
332
}
333
throw e;
334
}
335
336
let toDelete: string[];
337
if (opts.recursive) {
338
const tree = await this.tree(resource);
339
toDelete = tree.map(([path]) => path);
340
} else {
341
if (stat.type === FileType.Directory && (await this.readdir(resource)).length) {
342
throw ERR_DIR_NOT_EMPTY;
343
}
344
toDelete = [resource.path];
345
}
346
await this.deleteKeys(toDelete);
347
(await this.getFiletree()).delete(resource.path);
348
toDelete.forEach(key => this.mtimes.delete(key));
349
this.triggerChanges(toDelete.map(path => ({ resource: resource.with({ path }), type: FileChangeType.DELETED })));
350
}
351
352
private async tree(resource: URI): Promise<DirEntry[]> {
353
const stat = await this.stat(resource);
354
const allEntries: DirEntry[] = [[resource.path, stat.type]];
355
if (stat.type === FileType.Directory) {
356
const dirEntries = await this.readdir(resource);
357
for (const [key, type] of dirEntries) {
358
const childResource = this.extUri.joinPath(resource, key);
359
allEntries.push([childResource.path, type]);
360
if (type === FileType.Directory) {
361
const childEntries = await this.tree(childResource);
362
allEntries.push(...childEntries);
363
}
364
}
365
}
366
return allEntries;
367
}
368
369
private triggerChanges(changes: IFileChange[]): void {
370
if (changes.length) {
371
this._onDidChangeFile.fire(changes);
372
373
this.changesBroadcastChannel?.postData(changes);
374
}
375
}
376
377
private getFiletree(): Promise<IndexedDBFileSystemNode> {
378
if (!this.cachedFiletree) {
379
this.cachedFiletree = (async () => {
380
const rootNode = new IndexedDBFileSystemNode({
381
children: new Map(),
382
path: '',
383
type: FileType.Directory
384
});
385
const result = await this.indexedDB.runInTransaction(this.store, 'readonly', objectStore => objectStore.getAllKeys());
386
const keys = result.map(key => key.toString());
387
keys.forEach(key => rootNode.add(key, { type: 'file' }));
388
return rootNode;
389
})();
390
}
391
return this.cachedFiletree;
392
}
393
394
private async bulkWrite(files: [URI, Uint8Array][]): Promise<void> {
395
files.forEach(([resource, content]) => this.fileWriteBatch.push({ content, resource }));
396
await this.writeManyThrottler.queue(() => this.writeMany());
397
398
const fileTree = await this.getFiletree();
399
for (const [resource, content] of files) {
400
fileTree.add(resource.path, { type: 'file', size: content.byteLength });
401
this.mtimes.set(resource.toString(), Date.now());
402
}
403
404
this.triggerChanges(files.map(([resource]) => ({ resource, type: FileChangeType.UPDATED })));
405
}
406
407
private fileWriteBatch: { resource: URI; content: Uint8Array }[] = [];
408
private async writeMany() {
409
if (this.fileWriteBatch.length) {
410
const fileBatch = this.fileWriteBatch.splice(0, this.fileWriteBatch.length);
411
try {
412
await this.indexedDB.runInTransaction(this.store, 'readwrite', objectStore => fileBatch.map(entry => {
413
return objectStore.put(entry.content, entry.resource.path);
414
}));
415
} catch (ex) {
416
if (ex instanceof DOMException && ex.name === 'QuotaExceededError') {
417
throw ERR_FILE_EXCEEDS_STORAGE_QUOTA;
418
}
419
420
throw ex;
421
}
422
}
423
}
424
425
private async deleteKeys(keys: string[]): Promise<void> {
426
if (keys.length) {
427
await this.indexedDB.runInTransaction(this.store, 'readwrite', objectStore => keys.map(key => objectStore.delete(key)));
428
}
429
}
430
431
async reset(): Promise<void> {
432
await this.indexedDB.runInTransaction(this.store, 'readwrite', objectStore => objectStore.clear());
433
}
434
435
}
436
437