@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.
- package/lib/browser/download/file-download-command-contribution.d.ts +1 -1
- package/lib/browser/download/file-download-command-contribution.d.ts.map +1 -1
- package/lib/browser/download/file-download-command-contribution.js +3 -3
- package/lib/browser/download/file-download-command-contribution.js.map +1 -1
- package/lib/browser/download/file-download-frontend-module.d.ts.map +1 -1
- package/lib/browser/download/file-download-frontend-module.js +2 -1
- package/lib/browser/download/file-download-frontend-module.js.map +1 -1
- package/lib/browser/download/file-download-service.d.ts +2 -10
- package/lib/browser/download/file-download-service.d.ts.map +1 -1
- package/lib/browser/download/file-download-service.js +8 -7
- package/lib/browser/download/file-download-service.js.map +1 -1
- package/lib/browser/file-tree/file-tree-widget.d.ts +1 -1
- package/lib/browser/file-tree/file-tree-widget.d.ts.map +1 -1
- package/lib/browser/file-tree/file-tree-widget.js +3 -3
- package/lib/browser/file-tree/file-tree-widget.js.map +1 -1
- package/lib/browser/filesystem-frontend-contribution.d.ts +2 -2
- package/lib/browser/filesystem-frontend-contribution.d.ts.map +1 -1
- package/lib/browser/filesystem-frontend-contribution.js +3 -3
- package/lib/browser/filesystem-frontend-contribution.js.map +1 -1
- package/lib/browser/filesystem-frontend-module.d.ts.map +1 -1
- package/lib/browser/filesystem-frontend-module.js +3 -2
- package/lib/browser/filesystem-frontend-module.js.map +1 -1
- package/lib/browser/{file-upload-service.d.ts → upload/file-upload-service-impl.d.ts} +16 -52
- package/lib/browser/upload/file-upload-service-impl.d.ts.map +1 -0
- package/lib/browser/{file-upload-service.js → upload/file-upload-service-impl.js} +27 -27
- package/lib/browser/upload/file-upload-service-impl.js.map +1 -0
- package/lib/browser-only/browser-only-filesystem-frontend-module.d.ts.map +1 -1
- package/lib/browser-only/browser-only-filesystem-frontend-module.js +8 -0
- package/lib/browser-only/browser-only-filesystem-frontend-module.js.map +1 -1
- package/lib/browser-only/download/file-download-command-contribution.d.ts +15 -0
- package/lib/browser-only/download/file-download-command-contribution.d.ts.map +1 -0
- package/lib/browser-only/download/file-download-command-contribution.js +55 -0
- package/lib/browser-only/download/file-download-command-contribution.js.map +1 -0
- package/lib/browser-only/download/file-download-frontend-module.d.ts +4 -0
- package/lib/browser-only/download/file-download-frontend-module.d.ts.map +1 -0
- package/lib/browser-only/download/file-download-frontend-module.js +27 -0
- package/lib/browser-only/download/file-download-frontend-module.js.map +1 -0
- package/lib/browser-only/download/file-download-service.d.ts +86 -0
- package/lib/browser-only/download/file-download-service.d.ts.map +1 -0
- package/lib/browser-only/download/file-download-service.js +551 -0
- package/lib/browser-only/download/file-download-service.js.map +1 -0
- package/lib/browser-only/file-search.d.ts +38 -0
- package/lib/browser-only/file-search.d.ts.map +1 -0
- package/lib/browser-only/file-search.js +153 -0
- package/lib/browser-only/file-search.js.map +1 -0
- package/lib/browser-only/opfs-filesystem-initialization.d.ts +4 -2
- package/lib/browser-only/opfs-filesystem-initialization.d.ts.map +1 -1
- package/lib/browser-only/opfs-filesystem-initialization.js +4 -1
- package/lib/browser-only/opfs-filesystem-initialization.js.map +1 -1
- package/lib/browser-only/opfs-filesystem-provider.d.ts +89 -12
- package/lib/browser-only/opfs-filesystem-provider.d.ts.map +1 -1
- package/lib/browser-only/opfs-filesystem-provider.js +345 -181
- package/lib/browser-only/opfs-filesystem-provider.js.map +1 -1
- package/lib/browser-only/upload/file-upload-service-impl.d.ts +67 -0
- package/lib/browser-only/upload/file-upload-service-impl.d.ts.map +1 -0
- package/lib/browser-only/upload/file-upload-service-impl.js +328 -0
- package/lib/browser-only/upload/file-upload-service-impl.js.map +1 -0
- package/lib/common/download/file-download.d.ts +17 -0
- package/lib/common/download/file-download.d.ts.map +1 -0
- package/lib/common/download/{file-download-data.js → file-download.js} +3 -2
- package/lib/common/download/file-download.js.map +1 -0
- package/lib/common/files.d.ts +8 -1
- package/lib/common/files.d.ts.map +1 -1
- package/lib/common/files.js +35 -1
- package/lib/common/files.js.map +1 -1
- package/lib/common/io.js +7 -1
- package/lib/common/io.js.map +1 -1
- package/lib/common/upload/file-upload.d.ts +45 -0
- package/lib/common/upload/file-upload.d.ts.map +1 -0
- package/{src/common/download/file-download-data.ts → lib/common/upload/file-upload.js} +6 -13
- package/lib/common/upload/file-upload.js.map +1 -0
- package/lib/node/disk-file-system-provider.d.ts.map +1 -1
- package/lib/node/disk-file-system-provider.js +2 -4
- package/lib/node/disk-file-system-provider.js.map +1 -1
- package/lib/node/download/file-download-handler.js +2 -2
- package/lib/node/download/file-download-handler.js.map +1 -1
- package/lib/node/filesystem-backend-module.js +1 -1
- package/lib/node/filesystem-backend-module.js.map +1 -1
- package/lib/node/parcel-watcher/parcel-filesystem-service.d.ts +2 -2
- package/lib/node/parcel-watcher/parcel-filesystem-service.d.ts.map +1 -1
- package/lib/node/parcel-watcher/parcel-filesystem-service.js.map +1 -1
- package/lib/node/upload/node-file-upload-service.d.ts.map +1 -0
- package/lib/node/{node-file-upload-service.js → upload/node-file-upload-service.js} +1 -1
- package/lib/node/upload/node-file-upload-service.js.map +1 -0
- package/package.json +10 -5
- package/src/browser/download/file-download-command-contribution.ts +1 -1
- package/src/browser/download/file-download-frontend-module.ts +3 -2
- package/src/browser/download/file-download-service.ts +7 -12
- package/src/browser/file-tree/file-tree-widget.tsx +1 -1
- package/src/browser/filesystem-frontend-contribution.ts +2 -2
- package/src/browser/filesystem-frontend-module.ts +3 -2
- package/src/browser/{file-upload-service.ts → upload/file-upload-service-impl.ts} +31 -72
- package/src/browser-only/browser-only-filesystem-frontend-module.ts +10 -0
- package/src/browser-only/download/file-download-command-contribution.ts +56 -0
- package/src/browser-only/download/file-download-frontend-module.ts +26 -0
- package/src/browser-only/download/file-download-service.ts +726 -0
- package/src/browser-only/file-search.ts +170 -0
- package/src/browser-only/opfs-filesystem-initialization.ts +7 -4
- package/src/browser-only/opfs-filesystem-provider.ts +402 -189
- package/src/browser-only/upload/file-upload-service-impl.ts +408 -0
- package/src/common/download/file-download.ts +40 -0
- package/src/common/files.ts +42 -1
- package/src/common/io.ts +6 -1
- package/src/common/upload/file-upload.ts +65 -0
- package/src/node/disk-file-system-provider.ts +3 -4
- package/src/node/download/file-download-handler.ts +1 -1
- package/src/node/filesystem-backend-module.ts +1 -1
- package/src/node/parcel-watcher/parcel-filesystem-service.ts +2 -2
- package/src/node/{node-file-upload-service.ts → upload/node-file-upload-service.ts} +1 -1
- package/lib/browser/file-upload-service.d.ts.map +0 -1
- package/lib/browser/file-upload-service.js.map +0 -1
- package/lib/common/download/file-download-data.d.ts +0 -7
- package/lib/common/download/file-download-data.d.ts.map +0 -1
- package/lib/common/download/file-download-data.js.map +0 -1
- package/lib/node/node-file-upload-service.d.ts.map +0 -1
- package/lib/node/node-file-upload-service.js.map +0 -1
- /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
|
+
}
|