From 843ad1986ed1c27bd5a0f848694f3e721ddfc229 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 13 Oct 2026 13:37:47 +0200 Subject: [PATCH] Upload progress Co-Authored-By: Claude Opus 5.5 --- Client/Browser.py | 38 ++++++++- Client/Sources.py | 34 +++++++- Frontend/src/Api.js | 52 +++++++++---- Frontend/src/App.jsx | 2 + Frontend/src/Components/DirectoryPane.jsx | 29 +------ Frontend/src/Components/Sidebar.jsx | 29 +------ Frontend/src/Components/UploadToast.jsx | 26 +++++++ Frontend/src/Styles/Theme.css | 32 ++++++++ Frontend/src/Upload.js | 95 +++++++++++++++++++++++ 9 files changed, 259 insertions(+), 78 deletions(-) create mode 100644 Frontend/src/Components/UploadToast.jsx create mode 100644 Frontend/src/Upload.js diff --git a/Client/Browser.py b/Client/Browser.py index 71a48fe..6335bc2 100644 --- a/Client/Browser.py +++ b/Client/Browser.py @@ -84,6 +84,11 @@ class Browser(ft.Column): spacing=8, tight=True, ) + # Shown while uploading: which file, how far (Uploads' progress). + self.UploadText = ft.Text("", size=12, color=Palette.Overlay0) + self.UploadBar = ft.ProgressBar(value=0, color=Palette.Mauve, bgcolor=Palette.Surface0, bar_height=4) + self.UploadRow = ft.Container(ft.Column([self.UploadText, self.UploadBar], spacing=4), + padding=ft.Padding.symmetric(horizontal=8, vertical=6), visible=False) self.EntryList = ft.ListView(expand=True, spacing=2) # One shared popup host, opened programmatically (see _RequestMenu). # Its built-in triggers can't be switched off, so they're set to @@ -103,6 +108,7 @@ class Browser(ft.Column): # and the breadcrumbs get the rest (and wrap when it's tight). ft.Row([ft.Container(self.Breadcrumbs, padding=8, expand=True), ft.Container(self.ActionsToolbar, padding=8)]), ft.Divider(height=1, color=Palette.Surface1), + self.UploadRow, self.FileArea, ] self._ShowList(Update=False) @@ -600,19 +606,43 @@ class Browser(ft.Column): return Saved, Failed = 0, [] Server = self.TheApp.Server - for LocalFile in Paths: - if Server: - self.TheApp.Notify(f"Uploading {LocalFile.name} ({HumanSize(LocalFile.stat().st_size)})...", "info") + Sizes = [LocalFile.stat().st_size if LocalFile.is_file() else 0 for LocalFile in Paths] + Total = sum(Sizes) + Verb = "Uploading" if Server else "Copying in" + + Shown = [-1, -1] # (file, percent) on screen: redraw only when it changes + + def ShowProgress(Index: int, Fraction: float) -> None: + Done = sum(Sizes[:Index]) + Sizes[Index] * Fraction + Percent = int(Done / Total * 100) if Total else 100 + if Shown == [Index, Percent]: + return + Shown[:] = [Index, Percent] + Of = f" ({Index + 1} of {len(Paths)})" if len(Paths) > 1 else "" + self.UploadText.value = (f"{Verb} {Paths[Index].name}{Of}: {Percent}% " + f"({HumanSize(int(Done))} of {HumanSize(Total)})") + self.UploadBar.value = Done / Total if Total else 1 + self.UploadRow.update() + + self.UploadRow.visible = True + for Index, LocalFile in enumerate(Paths): + ShowProgress(Index, 0) try: - await asyncio.to_thread(self.TheApp.Source.Upload, self.Root, self.Path, LocalFile) + # Large files go up in chunks and report each one; small ones in one go. + await asyncio.to_thread(self.TheApp.Source.Upload, self.Root, self.Path, LocalFile, + lambda Fraction, I=Index: ShowProgress(I, Fraction)) Saved += 1 except SourceError as Error: if Error.Status == 401: + self.UploadRow.visible = False + self.UploadRow.update() self.TheApp.HandleError(Error) return Failed.append(f"{LocalFile.name}: {Error}") except OSError as Error: Failed.append(f"{LocalFile.name}: {Error}") + self.UploadRow.visible = False + self.UploadRow.update() self.Refresh() if Failed: self.TheApp.Notify("Couldn't upload " + "; ".join(Failed), "error") diff --git a/Client/Sources.py b/Client/Sources.py index 0afcd23..bc3200d 100644 --- a/Client/Sources.py +++ b/Client/Sources.py @@ -5,6 +5,7 @@ entries shaped like the server's /api/tree items, so the rest of the client never needs to know which one it's talking to.""" import fnmatch +import io import shutil import uuid from dataclasses import dataclass @@ -412,7 +413,8 @@ class ServerSource(Source): Params = self._At(Root, DirectoryPath) if Size <= ChunkedUploadThresholdBytes: with LocalFile.open("rb") as Handle: - Result = self._Json("POST", "/upload", params=Params, files=[("Files", (LocalFile.name, Handle))]) + Sending = _CountingReader(Handle, Size, OnProgress) if OnProgress else Handle + Result = self._Json("POST", "/upload", params=Params, files=[("Files", (LocalFile.name, Sending))]) if Result.get("Errors"): raise SourceError("; ".join(Result["Errors"])) return Result["Saved"][0] @@ -423,10 +425,13 @@ class ServerSource(Source): for ChunkIndex in range(TotalChunks): ChunkParams = {**Params, "UploadId": UploadId, "ChunkIndex": ChunkIndex, "TotalChunks": TotalChunks, "FileName": LocalFile.name} - self._Request("POST", "/upload/chunk", params=ChunkParams, - files=[("Chunk", ("chunk", Handle.read(UploadChunkSizeBytes)))]) + Chunk = Handle.read(UploadChunkSizeBytes) + Sending = io.BytesIO(Chunk) if OnProgress: - OnProgress((ChunkIndex + 1) / TotalChunks) + Before = ChunkIndex * UploadChunkSizeBytes + Sending = _CountingReader(Sending, len(Chunk), + lambda Fraction, B=Before, L=len(Chunk): OnProgress((B + L * Fraction) / Size)) + self._Request("POST", "/upload/chunk", params=ChunkParams, files=[("Chunk", ("chunk", Sending))]) Done = {**Params, "UploadId": UploadId, "TotalChunks": TotalChunks, "FileName": LocalFile.name} return self._Json("POST", "/upload/complete", params=Done)["Saved"] @@ -474,5 +479,26 @@ class ServerSource(Source): return self._Request("POST", "/diagram", json=Body).content +class _CountingReader: + """A file handle that reports how much of it has been read (sent): httpx + reads an upload's file in pieces, so this is its progress.""" + + def __init__(self, Handle, Size: int, OnProgress: Callable[[float], None]): + self._Handle = Handle + self._Size = Size + self._OnProgress = OnProgress + self._Sent = 0 + + def read(self, Count: int = -1) -> bytes: + Data = self._Handle.read(Count) + self._Sent += len(Data) + if self._Size: + self._OnProgress(min(1.0, self._Sent / self._Size)) + return Data + + def __getattr__(self, Name: str): + return getattr(self._Handle, Name) # seek, tell, fileno: for the length + + def MakeSource(Config: AppConfig, Token: str = "") -> Source: return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config) diff --git a/Frontend/src/Api.js b/Frontend/src/Api.js index a99dbf7..b092f9a 100644 --- a/Frontend/src/Api.js +++ b/Frontend/src/Api.js @@ -171,18 +171,41 @@ export function GetDownloadUrl(RootName, RelativePath) { return `${ApiBase}/download?${Params}`; } -export async function UploadFiles(RootName, RelativePath, Files) { +// Uploads go through XMLHttpRequest: unlike fetch, it reports how much has +// been sent. OnProgress(BytesSent) is optional. +function PostFormWithProgress(Url, Form, OnProgress) { + return new Promise((Resolve, Reject) => { + const Request = new XMLHttpRequest(); + Request.open("POST", Url); + Request.withCredentials = true; + if (OnProgress) { + Request.upload.onprogress = (Event) => OnProgress(Event.loaded); + } + Request.onload = () => { + let Body = null; + try { + Body = JSON.parse(Request.responseText); + } catch { + Body = null; + } + if (Request.status >= 200 && Request.status < 300) { + Resolve(Body); + } else { + Reject(new Error(Body && Body.detail ? Body.detail : Request.statusText || `Upload failed (${Request.status})`)); + } + }; + Request.onerror = () => Reject(new Error("Upload failed: couldn't reach the server.")); + Request.send(Form); + }); +} + +export function UploadFiles(RootName, RelativePath, Files, OnProgress) { const Form = new FormData(); for (const File of Files) { Form.append("Files", File); } const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); - const Response = await fetch(`${ApiBase}/upload?${Params}`, { - method: "POST", - credentials: "same-origin", - body: Form, - }); - return ParseJsonOrThrow(Response); + return PostFormWithProgress(`${ApiBase}/upload?${Params}`, Form, OnProgress); } const UploadChunkSizeBytes = 20 * 1024 * 1024; @@ -199,7 +222,7 @@ function GenerateUploadId() { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } -async function UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, TotalChunks, FileName, ChunkBlob) { +function UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, TotalChunks, FileName, ChunkBlob, OnProgress) { const Form = new FormData(); Form.append("Chunk", ChunkBlob, "chunk"); const Params = new URLSearchParams({ @@ -210,12 +233,7 @@ async function UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, Tot TotalChunks: String(TotalChunks), FileName, }); - const Response = await fetch(`${ApiBase}/upload/chunk?${Params}`, { - method: "POST", - credentials: "same-origin", - body: Form, - }); - return ParseJsonOrThrow(Response); + return PostFormWithProgress(`${ApiBase}/upload/chunk?${Params}`, Form, OnProgress); } function CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, FileName) { @@ -229,14 +247,16 @@ function CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, Fi return FetchJson(`/upload/complete?${Params}`, { method: "POST" }); } -export async function UploadFileChunked(RootName, RelativePath, File) { +// OnProgress(BytesSent) counts across all the chunks. +export async function UploadFileChunked(RootName, RelativePath, File, OnProgress) { const UploadId = GenerateUploadId(); const TotalChunks = Math.max(1, Math.ceil(File.size / UploadChunkSizeBytes)); for (let ChunkIndex = 0; ChunkIndex < TotalChunks; ChunkIndex++) { const Start = ChunkIndex * UploadChunkSizeBytes; const ChunkBlob = File.slice(Start, Start + UploadChunkSizeBytes); - await UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, TotalChunks, File.name, ChunkBlob); + await UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, TotalChunks, File.name, ChunkBlob, + OnProgress && ((Sent) => OnProgress(Start + Math.min(Sent, ChunkBlob.size)))); } return CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, File.name); diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index 1ae57a4..81c96ba 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -9,6 +9,7 @@ import SettingsPanel from "./Components/SettingsPanel.jsx"; import GraphPane from "./Components/GraphPane.jsx"; import LoginScreen from "./Components/LoginScreen.jsx"; import ErrorToast from "./Components/ErrorToast.jsx"; +import UploadToast from "./Components/UploadToast.jsx"; import { EnsureDirectoryNote, FetchAuthStatus, @@ -429,6 +430,7 @@ export default function App() { OnProfileUpdated={HandleProfileUpdated} /> )} + SetErrorMessage(null)} /> ); diff --git a/Frontend/src/Components/DirectoryPane.jsx b/Frontend/src/Components/DirectoryPane.jsx index fd397ad..eadeaa8 100644 --- a/Frontend/src/Components/DirectoryPane.jsx +++ b/Frontend/src/Components/DirectoryPane.jsx @@ -7,13 +7,11 @@ import { FetchLock, FetchTree, GetDownloadUrl, - IsLargeUpload, MoveEntry, RenameEntry, SetLock as SetLockApi, - UploadFileChunked, - UploadFiles, } from "../Api.js"; +import { UploadAll } from "../Upload.js"; import { GetFileIcon, GetFolderIcon } from "../IconStyle.js"; import AccessModal from "./AccessModal.jsx"; import PromptModal from "./PromptModal.jsx"; @@ -183,30 +181,7 @@ export default function DirectoryPane({ async function HandleUpload(Files) { if (!Files || Files.length === 0) return; - const FileArray = Array.from(Files); - const SmallFiles = FileArray.filter((File) => !IsLargeUpload(File)); - const LargeFiles = FileArray.filter((File) => IsLargeUpload(File)); - const Errors = []; - - if (SmallFiles.length > 0) { - try { - const Result = await UploadFiles(RootName, DirectoryPath, SmallFiles); - if (Result.Errors && Result.Errors.length > 0) { - Errors.push(...Result.Errors); - } - } catch (Error) { - Errors.push(Error.message); - } - } - - for (const File of LargeFiles) { - try { - await UploadFileChunked(RootName, DirectoryPath, File); - } catch (Error) { - Errors.push(`${File.name}: ${Error.message}`); - } - } - + const Errors = await UploadAll(RootName, DirectoryPath, Files); if (Errors.length > 0) { SetErrorMessage(Errors.join("; ")); } diff --git a/Frontend/src/Components/Sidebar.jsx b/Frontend/src/Components/Sidebar.jsx index 644b41f..cb00ba4 100644 --- a/Frontend/src/Components/Sidebar.jsx +++ b/Frontend/src/Components/Sidebar.jsx @@ -8,13 +8,11 @@ import { FetchTree, GetAvatarUrl, GetDownloadUrl, - IsLargeUpload, MoveEntry, RenameEntry, SetLock as SetLockApi, - UploadFileChunked, - UploadFiles, } from "../Api.js"; +import { UploadAll } from "../Upload.js"; import { GetFileIcon, GetFolderIcon } from "../IconStyle.js"; import AccessModal from "./AccessModal.jsx"; import PromptModal from "./PromptModal.jsx"; @@ -386,30 +384,7 @@ export default function Sidebar({ async function HandleUpload(Files) { if (!Files || Files.length === 0) return; - const FileArray = Array.from(Files); - const SmallFiles = FileArray.filter((File) => !IsLargeUpload(File)); - const LargeFiles = FileArray.filter((File) => IsLargeUpload(File)); - const Errors = []; - - if (SmallFiles.length > 0) { - try { - const Result = await UploadFiles(RootName, CurrentDirectoryPath, SmallFiles); - if (Result.Errors && Result.Errors.length > 0) { - Errors.push(...Result.Errors); - } - } catch (Error) { - Errors.push(Error.message); - } - } - - for (const File of LargeFiles) { - try { - await UploadFileChunked(RootName, CurrentDirectoryPath, File); - } catch (Error) { - Errors.push(`${File.name}: ${Error.message}`); - } - } - + const Errors = await UploadAll(RootName, CurrentDirectoryPath, Files); if (Errors.length > 0) { SetErrorMessage(Errors.join("; ")); } diff --git a/Frontend/src/Components/UploadToast.jsx b/Frontend/src/Components/UploadToast.jsx new file mode 100644 index 0000000..5159977 --- /dev/null +++ b/Frontend/src/Components/UploadToast.jsx @@ -0,0 +1,26 @@ +import React, { useSyncExternalStore } from "react"; + +import { FormatSize, GetUploadState, SubscribeUploads } from "../Upload.js"; + +export default function UploadToast() { + const State = useSyncExternalStore(SubscribeUploads, GetUploadState); + if (!State) return null; + + const Files = `${State.Count} file${State.Count === 1 ? "" : "s"}`; + const Percent = State.Total > 0 ? Math.floor((State.Done / State.Total) * 100) : 100; + + return ( +
+ + {State.Finished + ? `Uploaded ${Files}.` + : `Uploading ${Files}: ${Percent}% (${FormatSize(State.Done)} of ${FormatSize(State.Total)})`} + + {!State.Finished && ( +
+
+
+ )} +
+ ); +} diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css index bba6ebf..48f2bf7 100644 --- a/Frontend/src/Styles/Theme.css +++ b/Frontend/src/Styles/Theme.css @@ -1135,6 +1135,38 @@ img, z-index: 200; } +.UploadToast { + position: fixed; + bottom: calc(20px + env(safe-area-inset-bottom, 0px)); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 8px; + min-width: 260px; + max-width: 480px; + padding: 10px 14px; + background: var(--Surface); + border: 1px solid var(--Purple); + border-radius: 6px; + color: var(--Text); + font-size: 13px; + z-index: 200; +} + +.UploadToastBar { + height: 4px; + background: var(--SurfaceAlt); + border-radius: 2px; + overflow: hidden; +} + +.UploadToastFill { + height: 100%; + background: var(--Purple); + transition: width 0.2s ease; +} + .ErrorToastClose { background: none; border: none; diff --git a/Frontend/src/Upload.js b/Frontend/src/Upload.js new file mode 100644 index 0000000..aab7643 --- /dev/null +++ b/Frontend/src/Upload.js @@ -0,0 +1,95 @@ +// Uploading files, with progress: the sidebar and the directory pane both +// upload through UploadAll, and UploadToast.jsx shows how it's going +// ("Uploading 3 files: 45% (12 MB of 27 MB)", then "Uploaded 3 files."). +// Small files go up together, large ones in chunks (Api.js). + +import { IsLargeUpload, UploadFileChunked, UploadFiles } from "./Api.js"; + +let State = null; // { Count, Done, Total (bytes), Finished } +let ActiveUploads = 0; +const Listeners = new Set(); + +function Publish(Next) { + State = Next; + Listeners.forEach((Listener) => Listener()); +} + +export function SubscribeUploads(Listener) { + Listeners.add(Listener); + return () => Listeners.delete(Listener); +} + +export function GetUploadState() { + return State; +} + +export function FormatSize(Bytes) { + const Units = ["B", "KB", "MB", "GB", "TB"]; + let Size = Bytes; + let Unit = 0; + while (Size >= 1024 && Unit < Units.length - 1) { + Size /= 1024; + Unit++; + } + return `${Unit === 0 ? Size : Size.toFixed(1)} ${Units[Unit]}`; +} + +// Closing the tab mid-upload would lose it, so ask first. +window.addEventListener("beforeunload", (Event) => { + if (ActiveUploads > 0) { + Event.preventDefault(); + Event.returnValue = ""; + } +}); + +// Uploads Files into DirectoryPath; returns the errors (empty if all went up). +export async function UploadAll(RootName, DirectoryPath, Files) { + const FileArray = Array.from(Files || []); + if (FileArray.length === 0) return []; + + const SmallFiles = FileArray.filter((File) => !IsLargeUpload(File)); + const LargeFiles = FileArray.filter((File) => IsLargeUpload(File)); + const Total = FileArray.reduce((Sum, File) => Sum + File.size, 0); + const Errors = []; + let Finished = 0; // bytes of the parts already done + + const Report = (Current) => + Publish({ Count: FileArray.length, Done: Math.min(Total, Finished + Current), Total, Finished: false }); + + ActiveUploads++; + Report(0); + try { + if (SmallFiles.length > 0) { + try { + const Result = await UploadFiles(RootName, DirectoryPath, SmallFiles, Report); + if (Result.Errors && Result.Errors.length > 0) { + Errors.push(...Result.Errors); + } + } catch (Error) { + Errors.push(Error.message); + } + Finished += SmallFiles.reduce((Sum, File) => Sum + File.size, 0); + } + + for (const File of LargeFiles) { + try { + await UploadFileChunked(RootName, DirectoryPath, File, Report); + } catch (Error) { + Errors.push(`${File.name}: ${Error.message}`); + } + Finished += File.size; + } + } finally { + ActiveUploads--; + } + + // Errors show in the error toast instead; a success stays up for a moment. + const Done = { Count: FileArray.length, Done: Total, Total, Finished: true }; + Publish(Errors.length > 0 ? null : Done); + if (Errors.length === 0) { + setTimeout(() => { + if (State === Done) Publish(null); + }, 3000); + } + return Errors; +}