diff --git a/backend/services/file_service.py b/backend/services/file_service.py index 6a338b3..2d9b36b 100644 --- a/backend/services/file_service.py +++ b/backend/services/file_service.py @@ -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) diff --git a/backend/version.py b/backend/version.py index e8cc1fc..d0255c4 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.37.0" +APP_VERSION = "0.37.1" diff --git a/frontend/package.json b/frontend/package.json index 0aa3d3e..494b242 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.37.0", + "version": "0.37.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 16b4db1..35ab95e 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -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); }, diff --git a/frontend/src/pages/Files.tsx b/frontend/src/pages/Files.tsx index d6bb86b..2d709c1 100644 --- a/frontend/src/pages/Files.tsx +++ b/frontend/src/pages/Files.tsx @@ -83,7 +83,12 @@ export function Files() { const [newKind, setNewKind] = useState<"dir" | "file" | null>(null); const [clip, setClip] = useState(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(null); const folderInput = useRef(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 (
@@ -294,21 +317,37 @@ export function Files() {
- {/* Upload progress */} + {/* Upload / download progress */} {progress && (
- - Uploading {progress.label} + {progress.kind === "download" ? ( + + ) : ( + + )} + + {progress.kind === "download" + ? progress.indeterminate + ? `Preparing ${progress.label}…` + : `Downloading ${progress.label}` + : `Uploading ${progress.label}`} + + + + {progress.indeterminate ? "" : `${Math.round(progress.pct)}%`} - {Math.round(progress.pct)}%
-
+ {progress.indeterminate ? ( +
+ ) : ( +
+ )}
)} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index dcfb44a..25c3aa5 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -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: [],