feat: add zip file extraction support with error handling for invalid archives

This commit is contained in:
2026-06-08 23:32:57 +00:00
parent 547e4eda8c
commit 317126efc2
30 changed files with 1594 additions and 10 deletions
+2
View File
@@ -19,6 +19,7 @@ import { ApiKeyManager } from "./ApiKeyManager.js";
import { UserAiUsage } from "./UserAiUsage.js";
import { Tabs } from "./Tabs.js";
import { DeviiTerminal } from "./DeviiTerminal.js";
import { ZipDownloader } from "./ZipDownloader.js";
class Application {
constructor() {
@@ -46,6 +47,7 @@ class Application {
this.userAiUsage = new UserAiUsage();
this.tabs = new Tabs();
this.devii = new DeviiTerminal();
this.zipDownloader = new ZipDownloader();
}
}
+1
View File
@@ -358,6 +358,7 @@ export class ProjectFiles {
items.push({ label: "Upload here", icon: "⬆", onSelect: () => this.uploadTo(dir) });
}
items.push({ separator: true });
items.push({ label: "Download as zip", icon: "📦", onSelect: () => window.app.zipDownloader.start(`${this.base}/zip?path=${encodeURIComponent(path)}`) });
items.push({ label: "Rename / move", icon: "✏", onSelect: () => this.rename(path) });
items.push({ label: "Delete", icon: "🗑", danger: true, onSelect: () => this.remove(path) });
return items;
+71
View File
@@ -0,0 +1,71 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export class ZipDownloader {
constructor() {
this.pollMs = 1500;
this.maxPolls = 200;
this.active = new Set();
document.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-zip-download]");
if (!trigger) return;
event.preventDefault();
this.start(trigger.dataset.zipDownload);
});
}
async start(enqueueUrl) {
if (this.active.has(enqueueUrl)) return;
this.active.add(enqueueUrl);
window.app.toast.show("Preparing download...", { type: "info", ms: 4000 });
try {
const job = await Http.postJson(enqueueUrl, {});
await this.poll(job.status_url);
} catch (err) {
window.app.toast.show("Could not start the download", { type: "error" });
} finally {
this.active.delete(enqueueUrl);
}
}
poll(statusUrl) {
return new Promise((resolve) => {
let attempts = 0;
const timer = setInterval(async () => {
attempts += 1;
if (attempts > this.maxPolls) {
clearInterval(timer);
window.app.toast.show("Download timed out", { type: "error" });
resolve();
return;
}
let status;
try {
status = await Http.getJson(statusUrl);
} catch {
return;
}
if (status.status === "done") {
clearInterval(timer);
this.trigger(status.download_url);
window.app.toast.show("Download ready", { type: "success" });
resolve();
} else if (status.status === "failed") {
clearInterval(timer);
window.app.toast.show("Zip failed: " + (status.error || "unknown error"), { type: "error" });
resolve();
}
}, this.pollMs);
});
}
trigger(url) {
const link = document.createElement("a");
link.href = url;
link.download = "";
document.body.appendChild(link);
link.click();
link.remove();
}
}