0.37.1: fix folder-download hang/501 on special files + add download progress

Two issues with the 0.37.0 folder zip-download:

1. Hang / server error (reported as 501) on "some folders". archive_dir
   tried to zip every entry, including non-regular files. Opening a FIFO
   blocks forever (no writer); a unix socket / unreadable file raised an
   OSError that aborted the whole archive. Now only real regular files are
   zipped — FIFOs, sockets, devices and symlinks are skipped without ever
   open()-ing them, and a per-file read error skips just that file instead
   of failing the download.

2. No feedback while a large folder is being prepared. The zip is built
   server-side before any bytes flow, so the click felt dead. The Files
   page now shows an indeterminate "Preparing <name>…" bar from click,
   switching to a real percentage during the transfer (Content-Length is
   known for the finished zip). filesApi.download forwards onDownloadProgress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-21 20:11:50 +00:00
co-authored by Claude Opus 4.8
parent f1782eca0e
commit 0dc430bb2a
6 changed files with 97 additions and 23 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.37.0",
"version": "0.37.1",
"type": "module",
"scripts": {
"dev": "vite",
+13 -2
View File
@@ -58,8 +58,19 @@ export const filesApi = {
.post<{ path: string }>(`${base(agentId)}/move`, { src, dest_dir: destDir, overwrite })
.then((r) => r.data),
download: async (path: string, filename: string, agentId?: number) => {
const res = await api.get(`${base(agentId)}/download`, { params: { path }, responseType: "blob" });
download: async (
path: string,
filename: string,
agentId?: number,
onProgress?: (loaded: number, total: number | undefined) => void,
) => {
const res = await api.get(`${base(agentId)}/download`, {
params: { path },
responseType: "blob",
onDownloadProgress: onProgress
? (e) => onProgress(e.loaded, e.total || undefined)
: undefined,
});
triggerDownload(res.data as Blob, filename);
},
+54 -15
View File
@@ -83,7 +83,12 @@ export function Files() {
const [newKind, setNewKind] = useState<"dir" | "file" | null>(null);
const [clip, setClip] = useState<Clipboard | null>(null);
const [pasteConflict, setPasteConflict] = useState(false);
const [progress, setProgress] = useState<{ label: string; pct: number } | null>(null);
const [progress, setProgress] = useState<{
label: string;
pct: number;
kind: "upload" | "download";
indeterminate?: boolean;
} | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
@@ -111,9 +116,9 @@ export function Files() {
const upload = useMutation({
mutationFn: (file: File) => {
setProgress({ label: file.name, pct: 0 });
setProgress({ label: file.name, pct: 0, kind: "upload" });
return filesApi.upload(path, file, false, "", host, (pct) =>
setProgress({ label: file.name, pct })
setProgress({ label: file.name, pct, kind: "upload" })
);
},
onSuccess: (r) => {
@@ -149,6 +154,7 @@ export function Files() {
setProgress({
label: `${f.name} (${i + 1}/${total})`,
pct: ((i + filePct / 100) / total) * 100,
kind: "upload",
})
);
ok += 1;
@@ -200,10 +206,27 @@ export function Files() {
onError: (e) => toast.error(apiErrorMessage(e)),
});
const download = (e: HostPathEntry) =>
filesApi
.download(join(path, e.name), e.type === "dir" ? `${e.name}.zip` : e.name, host)
.catch((err) => toast.error(apiErrorMessage(err)));
const download = (e: HostPathEntry) => {
const isDir = e.type === "dir";
// Folders are zipped server-side first (no bytes yet) — start with an
// indeterminate "Preparing…" bar, then switch to % once data flows.
setProgress({ label: e.name, pct: 0, kind: "download", indeterminate: true });
return filesApi
.download(
join(path, e.name),
isDir ? `${e.name}.zip` : e.name,
host,
(loaded, total) =>
setProgress({
label: e.name,
pct: total ? (loaded / total) * 100 : 0,
kind: "download",
indeterminate: !total,
}),
)
.catch((err) => toast.error(apiErrorMessage(err)))
.finally(() => setProgress(null));
};
return (
<div className="space-y-4">
@@ -294,21 +317,37 @@ export function Files() {
</div>
</div>
{/* Upload progress */}
{/* Upload / download progress */}
{progress && (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
<span className="flex min-w-0 items-center gap-1.5">
<Upload className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Uploading {progress.label}</span>
{progress.kind === "download" ? (
<Download className="h-3.5 w-3.5 shrink-0" />
) : (
<Upload className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">
{progress.kind === "download"
? progress.indeterminate
? `Preparing ${progress.label}`
: `Downloading ${progress.label}`
: `Uploading ${progress.label}`}
</span>
</span>
<span className="tabular-nums">
{progress.indeterminate ? "" : `${Math.round(progress.pct)}%`}
</span>
<span className="tabular-nums">{Math.round(progress.pct)}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div
className="h-full rounded-full bg-accent transition-all dark:bg-accent-dark"
style={{ width: `${progress.pct}%` }}
/>
{progress.indeterminate ? (
<div className="h-full w-1/3 animate-indeterminate rounded-full bg-accent dark:bg-accent-dark" />
) : (
<div
className="h-full rounded-full bg-accent transition-all dark:bg-accent-dark"
style={{ width: `${progress.pct}%` }}
/>
)}
</div>
</div>
)}
+10
View File
@@ -37,6 +37,16 @@ export default {
pill: "var(--sp-r-pill)",
chip: "var(--sp-r-chip)",
},
keyframes: {
// Sliding bar for indeterminate progress (e.g. server zipping a folder).
indeterminate: {
"0%": { transform: "translateX(-100%)" },
"100%": { transform: "translateX(400%)" },
},
},
animation: {
indeterminate: "indeterminate 1.2s ease-in-out infinite",
},
},
},
plugins: [],