File browser: upload progress bar (0.21.5)
filesApi.upload now accepts an onProgress callback wired to axios onUploadProgress; the Files page shows a progress bar with percentage while uploading. Single-file upload tracks that file's bytes; folder upload tracks overall progress across the N files (file i/N + current file's bytes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8fc61b1531
commit
cf046648bd
@@ -62,7 +62,7 @@ def _map_docker(exc: DockerError):
|
|||||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||||
raise exc # falls through to the global 502 DockerError handler
|
raise exc # falls through to the global 502 DockerError handler
|
||||||
|
|
||||||
AGENT_VERSION = "0.21.4"
|
AGENT_VERSION = "0.21.5"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.21.4", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.21.5", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.21.4",
|
"version": "0.21.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -63,13 +63,27 @@ export const filesApi = {
|
|||||||
triggerDownload(res.data as Blob, filename);
|
triggerDownload(res.data as Blob, filename);
|
||||||
},
|
},
|
||||||
|
|
||||||
upload: async (path: string, file: File, overwrite = false, relPath = "", agentId?: number) => {
|
upload: async (
|
||||||
|
path: string,
|
||||||
|
file: File,
|
||||||
|
overwrite = false,
|
||||||
|
relPath = "",
|
||||||
|
agentId?: number,
|
||||||
|
onProgress?: (pct: number) => void,
|
||||||
|
) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("path", path);
|
form.append("path", path);
|
||||||
form.append("overwrite", String(overwrite));
|
form.append("overwrite", String(overwrite));
|
||||||
if (relPath) form.append("rel_path", relPath);
|
if (relPath) form.append("rel_path", relPath);
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
const res = await api.post<{ ok: boolean; name: string }>(`${base(agentId)}/upload`, form);
|
const res = await api.post<{ ok: boolean; name: string }>(`${base(agentId)}/upload`, form, {
|
||||||
|
onUploadProgress: onProgress
|
||||||
|
? (e) => {
|
||||||
|
const pct = e.total ? (e.loaded / e.total) * 100 : (e.progress ?? 0) * 100;
|
||||||
|
onProgress(Math.min(pct, 100));
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ 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 fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
const folderInput = useRef<HTMLInputElement>(null);
|
const folderInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -109,7 +110,12 @@ export function Files() {
|
|||||||
const refresh = () => qc.invalidateQueries({ queryKey: ["files"] });
|
const refresh = () => qc.invalidateQueries({ queryKey: ["files"] });
|
||||||
|
|
||||||
const upload = useMutation({
|
const upload = useMutation({
|
||||||
mutationFn: (file: File) => filesApi.upload(path, file, false, "", host),
|
mutationFn: (file: File) => {
|
||||||
|
setProgress({ label: file.name, pct: 0 });
|
||||||
|
return filesApi.upload(path, file, false, "", host, (pct) =>
|
||||||
|
setProgress({ label: file.name, pct })
|
||||||
|
);
|
||||||
|
},
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
toast.success(`Uploaded ${r.name}`);
|
toast.success(`Uploaded ${r.name}`);
|
||||||
refresh();
|
refresh();
|
||||||
@@ -123,6 +129,7 @@ export function Files() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
|
setProgress(null);
|
||||||
if (fileInput.current) fileInput.current.value = "";
|
if (fileInput.current) fileInput.current.value = "";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -131,12 +138,19 @@ export function Files() {
|
|||||||
// recreates the directory structure. Continues past individual failures.
|
// recreates the directory structure. Continues past individual failures.
|
||||||
const uploadFolder = useMutation({
|
const uploadFolder = useMutation({
|
||||||
mutationFn: async (files: File[]) => {
|
mutationFn: async (files: File[]) => {
|
||||||
|
const total = files.length;
|
||||||
let ok = 0;
|
let ok = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const f of files) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const f = files[i];
|
||||||
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name;
|
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name;
|
||||||
try {
|
try {
|
||||||
await filesApi.upload(path, f, true, rel, host);
|
await filesApi.upload(path, f, true, rel, host, (filePct) =>
|
||||||
|
setProgress({
|
||||||
|
label: `${f.name} (${i + 1}/${total})`,
|
||||||
|
pct: ((i + filePct / 100) / total) * 100,
|
||||||
|
})
|
||||||
|
);
|
||||||
ok += 1;
|
ok += 1;
|
||||||
} catch {
|
} catch {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
@@ -151,6 +165,7 @@ export function Files() {
|
|||||||
},
|
},
|
||||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
|
setProgress(null);
|
||||||
if (folderInput.current) folderInput.current.value = "";
|
if (folderInput.current) folderInput.current.value = "";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -279,6 +294,25 @@ export function Files() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upload 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>
|
||||||
|
</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}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<Card className="p-0">
|
<Card className="p-0">
|
||||||
<div className="flex flex-wrap items-center gap-1 border-b border-slate-200 px-3 py-2 text-sm dark:border-slate-700">
|
<div className="flex flex-wrap items-center gap-1 border-b border-slate-200 px-3 py-2 text-sm dark:border-slate-700">
|
||||||
|
|||||||
Reference in New Issue
Block a user