Fixed download sizes and directory highlighting

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 14:46:47 +02:00
1 parent 843ad1986e
commit d4c018a402
8 files changed
+113 -37

No files matched your search

+3 -6
View File
@@ -800,12 +800,9 @@ def GetDownloadEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCur
EncodedName = quote(DownloadName)
Headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{EncodedName}"}
def IterateDownload():
with EntryPath.open("rb") as SourceFile:
while Chunk := SourceFile.read(1024 * 1024):
yield Chunk
return StreamingResponse(IterateDownload(), media_type=MimeType, headers=Headers)
# FileResponse sends Content-Length, so browsers can show the size and
# time left (a plain stream showed them as unknown).
return FileResponse(EntryPath, media_type=MimeType, headers=Headers)
@Application.get("/api/book")
+45 -23
View File
@@ -11,7 +11,7 @@ from typing import Callable, Optional
import flet as ft
from App import App
from Core import ChipButtonStyle, FileIcons, HumanSize, OpenExternally, Palette
from Core import ChipButtonStyle, FileIcons, HumanDuration, HumanSize, OpenExternally, Palette
from Sources import BaseName, Entry, JoinPath, MoveDestination, ParentPath, SourceError
from Graph import GraphView
from Viewer import FileView
@@ -84,11 +84,12 @@ 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),
# Shown while uploading or downloading: which file, how far, how long (ShowTransfer).
self.TransferText = ft.Text("", size=12, color=Palette.Overlay0)
self.TransferBar = ft.ProgressBar(value=0, color=Palette.Mauve, bgcolor=Palette.Surface0, bar_height=4)
self.TransferRow = ft.Container(ft.Column([self.TransferText, self.TransferBar], spacing=4),
padding=ft.Padding.symmetric(horizontal=8, vertical=6), visible=False)
self.TransferShown: Optional[tuple] = None # (text, percent) on screen
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
@@ -108,7 +109,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.TransferRow,
self.FileArea,
]
self._ShowList(Update=False)
@@ -610,21 +611,14 @@ class Browser(ft.Column):
Total = sum(Sizes)
Verb = "Uploading" if Server else "Copying in"
Shown = [-1, -1] # (file, percent) on screen: redraw only when it changes
Started = time.monotonic()
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._ShowTransfer(f"{Verb} {Paths[Index].name}{Of}", sum(Sizes[:Index]) + Sizes[Index] * Fraction, Total, Started)
self.UploadRow.visible = True
self.TransferShown = None
self.TransferRow.visible = True
for Index, LocalFile in enumerate(Paths):
ShowProgress(Index, 0)
try:
@@ -634,15 +628,15 @@ class Browser(ft.Column):
Saved += 1
except SourceError as Error:
if Error.Status == 401:
self.UploadRow.visible = False
self.UploadRow.update()
self.TransferRow.visible = False
self.TransferRow.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.TransferRow.visible = False
self.TransferRow.update()
self.Refresh()
if Failed:
self.TheApp.Notify("Couldn't upload " + "; ".join(Failed), "error")
@@ -663,14 +657,42 @@ class Browser(ft.Column):
initial_directory=str(Path.home() / "Downloads"))
if not Chosen:
return
self.TheApp.Notify(f"Downloading {FileName}...", "info")
Started = time.monotonic()
self.TransferShown = None
self.TransferText.value = f"Preparing {FileName}…" # a directory is zipped first
self.TransferBar.value = None # moving, until the size is known
self.TransferRow.visible = True
self.TransferRow.update()
try:
await asyncio.to_thread(Server.Download, Root, RelativePath, Path(Chosen))
await asyncio.to_thread(Server.Download, Root, RelativePath, Path(Chosen),
lambda Done, Total: self._ShowTransfer(f"Downloading {FileName}", Done, Total, Started))
except SourceError as Error:
self.TheApp.HandleError(Error, f"Couldn't download {Name}")
return
finally:
self.TransferRow.visible = False
self.TransferRow.update()
self.TheApp.Notify(f"Saved {Chosen}", "success")
def _ShowTransfer(self, What: str, Done: float, Total: int, Started: float) -> None:
"""The transfer row: "Downloading a.zip: 45% (54.0 MB of 122.1 MB,
about 12 s left)". Redraws only when the percentage changes."""
Percent = int(Done / Total * 100) if Total else None
if self.TransferShown == (What, Percent):
return
self.TransferShown = (What, Percent)
Elapsed = time.monotonic() - Started
if Percent is None:
Detail = f"{HumanSize(int(Done))}"
else:
Detail = f"{Percent}% ({HumanSize(int(Done))} of {HumanSize(Total)}"
if Elapsed > 2 and 0 < Done < Total:
Detail += f", about {HumanDuration((Total - Done) * Elapsed / Done)} left"
Detail += ")"
self.TransferText.value = f"{What}: {Detail}"
self.TransferBar.value = Done / Total if Total else None
self.TransferRow.update()
# --- Keyboard ---------------------------------------------------------
def _SetShortcutsPaused(self, Paused: bool) -> None:
+11
View File
@@ -539,6 +539,17 @@ def HumanSize(NumBytes: Optional[int]) -> str:
return f"{Size:.1f} TB"
def HumanDuration(Seconds: float) -> str:
"""Roughly, for "about ... left": 8 s, 3 min, 1 h, 1 h 20 min."""
Seconds = max(1, round(Seconds))
if Seconds < 60:
return f"{Seconds} s"
Hours, Minutes = divmod(round(Seconds / 60), 60)
if not Hours:
return f"{Minutes} min"
return f"{Hours} h {Minutes} min" if Minutes else f"{Hours} h"
def DisplayPath(PathText: str) -> str:
"""/home/sarah/Notes -> ~/Notes, for labels (C:\\Users\\x\\Notes -> ~\\Notes)."""
HomeText = str(Home)
+24
View File
@@ -26,6 +26,12 @@ def _SetHighlight(Label: ft.Text, Holder: ft.Container, IsSelected: bool) -> Non
Holder.update()
def _SetOnPath(Holder: ft.Container, IsOnPath: bool) -> None:
"""The bar beside every directory you're in (the web view's TreeRowCurrentDirectory)."""
Holder.border = ft.Border.only(left=ft.BorderSide(2, Palette.Mauve)) if IsOnPath else None
Holder.update()
class _FileLeaf:
"""A file in the tree: a plain row (no expand arrow) indented like a
child tile, so names line up with the directories beside it."""
@@ -104,6 +110,9 @@ class _FiletreeNode:
def SetSelected(self, IsSelected: bool) -> None:
_SetHighlight(self.NameLabel, self.NameContainer, IsSelected)
def SetOnPath(self, IsOnPath: bool) -> None:
_SetOnPath(self.NameContainer, IsOnPath)
class Filetree(ft.Container):
def __init__(self, TheApp: App, OnNavigate: Callable[[str, str], None], OnOpenFile: Callable[[str, str], None]):
@@ -112,6 +121,7 @@ class Filetree(ft.Container):
self.OnOpenFile = OnOpenFile
self.RootNodes: dict[str, _FiletreeNode] = {}
self.SelectedNode: Optional[Union[_FiletreeNode, _FileLeaf]] = None
self.PathNodes: list[_FiletreeNode] = [] # the directories you're in, marked
self.TreeList = ft.ListView(expand=True)
self.SourceLabel = ft.Text("", size=12, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1)
super().__init__(
@@ -129,6 +139,7 @@ class Filetree(ft.Container):
Source = self.TheApp.Source
self.SourceLabel.value = "Not connected" if self.TheApp.ConnectError else Source.Describe()
self.SelectedNode = None
self.PathNodes = []
Roots: list[str] = []
if not self.TheApp.ConnectError:
try:
@@ -151,6 +162,9 @@ class Filetree(ft.Container):
if self.SelectedNode:
self.SelectedNode.SetSelected(False)
self.SelectedNode = None
for Marked in self.PathNodes:
Marked.SetOnPath(False)
self.PathNodes = []
Node = self.RootNodes.get(Root or "")
if Node is None:
return
@@ -169,5 +183,15 @@ class Filetree(ft.Container):
Target = Leaf
break
Node = Target = NextNode
# Every directory down to (and including) the one you're in, like the web view.
Walk = self.RootNodes.get(Root or "")
self.PathNodes = [Walk] if Walk else []
for Part in Parts:
Walk = Walk.ChildNodes.get(Part) if Walk else None
if Walk is None:
break
self.PathNodes.append(Walk)
for Marked in self.PathNodes:
Marked.SetOnPath(True)
Target.SetSelected(True)
self.SelectedNode = Target
+9 -2
View File
@@ -435,8 +435,10 @@ class ServerSource(Source):
Done = {**Params, "UploadId": UploadId, "TotalChunks": TotalChunks, "FileName": LocalFile.name}
return self._Json("POST", "/upload/complete", params=Done)["Saved"]
def Download(self, Root: str, RelativePath: str, Destination: Path) -> None:
"""A file as-is, a directory as a .zip (the server builds it)."""
def Download(self, Root: str, RelativePath: str, Destination: Path,
OnProgress: Optional[Callable[[int, int], None]] = None) -> None:
"""A file as-is, a directory as a .zip (the server builds it).
OnProgress(BytesReceived, TotalBytes) as it arrives (Total 0 if unknown)."""
if not self.Url:
raise SourceError("No server address yet.")
Partial = Destination.with_name(Destination.name + ".part")
@@ -445,9 +447,14 @@ class ServerSource(Source):
if not Response.is_success:
Response.read()
raise SourceError(f"Download failed: {Response.status_code} {Response.reason_phrase}", Response.status_code)
Total = int(Response.headers.get("Content-Length") or 0)
Received = 0
with Partial.open("wb") as Handle:
for Chunk in Response.iter_bytes(1024 * 1024):
Handle.write(Chunk)
Received += len(Chunk)
if OnProgress:
OnProgress(Received, Total)
Partial.replace(Destination)
except httpx.HTTPError as Error:
Partial.unlink(missing_ok=True)
+1 -2
View File
@@ -45,7 +45,6 @@ function TreeNode({
const [ErrorMessage, SetErrorMessage] = useState(null);
const FullPath = ParentPath ? `${ParentPath}/${Entry.Name}` : Entry.Name;
const IsSelected = SelectedFile === FullPath;
const IsCurrentDirectory = Entry.IsDirectory && CurrentDirectoryPath === FullPath;
const IsAncestorOfCurrentDirectory =
Entry.IsDirectory &&
CurrentDirectoryPath &&
@@ -144,7 +143,7 @@ function TreeNode({
<div className="TreeNode">
<div
className={`TreeRow${IsSelected ? " TreeRowSelected" : ""}${
IsCurrentDirectory ? " TreeRowCurrentDirectory" : ""
IsAncestorOfCurrentDirectory ? " TreeRowCurrentDirectory" : ""
}`}
onClick={ToggleExpand}
>
+7 -2
View File
@@ -1,6 +1,6 @@
import React, { useSyncExternalStore } from "react";
import { FormatSize, GetUploadState, SubscribeUploads } from "../Upload.js";
import { FormatDuration, FormatSize, GetUploadState, SubscribeUploads } from "../Upload.js";
export default function UploadToast() {
const State = useSyncExternalStore(SubscribeUploads, GetUploadState);
@@ -8,13 +8,18 @@ export default function UploadToast() {
const Files = `${State.Count} file${State.Count === 1 ? "" : "s"}`;
const Percent = State.Total > 0 ? Math.floor((State.Done / State.Total) * 100) : 100;
const Elapsed = (performance.now() - State.Started) / 1000;
const Left =
Elapsed > 2 && State.Done > 0 && State.Done < State.Total
? `, about ${FormatDuration(((State.Total - State.Done) * Elapsed) / State.Done)} left`
: "";
return (
<div className="UploadToast" role="status">
<span>
{State.Finished
? `Uploaded ${Files}.`
: `Uploading ${Files}: ${Percent}% (${FormatSize(State.Done)} of ${FormatSize(State.Total)})`}
: `Uploading ${Files}: ${Percent}% (${FormatSize(State.Done)} of ${FormatSize(State.Total)}${Left})`}
</span>
{!State.Finished && (
<div className="UploadToastBar">
+13 -2
View File
@@ -5,7 +5,7 @@
import { IsLargeUpload, UploadFileChunked, UploadFiles } from "./Api.js";
let State = null; // { Count, Done, Total (bytes), Finished }
let State = null; // { Count, Done, Total (bytes), Started (ms), Finished }
let ActiveUploads = 0;
const Listeners = new Set();
@@ -23,6 +23,16 @@ export function GetUploadState() {
return State;
}
// Roughly, for "about ... left": 8 s, 3 min, 1 h, 1 h 20 min. Same as the client's HumanDuration.
export function FormatDuration(Seconds) {
const Rounded = Math.max(1, Math.round(Seconds));
if (Rounded < 60) return `${Rounded} s`;
const Minutes = Math.round(Rounded / 60);
const Hours = Math.floor(Minutes / 60);
if (!Hours) return `${Minutes} min`;
return Minutes % 60 ? `${Hours} h ${Minutes % 60} min` : `${Hours} h`;
}
export function FormatSize(Bytes) {
const Units = ["B", "KB", "MB", "GB", "TB"];
let Size = Bytes;
@@ -53,8 +63,9 @@ export async function UploadAll(RootName, DirectoryPath, Files) {
const Errors = [];
let Finished = 0; // bytes of the parts already done
const Started = performance.now();
const Report = (Current) =>
Publish({ Count: FileArray.length, Done: Math.min(Total, Finished + Current), Total, Finished: false });
Publish({ Count: FileArray.length, Done: Math.min(Total, Finished + Current), Total, Started, Finished: false });
ActiveUploads++;
Report(0);