diff --git a/Backend/Main.py b/Backend/Main.py index ecdebb6..5321d52 100644 --- a/Backend/Main.py +++ b/Backend/Main.py @@ -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") diff --git a/Client/Browser.py b/Client/Browser.py index 6335bc2..ce8c76f 100644 --- a/Client/Browser.py +++ b/Client/Browser.py @@ -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: diff --git a/Client/Core.py b/Client/Core.py index 01990fc..c12369d 100644 --- a/Client/Core.py +++ b/Client/Core.py @@ -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) diff --git a/Client/Filetree.py b/Client/Filetree.py index e6eaa22..69ee1f4 100644 --- a/Client/Filetree.py +++ b/Client/Filetree.py @@ -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 diff --git a/Client/Sources.py b/Client/Sources.py index bc3200d..34f0178 100644 --- a/Client/Sources.py +++ b/Client/Sources.py @@ -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) diff --git a/Frontend/src/Components/Sidebar.jsx b/Frontend/src/Components/Sidebar.jsx index cb00ba4..6c059d7 100644 --- a/Frontend/src/Components/Sidebar.jsx +++ b/Frontend/src/Components/Sidebar.jsx @@ -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({
diff --git a/Frontend/src/Components/UploadToast.jsx b/Frontend/src/Components/UploadToast.jsx index 5159977..7540447 100644 --- a/Frontend/src/Components/UploadToast.jsx +++ b/Frontend/src/Components/UploadToast.jsx @@ -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 (
{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})`} {!State.Finished && (
diff --git a/Frontend/src/Upload.js b/Frontend/src/Upload.js index aab7643..b95501f 100644 --- a/Frontend/src/Upload.js +++ b/Frontend/src/Upload.js @@ -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);