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
|
Returns ``(tmp_zip_path, download_filename)``. The caller is responsible
|
||||||
for deleting the temp file once it has been streamed to the client.
|
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)
|
real = _safe_real(path)
|
||||||
if not os.path.isdir(real):
|
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) + "/", "")
|
zf.writestr(os.path.join(name, rel_root) + "/", "")
|
||||||
for f in files:
|
for f in files:
|
||||||
full = os.path.join(root, f)
|
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
|
continue
|
||||||
zf.write(full, os.path.join(name, rel_root, f) if rel_root != "."
|
|
||||||
else os.path.join(name, f))
|
|
||||||
except OSError:
|
except OSError:
|
||||||
if os.path.exists(tmp):
|
if os.path.exists(tmp):
|
||||||
os.unlink(tmp)
|
os.unlink(tmp)
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the StackPilot release version."""
|
"""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",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.37.0",
|
"version": "0.37.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -58,8 +58,19 @@ export const filesApi = {
|
|||||||
.post<{ path: string }>(`${base(agentId)}/move`, { src, dest_dir: destDir, overwrite })
|
.post<{ path: string }>(`${base(agentId)}/move`, { src, dest_dir: destDir, overwrite })
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
download: async (path: string, filename: string, agentId?: number) => {
|
download: async (
|
||||||
const res = await api.get(`${base(agentId)}/download`, { params: { path }, responseType: "blob" });
|
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);
|
triggerDownload(res.data as Blob, filename);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ export function Files() {
|
|||||||
const [newKind, setNewKind] = useState<"dir" | "file" | null>(null);
|
const [newKind, setNewKind] = useState<"dir" | "file" | null>(null);
|
||||||
const [clip, setClip] = useState<Clipboard | null>(null);
|
const [clip, setClip] = useState<Clipboard | null>(null);
|
||||||
const [pasteConflict, setPasteConflict] = useState(false);
|
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 fileInput = useRef<HTMLInputElement>(null);
|
||||||
const folderInput = useRef<HTMLInputElement>(null);
|
const folderInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -111,9 +116,9 @@ export function Files() {
|
|||||||
|
|
||||||
const upload = useMutation({
|
const upload = useMutation({
|
||||||
mutationFn: (file: File) => {
|
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) =>
|
return filesApi.upload(path, file, false, "", host, (pct) =>
|
||||||
setProgress({ label: file.name, pct })
|
setProgress({ label: file.name, pct, kind: "upload" })
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
@@ -149,6 +154,7 @@ export function Files() {
|
|||||||
setProgress({
|
setProgress({
|
||||||
label: `${f.name} (${i + 1}/${total})`,
|
label: `${f.name} (${i + 1}/${total})`,
|
||||||
pct: ((i + filePct / 100) / total) * 100,
|
pct: ((i + filePct / 100) / total) * 100,
|
||||||
|
kind: "upload",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
ok += 1;
|
ok += 1;
|
||||||
@@ -200,10 +206,27 @@ export function Files() {
|
|||||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const download = (e: HostPathEntry) =>
|
const download = (e: HostPathEntry) => {
|
||||||
filesApi
|
const isDir = e.type === "dir";
|
||||||
.download(join(path, e.name), e.type === "dir" ? `${e.name}.zip` : e.name, host)
|
// Folders are zipped server-side first (no bytes yet) — start with an
|
||||||
.catch((err) => toast.error(apiErrorMessage(err)));
|
// 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -294,21 +317,37 @@ export function Files() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload progress */}
|
{/* Upload / download progress */}
|
||||||
{progress && (
|
{progress && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
|
<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">
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
<Upload className="h-3.5 w-3.5 shrink-0" />
|
{progress.kind === "download" ? (
|
||||||
<span className="truncate">Uploading {progress.label}</span>
|
<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>
|
||||||
<span className="tabular-nums">{Math.round(progress.pct)}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||||
<div
|
{progress.indeterminate ? (
|
||||||
className="h-full rounded-full bg-accent transition-all dark:bg-accent-dark"
|
<div className="h-full w-1/3 animate-indeterminate rounded-full bg-accent dark:bg-accent-dark" />
|
||||||
style={{ width: `${progress.pct}%` }}
|
) : (
|
||||||
/>
|
<div
|
||||||
|
className="h-full rounded-full bg-accent transition-all dark:bg-accent-dark"
|
||||||
|
style={{ width: `${progress.pct}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ export default {
|
|||||||
pill: "var(--sp-r-pill)",
|
pill: "var(--sp-r-pill)",
|
||||||
chip: "var(--sp-r-chip)",
|
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: [],
|
plugins: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user