UNPKG

@theia/filesystem

Version:
551 lines • 22.3 kB
"use strict"; // ***************************************************************************** // Copyright (C) 2025 Maksim Kachurin and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** Object.defineProperty(exports, "__esModule", { value: true }); exports.FileDownloadServiceImpl = void 0; const tslib_1 = require("tslib"); const inversify_1 = require("@theia/core/shared/inversify"); const logger_1 = require("@theia/core/lib/common/logger"); const message_service_1 = require("@theia/core/lib/common/message-service"); const filesystem_preferences_1 = require("../../common/filesystem-preferences"); const core_1 = require("@theia/core"); const stream_1 = require("@theia/core/lib/common/stream"); const file_service_1 = require("../../browser/file-service"); const tarStream = require("tar-stream"); const minimatch_1 = require("minimatch"); let FileDownloadServiceImpl = class FileDownloadServiceImpl { constructor() { this.ignorePatterns = []; } getFileSizeThreshold() { return this.preferences['files.maxFileSizeMB'] * 1024 * 1024; } /** * Check if streaming download is supported (File System Access API) */ isStreamingSupported() { var _a, _b; if (!globalThis.isSecureContext) { return false; } if (!('showSaveFilePicker' in globalThis)) { return false; } try { return typeof ((_b = (_a = globalThis.ReadableStream) === null || _a === void 0 ? void 0 : _a.prototype) === null || _b === void 0 ? void 0 : _b.pipeTo) === 'function'; } catch { return false; } } async download(uris, options) { if (uris.length === 0) { return; } const abortController = new AbortController(); try { const progress = await this.messageService.showProgress({ text: core_1.nls.localize('theia/filesystem/prepareDownload', 'Preparing download...'), options: { cancelable: true }, }, () => { abortController.abort(); }); try { await this.doDownload(uris, abortController.signal); } finally { progress.cancel(); } } catch (e) { if (!abortController.signal.aborted) { this.logger.error(`Error occurred when downloading: ${uris.map(u => u.toString(true))}.`, e); this.messageService.error(core_1.nls.localize('theia/filesystem/downloadError', 'Failed to download files. See console for details.')); } } } async doDownload(uris, abortSignal) { try { const { files, directories, totalSize, stats } = await this.collectFiles(uris, abortSignal); if (abortSignal.aborted) { return; } if (totalSize > this.getFileSizeThreshold() && this.isStreamingSupported()) { await this.streamDownloadToFile(uris, files, directories, stats, abortSignal); } else { let data; let filename = 'theia-download.tar'; if (uris.length === 1) { const stat = stats[0]; if (stat.isDirectory) { filename = `${stat.name}.tar`; data = await this.createArchiveBlob(async (tarPack) => { await this.addFilesToArchive(tarPack, files, directories, abortSignal); }, abortSignal); } else { filename = stat.name; const content = await this.fileService.readFile(uris[0]); data = new Blob([content.value.buffer], { type: 'application/octet-stream', }); } } else { data = await this.createArchiveBlob(async (tarPack) => { await this.addFilesToArchive(tarPack, files, directories, abortSignal); }, abortSignal); } if (!abortSignal.aborted) { this.blobDownload(data, filename); } } } catch (error) { if (!abortSignal.aborted) { this.logger.error('Failed to download files', error); throw error; } } } async createArchiveBlob(populateArchive, abortSignal) { const stream = this.createArchiveStream(abortSignal, populateArchive); const reader = stream.getReader(); const chunks = []; let total = 0; try { while (true) { if (abortSignal.aborted) { throw new Error('Operation aborted'); } const { done, value } = await reader.read(); if (done) { break; } chunks.push(value); total += value.byteLength; } const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.byteLength; } return new Blob([out], { type: 'application/x-tar' }); } finally { try { reader.releaseLock(); } catch { } } } /** * Create ReadableStream from a single file using FileService streaming */ async createFileStream(uri, abortSignal) { if (abortSignal.aborted) { throw new Error('Operation aborted'); } const fileStreamContent = await this.fileService.readFileStream(uri); return (0, stream_1.binaryStreamToWebStream)(fileStreamContent.value, abortSignal); } async addFileToArchive(tarPack, file, abortSignal) { if (abortSignal.aborted) { return; } try { const name = this.sanitizeFilename(file.path); const size = file.size; const entry = tarPack.entry({ name, size }); const fileStreamContent = await this.fileService.readFileStream(file.uri); const src = fileStreamContent.value; return new Promise((resolve, reject) => { var _a, _b, _c, _d; const cleanup = () => { var _a, _b, _c, _d; (_a = src.removeListener) === null || _a === void 0 ? void 0 : _a.call(src, 'data', onData); (_b = src.removeListener) === null || _b === void 0 ? void 0 : _b.call(src, 'end', onEnd); (_c = src.removeListener) === null || _c === void 0 ? void 0 : _c.call(src, 'error', onError); (_d = entry.removeListener) === null || _d === void 0 ? void 0 : _d.call(entry, 'error', onEntryError); abortSignal.removeEventListener('abort', onAbort); }; const onAbort = () => { var _a; cleanup(); (_a = entry.destroy) === null || _a === void 0 ? void 0 : _a.call(entry); reject(new Error('Operation aborted')); }; let ended = false; let pendingWrite = undefined; const onData = async (chunk) => { var _a, _b; if (abortSignal.aborted || ended) { return; } (_a = src.pause) === null || _a === void 0 ? void 0 : _a.call(src); const u8 = new Uint8Array(chunk.buffer); const canWrite = entry.write(u8); if (!canWrite) { pendingWrite = new Promise(resolveDrain => { entry.once('drain', resolveDrain); }); await pendingWrite; pendingWrite = undefined; } if (!ended) { (_b = src.resume) === null || _b === void 0 ? void 0 : _b.call(src); } }; const onEnd = async () => { ended = true; if (pendingWrite) { await pendingWrite; } cleanup(); entry.end(); resolve(); }; const onError = (err) => { var _a; cleanup(); try { (_a = entry.destroy) === null || _a === void 0 ? void 0 : _a.call(entry, err); } catch { } reject(err); }; const onEntryError = (err) => { cleanup(); reject(new Error(`Entry error for ${name}: ${err.message}`)); }; if (abortSignal.aborted) { return onAbort(); } abortSignal.addEventListener('abort', onAbort, { once: true }); (_a = entry.on) === null || _a === void 0 ? void 0 : _a.call(entry, 'error', onEntryError); (_b = src.on) === null || _b === void 0 ? void 0 : _b.call(src, 'data', onData); (_c = src.on) === null || _c === void 0 ? void 0 : _c.call(src, 'end', onEnd); (_d = src.on) === null || _d === void 0 ? void 0 : _d.call(src, 'error', onError); }); } catch (error) { this.logger.error(`Failed to read file ${file.uri.toString()}:`, error); throw error; } } async addFilesToArchive(tarPack, files, directories, abortSignal) { const uniqueDirs = new Set(); for (const dir of directories) { const normalizedPath = this.sanitizeFilename(dir.path) + '/'; uniqueDirs.add(normalizedPath); } for (const dirPath of uniqueDirs) { try { const entry = tarPack.entry({ name: dirPath, type: 'directory', }); entry.end(); } catch (error) { this.logger.error(`Failed to add directory ${dirPath}:`, error); } } for (const file of files) { if (abortSignal.aborted) { break; } try { await this.addFileToArchive(tarPack, file, abortSignal); } catch (error) { this.logger.error(`Failed to read file ${file.uri.toString()}:`, error); } } } createArchiveStream(abortSignal, populateArchive) { const tarPack = tarStream.pack(); return new ReadableStream({ start(controller) { const cleanup = () => { var _a; try { tarPack.removeAllListeners(); } catch { } try { (_a = tarPack.destroy) === null || _a === void 0 ? void 0 : _a.call(tarPack); } catch { } abortSignal.removeEventListener('abort', onAbort); }; const onAbort = () => { cleanup(); controller.error(new Error('Operation aborted')); }; if (abortSignal.aborted) { onAbort(); return; } abortSignal.addEventListener('abort', onAbort, { once: true }); tarPack.on('data', (chunk) => { if (abortSignal.aborted) { return; } try { controller.enqueue(chunk); } catch (error) { cleanup(); controller.error(error); } }); tarPack.once('end', () => { cleanup(); controller.close(); }); tarPack.once('error', error => { cleanup(); controller.error(error); }); populateArchive(tarPack) .then(() => { if (!abortSignal.aborted) { tarPack.finalize(); } }) .catch(error => { cleanup(); controller.error(error); }); }, cancel: () => { var _a, _b; try { (_a = tarPack.finalize) === null || _a === void 0 ? void 0 : _a.call(tarPack); (_b = tarPack.destroy) === null || _b === void 0 ? void 0 : _b.call(tarPack); } catch { } }, }); } async streamDownloadToFile(uris, files, directories, stats, abortSignal) { var _a; let filename = 'theia-download.tar'; if (uris.length === 1) { const stat = stats[0]; filename = stat.isDirectory ? `${stat.name}.tar` : stat.name; } const isArchive = filename.endsWith('.tar'); let fileHandle; try { // @ts-expect-error non-standard fileHandle = await window.showSaveFilePicker({ suggestedName: filename, types: isArchive ? [ { description: 'Archive files', accept: { 'application/x-tar': ['.tar'] }, }, ] : undefined, }); } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { return; } throw error; } let stream; if (uris.length === 1) { const stat = await this.fileService.resolve(uris[0]); stream = stat.isDirectory ? this.createArchiveStream(abortSignal, async (tarPack) => { await this.addFilesToArchive(tarPack, files, directories, abortSignal); }) : await this.createFileStream(uris[0], abortSignal); } else { stream = this.createArchiveStream(abortSignal, async (tarPack) => { await this.addFilesToArchive(tarPack, files, directories, abortSignal); }); } const writable = await fileHandle.createWritable(); try { await stream.pipeTo(writable, { signal: abortSignal }); } catch (error) { try { await ((_a = writable.abort) === null || _a === void 0 ? void 0 : _a.call(writable)); } catch { } throw error; } } blobDownload(data, filename) { const url = URL.createObjectURL(data); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 0); } sanitizeFilename(filename) { return filename .replace(/[\\:*?"<>|]/g, '_') // Replace Windows-problematic chars .replace(/\.\./g, '__') // Replace .. to prevent directory traversal .replace(/^\/+/g, '') // Remove leading slashes .replace(/\/+$/, '') // Remove trailing slashes for files .replace(/[\u0000-\u001f\u007f]/g, '_') // Replace control characters .replace(/\/+/g, '/') .replace(/^\.$/, '_') .replace(/^$/, '_'); } shouldIncludeFile(path) { return !this.ignorePatterns.some((pattern) => (0, minimatch_1.minimatch)(path, pattern)); } /** * Collect all files and calculate total size */ async collectFiles(uris, abortSignal) { var _a; const files = []; const directories = []; let totalSize = 0; const stats = []; for (const uri of uris) { if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) { break; } try { const stat = await this.fileService.resolve(uri, { resolveMetadata: true, }); stats.push({ name: stat.name, isDirectory: stat.isDirectory, size: stat.size, }); if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) { break; } if (!stat.isDirectory) { const size = stat.size || 0; files.push({ uri, path: stat.name, size }); totalSize += size; continue; } if (!((_a = stat.children) === null || _a === void 0 ? void 0 : _a.length)) { directories.push({ path: stat.name }); continue; } directories.push({ path: stat.name }); const dirResult = await this.collectFilesFromDirectory(uri, stat.name, abortSignal); files.push(...dirResult.files); directories.push(...dirResult.directories); totalSize += dirResult.files.reduce((sum, file) => sum + file.size, 0); } catch (error) { this.logger.warn(`Failed to collect files from ${uri.toString()}:`, error); stats.push({ name: uri.path.name || 'unknown', isDirectory: false, size: 0, }); } } return { files, directories, totalSize, stats }; } /** * Recursively collect files from a directory */ async collectFilesFromDirectory(dirUri, basePath, abortSignal) { var _a; const files = []; const directories = []; try { const dirStat = await this.fileService.resolve(dirUri); if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) { return { files, directories }; } // Empty directory - add it to preserve structure if (!((_a = dirStat.children) === null || _a === void 0 ? void 0 : _a.length)) { directories.push({ path: basePath }); return { files, directories }; } for (const child of dirStat.children) { if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) { break; } const childPath = basePath ? `${basePath}/${child.name}` : child.name; if (!this.shouldIncludeFile(childPath)) { continue; } if (child.isDirectory) { directories.push({ path: childPath }); const subResult = await this.collectFilesFromDirectory(child.resource, childPath, abortSignal); files.push(...subResult.files); directories.push(...subResult.directories); } else { const childStat = await this.fileService.resolve(child.resource); files.push({ uri: child.resource, path: childPath, size: childStat.size || 0, }); } } } catch (error) { this.logger.warn(`Failed to collect files from directory ${dirUri.toString()}:`, error); } return { files, directories }; } }; exports.FileDownloadServiceImpl = FileDownloadServiceImpl; tslib_1.__decorate([ (0, inversify_1.inject)(file_service_1.FileService), tslib_1.__metadata("design:type", file_service_1.FileService) ], FileDownloadServiceImpl.prototype, "fileService", void 0); tslib_1.__decorate([ (0, inversify_1.inject)(logger_1.ILogger), tslib_1.__metadata("design:type", Object) ], FileDownloadServiceImpl.prototype, "logger", void 0); tslib_1.__decorate([ (0, inversify_1.inject)(message_service_1.MessageService), tslib_1.__metadata("design:type", message_service_1.MessageService) ], FileDownloadServiceImpl.prototype, "messageService", void 0); tslib_1.__decorate([ (0, inversify_1.inject)(filesystem_preferences_1.FileSystemPreferences), tslib_1.__metadata("design:type", Object) ], FileDownloadServiceImpl.prototype, "preferences", void 0); exports.FileDownloadServiceImpl = FileDownloadServiceImpl = tslib_1.__decorate([ (0, inversify_1.injectable)() ], FileDownloadServiceImpl); //# sourceMappingURL=file-download-service.js.map