feat(files): streaming ZIP download in the project explorer
Nightly Build / build (push) Successful in 8m52s
Nightly Build / build (push) Successful in 8m52s
Each row of the project Files tab gains a download action, and the toolbar gains a Download ZIP button scoped to the folder being browsed (at the root, the whole project). Visible to read-only members too: download is a read. Single files need no new backend: they reuse GET /api/file?force_download. Directories go through the new GET /api/file/download, which builds the ZIP on the fly: an async task walks the tree and async_zip (Astral's maintained rs-async-zip fork) streams entries into a bounded duplex stream backing the response body — no temp file, no whole-archive buffer, backpressure for free, and the task dies with the client. Compression is per entry: Deflate at maximum level, except files whose magic bytes name an already-compressed format (media/PDF via the shared sniffer, the ZIP family, gzip/zstd/7z/rar, compressed audio), which are Stored. Entries are prefixed with the folder name, empty folders and unix permission bits survive, symlinks are never followed into the archive, and containment stays fail-closed under the resolved root. Covered by a round-trip test read back with the crate's own reader (and verified against unzip/python's zipfile).
This commit is contained in:
@@ -228,6 +228,10 @@ export class ProjectFilesPanel extends LightElement {
|
||||
?disabled=${this._loading} @click=${() => this._load()}>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<a class="btn btn-sm btn-outline-secondary" download
|
||||
href=${`/api/file/download?path=${encodeURIComponent(this._dirPath())}`}>
|
||||
<i class="bi bi-file-zip me-1"></i>${t('projects.files.btn.download')}
|
||||
</a>
|
||||
${canWrite ? html`
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy}
|
||||
@click=${() => this._openModal('mkdir')}>
|
||||
@@ -289,8 +293,15 @@ export class ProjectFilesPanel extends LightElement {
|
||||
<td class="text-muted text-nowrap" style="font-size:0.82rem">${this._fmtDate(entry.created_at)}</td>
|
||||
<td class="text-muted text-nowrap" style="font-size:0.82rem">${this._fmtDate(entry.modified_at)}</td>
|
||||
<td class="text-muted text-end text-nowrap" style="font-size:0.82rem">${entry.is_dir ? '—' : this._fmtSize(entry.size)}</td>
|
||||
${canWrite ? html`
|
||||
<td class="text-end text-nowrap" @click=${e => e.stopPropagation()}>
|
||||
<td class="text-end text-nowrap" @click=${e => e.stopPropagation()}>
|
||||
<a class="btn btn-sm btn-link text-secondary p-0 me-2" download
|
||||
title=${t('projects.files.action.download')}
|
||||
href=${entry.is_dir
|
||||
? `/api/file/download?path=${encodeURIComponent(entry.path)}`
|
||||
: `/api/file?path=${encodeURIComponent(entry.path)}&force_download=true`}>
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
${canWrite ? html`
|
||||
<button class="btn btn-sm btn-link text-secondary p-0 me-2" title=${t('projects.files.action.rename')}
|
||||
@click=${() => this._openModal('rename', entry)}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
@@ -299,14 +310,13 @@ export class ProjectFilesPanel extends LightElement {
|
||||
?disabled=${this._busy} @click=${() => this._remove(entry)}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
` : nothing}
|
||||
` : nothing}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderTable() {
|
||||
const canWrite = !!this.project?.can_write;
|
||||
if (!this._entries) {
|
||||
return html`<div class="text-center py-4"><span class="spinner-border spinner-border-sm text-primary"></span></div>`;
|
||||
}
|
||||
@@ -327,7 +337,7 @@ export class ProjectFilesPanel extends LightElement {
|
||||
<th style="width:9.5rem">${t('projects.files.col.created')}</th>
|
||||
<th style="width:9.5rem">${t('projects.files.col.modified')}</th>
|
||||
<th class="text-end" style="width:5.5rem">${t('projects.files.col.size')}</th>
|
||||
${canWrite ? html`<th style="width:4.5rem"></th>` : nothing}
|
||||
<th style="width:6rem"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -275,7 +275,9 @@ export default {
|
||||
'projects.files.drop': 'Drop files here to upload',
|
||||
'projects.files.btn.new_folder': 'New folder',
|
||||
'projects.files.btn.upload': 'Upload',
|
||||
'projects.files.btn.download': 'Download ZIP',
|
||||
'projects.files.uploading': 'Uploading…',
|
||||
'projects.files.action.download': 'Download',
|
||||
'projects.files.action.rename': 'Rename',
|
||||
'projects.files.action.delete': 'Delete',
|
||||
'projects.files.confirm.delete_file': 'Delete "{name}"?',
|
||||
|
||||
@@ -275,7 +275,9 @@ export default {
|
||||
'projects.files.drop': 'Déposez les fichiers ici pour les envoyer',
|
||||
'projects.files.btn.new_folder': 'Nouveau dossier',
|
||||
'projects.files.btn.upload': 'Envoyer',
|
||||
'projects.files.btn.download': 'Télécharger (ZIP)',
|
||||
'projects.files.uploading': 'Envoi…',
|
||||
'projects.files.action.download': 'Télécharger',
|
||||
'projects.files.action.rename': 'Renommer',
|
||||
'projects.files.action.delete': 'Supprimer',
|
||||
'projects.files.confirm.delete_file': 'Supprimer « {name} » ?',
|
||||
|
||||
@@ -299,7 +299,9 @@ export default {
|
||||
'projects.files.drop': 'Trascina qui i file per caricarli',
|
||||
'projects.files.btn.new_folder': 'Nuova cartella',
|
||||
'projects.files.btn.upload': 'Carica',
|
||||
'projects.files.btn.download': 'Scarica ZIP',
|
||||
'projects.files.uploading': 'Caricamento…',
|
||||
'projects.files.action.download': 'Scarica',
|
||||
'projects.files.action.rename': 'Rinomina',
|
||||
'projects.files.action.delete': 'Elimina',
|
||||
'projects.files.confirm.delete_file': 'Eliminare "{name}"?',
|
||||
|
||||
Reference in New Issue
Block a user