Upload progress

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 13:37:47 +02:00
1 parent db81507b7c
commit 843ad1986e
9 files changed
+259 -78

No files matched your search

+34 -4
View File
@@ -84,6 +84,11 @@ class Browser(ft.Column):
spacing=8, spacing=8,
tight=True, 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) self.EntryList = ft.ListView(expand=True, spacing=2)
# One shared popup host, opened programmatically (see _RequestMenu). # One shared popup host, opened programmatically (see _RequestMenu).
# Its built-in triggers can't be switched off, so they're set to # 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). # 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.Row([ft.Container(self.Breadcrumbs, padding=8, expand=True), ft.Container(self.ActionsToolbar, padding=8)]),
ft.Divider(height=1, color=Palette.Surface1), ft.Divider(height=1, color=Palette.Surface1),
self.UploadRow,
self.FileArea, self.FileArea,
] ]
self._ShowList(Update=False) self._ShowList(Update=False)
@@ -600,19 +606,43 @@ class Browser(ft.Column):
return return
Saved, Failed = 0, [] Saved, Failed = 0, []
Server = self.TheApp.Server Server = self.TheApp.Server
for LocalFile in Paths: Sizes = [LocalFile.stat().st_size if LocalFile.is_file() else 0 for LocalFile in Paths]
if Server: Total = sum(Sizes)
self.TheApp.Notify(f"Uploading {LocalFile.name} ({HumanSize(LocalFile.stat().st_size)})...", "info") 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: 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 Saved += 1
except SourceError as Error: except SourceError as Error:
if Error.Status == 401: if Error.Status == 401:
self.UploadRow.visible = False
self.UploadRow.update()
self.TheApp.HandleError(Error) self.TheApp.HandleError(Error)
return return
Failed.append(f"{LocalFile.name}: {Error}") Failed.append(f"{LocalFile.name}: {Error}")
except OSError as Error: except OSError as Error:
Failed.append(f"{LocalFile.name}: {Error}") Failed.append(f"{LocalFile.name}: {Error}")
self.UploadRow.visible = False
self.UploadRow.update()
self.Refresh() self.Refresh()
if Failed: if Failed:
self.TheApp.Notify("Couldn't upload " + "; ".join(Failed), "error") self.TheApp.Notify("Couldn't upload " + "; ".join(Failed), "error")
+30 -4
View File
@@ -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.""" never needs to know which one it's talking to."""
import fnmatch import fnmatch
import io
import shutil import shutil
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
@@ -412,7 +413,8 @@ class ServerSource(Source):
Params = self._At(Root, DirectoryPath) Params = self._At(Root, DirectoryPath)
if Size <= ChunkedUploadThresholdBytes: if Size <= ChunkedUploadThresholdBytes:
with LocalFile.open("rb") as Handle: 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"): if Result.get("Errors"):
raise SourceError("; ".join(Result["Errors"])) raise SourceError("; ".join(Result["Errors"]))
return Result["Saved"][0] return Result["Saved"][0]
@@ -423,10 +425,13 @@ class ServerSource(Source):
for ChunkIndex in range(TotalChunks): for ChunkIndex in range(TotalChunks):
ChunkParams = {**Params, "UploadId": UploadId, "ChunkIndex": ChunkIndex, ChunkParams = {**Params, "UploadId": UploadId, "ChunkIndex": ChunkIndex,
"TotalChunks": TotalChunks, "FileName": LocalFile.name} "TotalChunks": TotalChunks, "FileName": LocalFile.name}
self._Request("POST", "/upload/chunk", params=ChunkParams, Chunk = Handle.read(UploadChunkSizeBytes)
files=[("Chunk", ("chunk", Handle.read(UploadChunkSizeBytes)))]) Sending = io.BytesIO(Chunk)
if OnProgress: 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} Done = {**Params, "UploadId": UploadId, "TotalChunks": TotalChunks, "FileName": LocalFile.name}
return self._Json("POST", "/upload/complete", params=Done)["Saved"] return self._Json("POST", "/upload/complete", params=Done)["Saved"]
@@ -474,5 +479,26 @@ class ServerSource(Source):
return self._Request("POST", "/diagram", json=Body).content 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: def MakeSource(Config: AppConfig, Token: str = "") -> Source:
return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config) return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config)
+36 -16
View File
@@ -171,18 +171,41 @@ export function GetDownloadUrl(RootName, RelativePath) {
return `${ApiBase}/download?${Params}`; 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(); const Form = new FormData();
for (const File of Files) { for (const File of Files) {
Form.append("Files", File); Form.append("Files", File);
} }
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
const Response = await fetch(`${ApiBase}/upload?${Params}`, { return PostFormWithProgress(`${ApiBase}/upload?${Params}`, Form, OnProgress);
method: "POST",
credentials: "same-origin",
body: Form,
});
return ParseJsonOrThrow(Response);
} }
const UploadChunkSizeBytes = 20 * 1024 * 1024; const UploadChunkSizeBytes = 20 * 1024 * 1024;
@@ -199,7 +222,7 @@ function GenerateUploadId() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`; 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(); const Form = new FormData();
Form.append("Chunk", ChunkBlob, "chunk"); Form.append("Chunk", ChunkBlob, "chunk");
const Params = new URLSearchParams({ const Params = new URLSearchParams({
@@ -210,12 +233,7 @@ async function UploadFileChunk(RootName, RelativePath, UploadId, ChunkIndex, Tot
TotalChunks: String(TotalChunks), TotalChunks: String(TotalChunks),
FileName, FileName,
}); });
const Response = await fetch(`${ApiBase}/upload/chunk?${Params}`, { return PostFormWithProgress(`${ApiBase}/upload/chunk?${Params}`, Form, OnProgress);
method: "POST",
credentials: "same-origin",
body: Form,
});
return ParseJsonOrThrow(Response);
} }
function CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, FileName) { 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" }); 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 UploadId = GenerateUploadId();
const TotalChunks = Math.max(1, Math.ceil(File.size / UploadChunkSizeBytes)); const TotalChunks = Math.max(1, Math.ceil(File.size / UploadChunkSizeBytes));
for (let ChunkIndex = 0; ChunkIndex < TotalChunks; ChunkIndex++) { for (let ChunkIndex = 0; ChunkIndex < TotalChunks; ChunkIndex++) {
const Start = ChunkIndex * UploadChunkSizeBytes; const Start = ChunkIndex * UploadChunkSizeBytes;
const ChunkBlob = File.slice(Start, Start + 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); return CompleteChunkedUpload(RootName, RelativePath, UploadId, TotalChunks, File.name);
+2
View File
@@ -9,6 +9,7 @@ import SettingsPanel from "./Components/SettingsPanel.jsx";
import GraphPane from "./Components/GraphPane.jsx"; import GraphPane from "./Components/GraphPane.jsx";
import LoginScreen from "./Components/LoginScreen.jsx"; import LoginScreen from "./Components/LoginScreen.jsx";
import ErrorToast from "./Components/ErrorToast.jsx"; import ErrorToast from "./Components/ErrorToast.jsx";
import UploadToast from "./Components/UploadToast.jsx";
import { import {
EnsureDirectoryNote, EnsureDirectoryNote,
FetchAuthStatus, FetchAuthStatus,
@@ -429,6 +430,7 @@ export default function App() {
OnProfileUpdated={HandleProfileUpdated} OnProfileUpdated={HandleProfileUpdated}
/> />
)} )}
<UploadToast />
<ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} /> <ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} />
</div> </div>
); );
+2 -27
View File
@@ -7,13 +7,11 @@ import {
FetchLock, FetchLock,
FetchTree, FetchTree,
GetDownloadUrl, GetDownloadUrl,
IsLargeUpload,
MoveEntry, MoveEntry,
RenameEntry, RenameEntry,
SetLock as SetLockApi, SetLock as SetLockApi,
UploadFileChunked,
UploadFiles,
} from "../Api.js"; } from "../Api.js";
import { UploadAll } from "../Upload.js";
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js"; import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
import AccessModal from "./AccessModal.jsx"; import AccessModal from "./AccessModal.jsx";
import PromptModal from "./PromptModal.jsx"; import PromptModal from "./PromptModal.jsx";
@@ -183,30 +181,7 @@ export default function DirectoryPane({
async function HandleUpload(Files) { async function HandleUpload(Files) {
if (!Files || Files.length === 0) return; if (!Files || Files.length === 0) return;
const FileArray = Array.from(Files); const Errors = await UploadAll(RootName, DirectoryPath, 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}`);
}
}
if (Errors.length > 0) { if (Errors.length > 0) {
SetErrorMessage(Errors.join("; ")); SetErrorMessage(Errors.join("; "));
} }
+2 -27
View File
@@ -8,13 +8,11 @@ import {
FetchTree, FetchTree,
GetAvatarUrl, GetAvatarUrl,
GetDownloadUrl, GetDownloadUrl,
IsLargeUpload,
MoveEntry, MoveEntry,
RenameEntry, RenameEntry,
SetLock as SetLockApi, SetLock as SetLockApi,
UploadFileChunked,
UploadFiles,
} from "../Api.js"; } from "../Api.js";
import { UploadAll } from "../Upload.js";
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js"; import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
import AccessModal from "./AccessModal.jsx"; import AccessModal from "./AccessModal.jsx";
import PromptModal from "./PromptModal.jsx"; import PromptModal from "./PromptModal.jsx";
@@ -386,30 +384,7 @@ export default function Sidebar({
async function HandleUpload(Files) { async function HandleUpload(Files) {
if (!Files || Files.length === 0) return; if (!Files || Files.length === 0) return;
const FileArray = Array.from(Files); const Errors = await UploadAll(RootName, CurrentDirectoryPath, 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}`);
}
}
if (Errors.length > 0) { if (Errors.length > 0) {
SetErrorMessage(Errors.join("; ")); SetErrorMessage(Errors.join("; "));
} }
+26
View File
@@ -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 (
<div className="UploadToast" role="status">
<span>
{State.Finished
? `Uploaded ${Files}.`
: `Uploading ${Files}: ${Percent}% (${FormatSize(State.Done)} of ${FormatSize(State.Total)})`}
</span>
{!State.Finished && (
<div className="UploadToastBar">
<div className="UploadToastFill" style={{ width: `${Percent}%` }} />
</div>
)}
</div>
);
}
+32
View File
@@ -1135,6 +1135,38 @@ img,
z-index: 200; 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 { .ErrorToastClose {
background: none; background: none;
border: none; border: none;
+95
View File
@@ -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;
}