@theia/filesystem 1.65.0-next.55 → 1.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/lib/browser/download/file-download-command-contribution.d.ts +1 -1
  2. package/lib/browser/download/file-download-command-contribution.d.ts.map +1 -1
  3. package/lib/browser/download/file-download-command-contribution.js +3 -3
  4. package/lib/browser/download/file-download-command-contribution.js.map +1 -1
  5. package/lib/browser/download/file-download-frontend-module.d.ts.map +1 -1
  6. package/lib/browser/download/file-download-frontend-module.js +2 -1
  7. package/lib/browser/download/file-download-frontend-module.js.map +1 -1
  8. package/lib/browser/download/file-download-service.d.ts +2 -10
  9. package/lib/browser/download/file-download-service.d.ts.map +1 -1
  10. package/lib/browser/download/file-download-service.js +8 -7
  11. package/lib/browser/download/file-download-service.js.map +1 -1
  12. package/lib/browser/file-tree/file-tree-widget.d.ts +1 -1
  13. package/lib/browser/file-tree/file-tree-widget.d.ts.map +1 -1
  14. package/lib/browser/file-tree/file-tree-widget.js +3 -3
  15. package/lib/browser/file-tree/file-tree-widget.js.map +1 -1
  16. package/lib/browser/filesystem-frontend-contribution.d.ts +2 -2
  17. package/lib/browser/filesystem-frontend-contribution.d.ts.map +1 -1
  18. package/lib/browser/filesystem-frontend-contribution.js +3 -3
  19. package/lib/browser/filesystem-frontend-contribution.js.map +1 -1
  20. package/lib/browser/filesystem-frontend-module.d.ts.map +1 -1
  21. package/lib/browser/filesystem-frontend-module.js +3 -2
  22. package/lib/browser/filesystem-frontend-module.js.map +1 -1
  23. package/lib/browser/{file-upload-service.d.ts → upload/file-upload-service-impl.d.ts} +16 -52
  24. package/lib/browser/upload/file-upload-service-impl.d.ts.map +1 -0
  25. package/lib/browser/{file-upload-service.js → upload/file-upload-service-impl.js} +27 -27
  26. package/lib/browser/upload/file-upload-service-impl.js.map +1 -0
  27. package/lib/browser-only/browser-only-filesystem-frontend-module.d.ts.map +1 -1
  28. package/lib/browser-only/browser-only-filesystem-frontend-module.js +8 -0
  29. package/lib/browser-only/browser-only-filesystem-frontend-module.js.map +1 -1
  30. package/lib/browser-only/download/file-download-command-contribution.d.ts +15 -0
  31. package/lib/browser-only/download/file-download-command-contribution.d.ts.map +1 -0
  32. package/lib/browser-only/download/file-download-command-contribution.js +55 -0
  33. package/lib/browser-only/download/file-download-command-contribution.js.map +1 -0
  34. package/lib/browser-only/download/file-download-frontend-module.d.ts +4 -0
  35. package/lib/browser-only/download/file-download-frontend-module.d.ts.map +1 -0
  36. package/lib/browser-only/download/file-download-frontend-module.js +27 -0
  37. package/lib/browser-only/download/file-download-frontend-module.js.map +1 -0
  38. package/lib/browser-only/download/file-download-service.d.ts +86 -0
  39. package/lib/browser-only/download/file-download-service.d.ts.map +1 -0
  40. package/lib/browser-only/download/file-download-service.js +551 -0
  41. package/lib/browser-only/download/file-download-service.js.map +1 -0
  42. package/lib/browser-only/file-search.d.ts +38 -0
  43. package/lib/browser-only/file-search.d.ts.map +1 -0
  44. package/lib/browser-only/file-search.js +153 -0
  45. package/lib/browser-only/file-search.js.map +1 -0
  46. package/lib/browser-only/opfs-filesystem-initialization.d.ts +4 -2
  47. package/lib/browser-only/opfs-filesystem-initialization.d.ts.map +1 -1
  48. package/lib/browser-only/opfs-filesystem-initialization.js +4 -1
  49. package/lib/browser-only/opfs-filesystem-initialization.js.map +1 -1
  50. package/lib/browser-only/opfs-filesystem-provider.d.ts +89 -12
  51. package/lib/browser-only/opfs-filesystem-provider.d.ts.map +1 -1
  52. package/lib/browser-only/opfs-filesystem-provider.js +345 -181
  53. package/lib/browser-only/opfs-filesystem-provider.js.map +1 -1
  54. package/lib/browser-only/upload/file-upload-service-impl.d.ts +67 -0
  55. package/lib/browser-only/upload/file-upload-service-impl.d.ts.map +1 -0
  56. package/lib/browser-only/upload/file-upload-service-impl.js +328 -0
  57. package/lib/browser-only/upload/file-upload-service-impl.js.map +1 -0
  58. package/lib/common/download/file-download.d.ts +17 -0
  59. package/lib/common/download/file-download.d.ts.map +1 -0
  60. package/lib/common/download/{file-download-data.js → file-download.js} +3 -2
  61. package/lib/common/download/file-download.js.map +1 -0
  62. package/lib/common/files.d.ts +8 -1
  63. package/lib/common/files.d.ts.map +1 -1
  64. package/lib/common/files.js +35 -1
  65. package/lib/common/files.js.map +1 -1
  66. package/lib/common/io.js +7 -1
  67. package/lib/common/io.js.map +1 -1
  68. package/lib/common/upload/file-upload.d.ts +45 -0
  69. package/lib/common/upload/file-upload.d.ts.map +1 -0
  70. package/{src/common/download/file-download-data.ts → lib/common/upload/file-upload.js} +6 -13
  71. package/lib/common/upload/file-upload.js.map +1 -0
  72. package/lib/node/disk-file-system-provider.d.ts.map +1 -1
  73. package/lib/node/disk-file-system-provider.js +2 -4
  74. package/lib/node/disk-file-system-provider.js.map +1 -1
  75. package/lib/node/download/file-download-handler.js +2 -2
  76. package/lib/node/download/file-download-handler.js.map +1 -1
  77. package/lib/node/filesystem-backend-module.js +1 -1
  78. package/lib/node/filesystem-backend-module.js.map +1 -1
  79. package/lib/node/parcel-watcher/parcel-filesystem-service.d.ts +2 -2
  80. package/lib/node/parcel-watcher/parcel-filesystem-service.d.ts.map +1 -1
  81. package/lib/node/parcel-watcher/parcel-filesystem-service.js.map +1 -1
  82. package/lib/node/upload/node-file-upload-service.d.ts.map +1 -0
  83. package/lib/node/{node-file-upload-service.js → upload/node-file-upload-service.js} +1 -1
  84. package/lib/node/upload/node-file-upload-service.js.map +1 -0
  85. package/package.json +10 -5
  86. package/src/browser/download/file-download-command-contribution.ts +1 -1
  87. package/src/browser/download/file-download-frontend-module.ts +3 -2
  88. package/src/browser/download/file-download-service.ts +7 -12
  89. package/src/browser/file-tree/file-tree-widget.tsx +1 -1
  90. package/src/browser/filesystem-frontend-contribution.ts +2 -2
  91. package/src/browser/filesystem-frontend-module.ts +3 -2
  92. package/src/browser/{file-upload-service.ts → upload/file-upload-service-impl.ts} +31 -72
  93. package/src/browser-only/browser-only-filesystem-frontend-module.ts +10 -0
  94. package/src/browser-only/download/file-download-command-contribution.ts +56 -0
  95. package/src/browser-only/download/file-download-frontend-module.ts +26 -0
  96. package/src/browser-only/download/file-download-service.ts +726 -0
  97. package/src/browser-only/file-search.ts +170 -0
  98. package/src/browser-only/opfs-filesystem-initialization.ts +7 -4
  99. package/src/browser-only/opfs-filesystem-provider.ts +402 -189
  100. package/src/browser-only/upload/file-upload-service-impl.ts +408 -0
  101. package/src/common/download/file-download.ts +40 -0
  102. package/src/common/files.ts +42 -1
  103. package/src/common/io.ts +6 -1
  104. package/src/common/upload/file-upload.ts +65 -0
  105. package/src/node/disk-file-system-provider.ts +3 -4
  106. package/src/node/download/file-download-handler.ts +1 -1
  107. package/src/node/filesystem-backend-module.ts +1 -1
  108. package/src/node/parcel-watcher/parcel-filesystem-service.ts +2 -2
  109. package/src/node/{node-file-upload-service.ts → upload/node-file-upload-service.ts} +1 -1
  110. package/lib/browser/file-upload-service.d.ts.map +0 -1
  111. package/lib/browser/file-upload-service.js.map +0 -1
  112. package/lib/common/download/file-download-data.d.ts +0 -7
  113. package/lib/common/download/file-download-data.d.ts.map +0 -1
  114. package/lib/common/download/file-download-data.js.map +0 -1
  115. package/lib/node/node-file-upload-service.d.ts.map +0 -1
  116. package/lib/node/node-file-upload-service.js.map +0 -1
  117. /package/lib/node/{node-file-upload-service.d.ts → upload/node-file-upload-service.d.ts} +0 -0
