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