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:
co-authored by
Claude Opus 4.8
parent
f1782eca0e
commit
0dc430bb2a
@@ -187,7 +187,13 @@ def archive_dir(path: str) -> tuple[str, str]:
|
||||
|
||||
Returns ``(tmp_zip_path, download_filename)``. The caller is responsible
|
||||
for deleting the temp file once it has been streamed to the client.
|
||||
Symlinks are skipped so the archive cannot escape the sandbox or loop.
|
||||
|
||||
Only regular files and real subdirectories are archived. Symlinks are
|
||||
skipped (no sandbox escape / loops); special files (FIFOs, sockets,
|
||||
devices) are skipped too — opening a FIFO would block forever and a
|
||||
socket can't be read at all. Files that can't be read (permissions, or
|
||||
that vanish mid-walk) are skipped individually rather than aborting the
|
||||
whole archive.
|
||||
"""
|
||||
real = _safe_real(path)
|
||||
if not os.path.isdir(real):
|
||||
@@ -207,10 +213,18 @@ def archive_dir(path: str) -> tuple[str, str]:
|
||||
zf.writestr(os.path.join(name, rel_root) + "/", "")
|
||||
for f in files:
|
||||
full = os.path.join(root, f)
|
||||
if os.path.islink(full):
|
||||
# os.path.isfile follows symlinks; combined with the islink
|
||||
# check it admits only real regular files (skips FIFOs,
|
||||
# sockets, devices and symlinks without ever open()-ing them).
|
||||
if os.path.islink(full) or not os.path.isfile(full):
|
||||
continue
|
||||
arc = (os.path.join(name, rel_root, f) if rel_root != "."
|
||||
else os.path.join(name, f))
|
||||
try:
|
||||
zf.write(full, arc)
|
||||
except OSError:
|
||||
# Unreadable or vanished mid-walk — skip just this file.
|
||||
continue
|
||||
zf.write(full, os.path.join(name, rel_root, f) if rel_root != "."
|
||||
else os.path.join(name, f))
|
||||
except OSError:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.37.0"
|
||||
APP_VERSION = "0.37.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.37.0",
|
||||
"version": "0.37.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user