@@ -0,0 +1,726 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2025 Maksim Kachurin and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { inject, injectable } from '@theia/core/shared/inversify';
18
+ import URI from '@theia/core/lib/common/uri';
19
+ import { ILogger } from '@theia/core/lib/common/logger';
20
+ import { MessageService } from '@theia/core/lib/common/message-service';
21
+ import { FileSystemPreferences } from '../../common/filesystem-preferences';
22
+ import { nls } from '@theia/core';
23
+ import { BinaryBuffer } from '@theia/core/lib/common/buffer';
24
+ import { binaryStreamToWebStream } from '@theia/core/lib/common/stream';
25
+ import { FileService } from '../../browser/file-service';
26
+ import type { FileDownloadService } from '../../common/download/file-download';
27
+ import * as tarStream from 'tar-stream';
28
+ import { minimatch } from 'minimatch';
29
+
30
+ @injectable()
31
+ export class FileDownloadServiceImpl implements FileDownloadService {
32
+ @inject(FileService)
33
+ protected readonly fileService: FileService;
34
+
35
+ @inject(ILogger)
36
+ protected readonly logger: ILogger;
37
+
38
+ @inject(MessageService)
39
+ protected readonly messageService: MessageService;
40
+
41
+ @inject(FileSystemPreferences)
42
+ protected readonly preferences: FileSystemPreferences;
43
+
44
+ private readonly ignorePatterns: string[] = [];
45
+
46
+ protected getFileSizeThreshold(): number {
47
+ return this.preferences['files.maxFileSizeMB'] * 1024 * 1024;
48
+ }
49
+
50
+ /**
51
+ * Check if streaming download is supported (File System Access API)
52
+ */
53
+ protected isStreamingSupported(): boolean {
54
+ if (!globalThis.isSecureContext) {
55
+ return false;
56
+ }
57
+
58
+ if (!('showSaveFilePicker' in globalThis)) {
59
+ return false;
60
+ }
61
+
62
+ try {
63
+ return typeof (globalThis as unknown as { ReadableStream?: { prototype?: { pipeTo?: unknown } } }).ReadableStream?.prototype?.pipeTo === 'function';
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ async download(uris: URI[], options?: never): Promise<void> {
70
+ if (uris.length === 0) {
71
+ return;
72
+ }
73
+
74
+ const abortController = new AbortController();
75
+
76
+ try {
77
+ const progress = await this.messageService.showProgress(
78
+ {
79
+ text: nls.localize(
80
+ 'theia/filesystem/prepareDownload',
81
+ 'Preparing download...'
82
+ ),
83
+ options: { cancelable: true },
84
+ },
85
+ () => {
86
+ abortController.abort();
87
+ }
88
+ );
89
+
90
+ try {
91
+ await this.doDownload(uris, abortController.signal);
92
+ } finally {
93
+ progress.cancel();
94
+ }
95
+ } catch (e) {
96
+ if (!abortController.signal.aborted) {
97
+ this.logger.error(
98
+ `Error occurred when downloading: ${uris.map(u =>
99
+ u.toString(true)
100
+ )}.`,
101
+ e
102
+ );
103
+ this.messageService.error(
104
+ nls.localize(
105
+ 'theia/filesystem/downloadError',
106
+ 'Failed to download files. See console for details.'
107
+ )
108
+ );
109
+ }
110
+ }
111
+ }
112
+
113
+ protected async doDownload(
114
+ uris: URI[],
115
+ abortSignal: AbortSignal
116
+ ): Promise<void> {
117
+ try {
118
+ const { files, directories, totalSize, stats } =
119
+ await this.collectFiles(uris, abortSignal);
120
+
121
+ if (abortSignal.aborted) {
122
+ return;
123
+ }
124
+
125
+ if (
126
+ totalSize > this.getFileSizeThreshold() &&
127
+ this.isStreamingSupported()
128
+ ) {
129
+ await this.streamDownloadToFile(
130
+ uris,
131
+ files,
132
+ directories,
133
+ stats,
134
+ abortSignal
135
+ );
136
+ } else {
137
+ let data: Blob;
138
+ let filename: string = 'theia-download.tar';
139
+
140
+ if (uris.length === 1) {
141
+ const stat = stats[0];
142
+
143
+ if (stat.isDirectory) {
144
+ filename = `${stat.name}.tar`;
145
+ data = await this.createArchiveBlob(async tarPack => {
146
+ await this.addFilesToArchive(
147
+ tarPack,
148
+ files,
149
+ directories,
150
+ abortSignal
151
+ );
152
+ }, abortSignal);
153
+ } else {
154
+ filename = stat.name;
155
+ const content = await this.fileService.readFile(
156
+ uris[0]
157
+ );
158
+ data = new Blob([content.value.buffer as BlobPart], {
159
+ type: 'application/octet-stream',
160
+ });
161
+ }
162
+ } else {
163
+ data = await this.createArchiveBlob(async tarPack => {
164
+ await this.addFilesToArchive(
165
+ tarPack,
166
+ files,
167
+ directories,
168
+ abortSignal
169
+ );
170
+ }, abortSignal);
171
+ }
172
+
173
+ if (!abortSignal.aborted) {
174
+ this.blobDownload(data, filename);
175
+ }
176
+ }
177
+ } catch (error) {
178
+ if (!abortSignal.aborted) {
179
+ this.logger.error('Failed to download files', error);
180
+ throw error;
181
+ }
182
+ }
183
+ }
184
+
185
+ protected async createArchiveBlob(
186
+ populateArchive: (tarPack: tarStream.Pack) => Promise<void>,
187
+ abortSignal: AbortSignal
188
+ ): Promise<Blob> {
189
+ const stream = this.createArchiveStream(abortSignal, populateArchive);
190
+ const reader = stream.getReader();
191
+ const chunks: Uint8Array[] = [];
192
+ let total = 0;
193
+
194
+ try {
195
+ while (true) {
196
+ if (abortSignal.aborted) {
197
+ throw new Error('Operation aborted');
198
+ }
199
+ const { done, value } = await reader.read();
200
+ if (done) {
201
+ break;
202
+ }
203
+ chunks.push(value!);
204
+ total += value!.byteLength;
205
+ }
206
+
207
+ const out = new Uint8Array(total);
208
+ let off = 0;
209
+
210
+ for (const c of chunks) {
211
+ out.set(c, off);
212
+ off += c.byteLength;
213
+ }
214
+ return new Blob([out], { type: 'application/x-tar' });
215
+ } finally {
216
+ try {
217
+ reader.releaseLock();
218
+ } catch {}
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Create ReadableStream from a single file using FileService streaming
224
+ */
225
+ protected async createFileStream(
226
+ uri: URI,
227
+ abortSignal: AbortSignal
228
+ ): Promise<ReadableStream<Uint8Array>> {
229
+ if (abortSignal.aborted) {
230
+ throw new Error('Operation aborted');
231
+ }
232
+
233
+ const fileStreamContent = await this.fileService.readFileStream(uri);
234
+
235
+ return binaryStreamToWebStream(fileStreamContent.value, abortSignal);
236
+ }
237
+
238
+ protected async addFileToArchive(
239
+ tarPack: tarStream.Pack,
240
+ file: { uri: URI; path: string; size: number },
241
+ abortSignal: AbortSignal
242
+ ): Promise<void> {
243
+ if (abortSignal.aborted) {
244
+ return;
245
+ }
246
+
247
+ try {
248
+ const name = this.sanitizeFilename(file.path);
249
+ const size = file.size;
250
+ const entry = tarPack.entry({ name, size });
251
+
252
+ const fileStreamContent = await this.fileService.readFileStream(
253
+ file.uri
254
+ );
255
+ const src = fileStreamContent.value;
256
+
257
+ return new Promise<void>((resolve, reject) => {
258
+ const cleanup = () => {
259
+ src.removeListener?.('data', onData);
260
+ src.removeListener?.('end', onEnd);
261
+ src.removeListener?.('error', onError);
262
+ entry.removeListener?.('error', onEntryError);
263
+ abortSignal.removeEventListener('abort', onAbort);
264
+ };
265
+
266
+ const onAbort = () => {
267
+ cleanup();
268
+ entry.destroy?.();
269
+ reject(new Error('Operation aborted'));
270
+ };
271
+
272
+ let ended = false;
273
+ let pendingWrite: Promise<void> | undefined = undefined;
274
+
275
+ const onData = async (chunk: BinaryBuffer) => {
276
+ if (abortSignal.aborted || ended) {
277
+ return;
278
+ }
279
+
280
+ src.pause?.();
281
+
282
+ const u8 = new Uint8Array(
283
+ chunk.buffer as unknown as ArrayBuffer
284
+ );
285
+
286
+ const canWrite = entry.write(u8);
287
+
288
+ if (!canWrite) {
289
+ pendingWrite = new Promise<void>(resolveDrain => {
290
+ entry.once('drain', resolveDrain);
291
+ });
292
+ await pendingWrite;
293
+ pendingWrite = undefined;
294
+ }
295
+
296
+ if (!ended) {
297
+ src.resume?.();
298
+ }
299
+ };
300
+
301
+ const onEnd = async () => {
302
+ ended = true;
303
+
304
+ if (pendingWrite) {
305
+ await pendingWrite;
306
+ }
307
+
308
+ cleanup();
309
+ entry.end();
310
+ resolve();
311
+ };
312
+
313
+ const onError = (err: Error) => {
314
+ cleanup();
315
+ try {
316
+ entry.destroy?.(err);
317
+ } catch {}
318
+ reject(err);
319
+ };
320
+
321
+ const onEntryError = (err: Error) => {
322
+ cleanup();
323
+ reject(
324
+ new Error(`Entry error for ${name}: ${err.message}`)
325
+ );
326
+ };
327
+
328
+ if (abortSignal.aborted) {
329
+ return onAbort();
330
+ }
331
+ abortSignal.addEventListener('abort', onAbort, { once: true });
332
+
333
+ entry.on?.('error', onEntryError);
334
+ src.on?.('data', onData);
335
+ src.on?.('end', onEnd);
336
+ src.on?.('error', onError);
337
+ });
338
+ } catch (error) {
339
+ this.logger.error(
340
+ `Failed to read file ${file.uri.toString()}:`,
341
+ error
342
+ );
343
+ throw error;
344
+ }
345
+ }
346
+
347
+ protected async addFilesToArchive(
348
+ tarPack: tarStream.Pack,
349
+ files: Array<{ uri: URI; path: string; size: number }>,
350
+ directories: Array<{ path: string }>,
351
+ abortSignal: AbortSignal
352
+ ): Promise<void> {
353
+ const uniqueDirs = new Set<string>();
354
+
355
+ for (const dir of directories) {
356
+ const normalizedPath = this.sanitizeFilename(dir.path) + '/';
357
+ uniqueDirs.add(normalizedPath);
358
+ }
359
+
360
+ for (const dirPath of uniqueDirs) {
361
+ try {
362
+ const entry = tarPack.entry({
363
+ name: dirPath,
364
+ type: 'directory',
365
+ });
366
+
367
+ entry.end();
368
+ } catch (error) {
369
+ this.logger.error(
370
+ `Failed to add directory ${dirPath}:`,
371
+ error
372
+ );
373
+ }
374
+ }
375
+
376
+ for (const file of files) {
377
+ if (abortSignal.aborted) {
378
+ break;
379
+ }
380
+ try {
381
+ await this.addFileToArchive(
382
+ tarPack,
383
+ file,
384
+ abortSignal
385
+ );
386
+ } catch (error) {
387
+ this.logger.error(
388
+ `Failed to read file ${file.uri.toString()}:`,
389
+ error
390
+ );
391
+ }
392
+ }
393
+ }
394
+
395
+ protected createArchiveStream(
396
+ abortSignal: AbortSignal,
397
+ populateArchive: (tarPack: tarStream.Pack) => Promise<void>
398
+ ): globalThis.ReadableStream<Uint8Array> {
399
+ const tarPack = tarStream.pack();
400
+
401
+ return new ReadableStream<Uint8Array>({
402
+ start(
403
+ controller: ReadableStreamDefaultController<Uint8Array>
404
+ ): void {
405
+ const cleanup = () => {
406
+ try {
407
+ tarPack.removeAllListeners();
408
+ } catch {}
409
+ try {
410
+ tarPack.destroy?.();
411
+ } catch {}
412
+ abortSignal.removeEventListener('abort', onAbort);
413
+ };
414
+
415
+ const onAbort = () => {
416
+ cleanup();
417
+ controller.error(new Error('Operation aborted'));
418
+ };
419
+
420
+ if (abortSignal.aborted) {
421
+ onAbort();
422
+ return;
423
+ }
424
+
425
+ abortSignal.addEventListener('abort', onAbort, { once: true });
426
+
427
+ tarPack.on('data', (chunk: Uint8Array) => {
428
+ if (abortSignal.aborted) {
429
+ return;
430
+ }
431
+ try {
432
+ controller.enqueue(chunk);
433
+ } catch (error) {
434
+ cleanup();
435
+ controller.error(error as Error);
436
+ }
437
+ });
438
+
439
+ tarPack.once('end', () => {
440
+ cleanup();
441
+ controller.close();
442
+ });
443
+
444
+ tarPack.once('error', error => {
445
+ cleanup();
446
+ controller.error(error);
447
+ });
448
+
449
+ populateArchive(tarPack)
450
+ .then(() => {
451
+ if (!abortSignal.aborted) {
452
+ tarPack.finalize();
453
+ }
454
+ })
455
+ .catch(error => {
456
+ cleanup();
457
+ controller.error(error);
458
+ });
459
+ },
460
+ cancel: () => {
461
+ try {
462
+ tarPack.finalize?.();
463
+ tarPack.destroy?.();
464
+ } catch {}
465
+ },
466
+ });
467
+ }
468
+
469
+ protected async streamDownloadToFile(
470
+ uris: URI[],
471
+ files: Array<{ uri: URI; path: string; size: number }>,
472
+ directories: Array<{ path: string }>,
473
+ stats: Array<{ name: string; isDirectory: boolean; size?: number }>,
474
+ abortSignal: AbortSignal
475
+ ): Promise<void> {
476
+ let filename = 'theia-download.tar';
477
+ if (uris.length === 1) {
478
+ const stat = stats[0];
479
+ filename = stat.isDirectory ? `${stat.name}.tar` : stat.name;
480
+ }
481
+
482
+ const isArchive = filename.endsWith('.tar');
483
+ let fileHandle: FileSystemFileHandle;
484
+ try {
485
+ // @ts-expect-error non-standard
486
+ fileHandle = await window.showSaveFilePicker({
487
+ suggestedName: filename,
488
+ types: isArchive
489
+ ? [
490
+ {
491
+ description: 'Archive files',
492
+ accept: { 'application/x-tar': ['.tar'] },
493
+ },
494
+ ]
495
+ : undefined,
496
+ });
497
+ } catch (error) {
498
+ if (error instanceof DOMException && error.name === 'AbortError') {
499
+ return;
500
+ }
501
+ throw error;
502
+ }
503
+
504
+ let stream: ReadableStream<Uint8Array>;
505
+
506
+ if (uris.length === 1) {
507
+ const stat = await this.fileService.resolve(uris[0]);
508
+ stream = stat.isDirectory
509
+ ? this.createArchiveStream(abortSignal, async tarPack => {
510
+ await this.addFilesToArchive(
511
+ tarPack,
512
+ files,
513
+ directories,
514
+ abortSignal
515
+ );
516
+ })
517
+ : await this.createFileStream(uris[0], abortSignal);
518
+ } else {
519
+ stream = this.createArchiveStream(abortSignal, async tarPack => {
520
+ await this.addFilesToArchive(
521
+ tarPack,
522
+ files,
523
+ directories,
524
+ abortSignal
525
+ );
526
+ });
527
+ }
528
+
529
+ const writable = await fileHandle.createWritable();
530
+ try {
531
+ await stream.pipeTo(writable, { signal: abortSignal });
532
+ } catch (error) {
533
+ try {
534
+ await writable.abort?.();
535
+ } catch {}
536
+ throw error;
537
+ }
538
+ }
539
+
540
+ protected blobDownload(data: Blob, filename: string): void {
541
+ const url = URL.createObjectURL(data);
542
+ const a = document.createElement('a');
543
+ a.href = url;
544
+ a.download = filename;
545
+ a.style.display = 'none';
546
+ document.body.appendChild(a);
547
+ a.click();
548
+
549
+ setTimeout(() => {
550
+ document.body.removeChild(a);
551
+ URL.revokeObjectURL(url);
552
+ }, 0);
553
+ }
554
+
555
+ protected sanitizeFilename(filename: string): string {
556
+ return filename
557
+ .replace(/[\\:*?"<>|]/g, '_') // Replace Windows-problematic chars
558
+ .replace(/\.\./g, '__') // Replace .. to prevent directory traversal
559
+ .replace(/^\/+/g, '') // Remove leading slashes
560
+ .replace(/\/+$/, '') // Remove trailing slashes for files
561
+ .replace(/[\u0000-\u001f\u007f]/g, '_') // Replace control characters
562
+ .replace(/\/+/g, '/')
563
+ .replace(/^\.$/, '_')
564
+ .replace(/^$/, '_');
565
+ }
566
+
567
+ protected shouldIncludeFile(path: string): boolean {
568
+ return !this.ignorePatterns.some((pattern: string) =>
569
+ minimatch(path, pattern)
570
+ );
571
+ }
572
+
573
+ /**
574
+ * Collect all files and calculate total size
575
+ */
576
+ protected async collectFiles(
577
+ uris: URI[],
578
+ abortSignal?: AbortSignal
579
+ ): Promise<{
580
+ files: Array<{ uri: URI; path: string; size: number }>;
581
+ directories: Array<{ path: string }>;
582
+ totalSize: number;
583
+ stats: Array<{ name: string; isDirectory: boolean; size?: number }>;
584
+ }> {
585
+ const files: Array<{ uri: URI; path: string; size: number }> = [];
586
+ const directories: Array<{ path: string }> = [];
587
+ let totalSize = 0;
588
+ const stats: Array<{
589
+ name: string;
590
+ isDirectory: boolean;
591
+ size?: number;
592
+ }> = [];
593
+
594
+ for (const uri of uris) {
595
+ if (abortSignal?.aborted) {
596
+ break;
597
+ }
598
+
599
+ try {
600
+ const stat = await this.fileService.resolve(uri, {
601
+ resolveMetadata: true,
602
+ });
603
+ stats.push({
604
+ name: stat.name,
605
+ isDirectory: stat.isDirectory,
606
+ size: stat.size,
607
+ });
608
+
609
+ if (abortSignal?.aborted) {
610
+ break;
611
+ }
612
+
613
+ if (!stat.isDirectory) {
614
+ const size = stat.size || 0;
615
+ files.push({ uri, path: stat.name, size });
616
+ totalSize += size;
617
+ continue;
618
+ }
619
+
620
+ if (!stat.children?.length) {
621
+ directories.push({ path: stat.name });
622
+ continue;
623
+ }
624
+
625
+ directories.push({ path: stat.name });
626
+
627
+ const dirResult = await this.collectFilesFromDirectory(
628
+ uri,
629
+ stat.name,
630
+ abortSignal
631
+ );
632
+ files.push(...dirResult.files);
633
+ directories.push(...dirResult.directories);
634
+ totalSize += dirResult.files.reduce(
635
+ (sum, file) => sum + file.size,
636
+ 0
637
+ );
638
+ } catch (error) {
639
+ this.logger.warn(
640
+ `Failed to collect files from ${uri.toString()}:`,
641
+ error
642
+ );
643
+ stats.push({
644
+ name: uri.path.name || 'unknown',
645
+ isDirectory: false,
646
+ size: 0,
647
+ });
648
+ }
649
+ }
650
+
651
+ return { files, directories, totalSize, stats };
652
+ }
653
+
654
+ /**
655
+ * Recursively collect files from a directory
656
+ */
657
+ protected async collectFilesFromDirectory(
658
+ dirUri: URI,
659
+ basePath: string,
660
+ abortSignal?: AbortSignal
661
+ ): Promise<{
662
+ files: Array<{ uri: URI; path: string; size: number }>;
663
+ directories: Array<{ path: string }>;
664
+ }> {
665
+ const files: Array<{ uri: URI; path: string; size: number }> = [];
666
+ const directories: Array<{ path: string }> = [];
667
+
668
+ try {
669
+ const dirStat = await this.fileService.resolve(dirUri);
670
+
671
+ if (abortSignal?.aborted) {
672
+ return { files, directories };
673
+ }
674
+
675
+ // Empty directory - add it to preserve structure
676
+ if (!dirStat.children?.length) {
677
+ directories.push({ path: basePath });
678
+ return { files, directories };
679
+ }
680
+
681
+ for (const child of dirStat.children) {
682
+ if (abortSignal?.aborted) {
683
+ break;
684
+ }
685
+
686
+ const childPath = basePath
687
+ ? `${basePath}/${child.name}`
688
+ : child.name;
689
+
690
+ if (!this.shouldIncludeFile(childPath)) {
691
+ continue;
692
+ }
693
+
694
+ if (child.isDirectory) {
695
+ directories.push({ path: childPath });
696
+
697
+ const subResult = await this.collectFilesFromDirectory(
698
+ child.resource,
699
+ childPath,
700
+ abortSignal
701
+ );
702
+
703
+ files.push(...subResult.files);
704
+ directories.push(...subResult.directories);
705
+ } else {
706
+ const childStat = await this.fileService.resolve(
707
+ child.resource
708
+ );
709
+
710
+ files.push({
711
+ uri: child.resource,
712
+ path: childPath,
713
+ size: childStat.size || 0,
714
+ });
715
+ }
716
+ }
717
+ } catch (error) {
718
+ this.logger.warn(
719
+ `Failed to collect files from directory ${dirUri.toString()}:`,
720
+ error
721
+ );
722
+ }
723
+
724
+ return { files, directories };
725
+ }
726
+ }