diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index c30c0cc..3488513 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -380,7 +380,10 @@ fn cmd_transfers() -> Result<()> { TransferDirection::Upload => "↑", }; let pct = if t.bytes_total > 0 { - format!("{:.0}%", 100.0 * t.bytes_completed as f64 / t.bytes_total as f64) + format!( + "{:.0}%", + 100.0 * t.bytes_completed as f64 / t.bytes_total as f64 + ) } else { "—".to_string() }; diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 556650e..ed29543 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -63,8 +63,8 @@ use proton_drive_rs::{ }; mod transfers; -use transfers::{CountingReader, CountingWriter, TransferRegistry}; use tracing::{debug, error, info, warn}; +use transfers::{CountingReader, CountingWriter, TransferRegistry}; /// Attribute/entry cache lifetime handed back to the kernel. Long because the /// Phase 2 event poller actively invalidates changed inodes; without a remote @@ -776,8 +776,7 @@ impl Core { .transfers .begin(name, uid.to_string(), TransferDirection::Download, total); let mut out = CountingWriter::new(Vec::with_capacity(total as usize), &guard); - self.rt - .block_on(photos.download_photo_to(uid, &mut out))?; + self.rt.block_on(photos.download_photo_to(uid, &mut out))?; Ok(out.into_inner()) } @@ -1134,7 +1133,11 @@ impl Core { if !e.node.is_file() { return Err("not a regular file".into()); } - (e.node.name.clone(), e.node.modification_time, node_size(&e.node)) + ( + e.node.name.clone(), + e.node.modification_time, + node_size(&e.node), + ) }; if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) { return Ok(p); @@ -2393,9 +2396,12 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U capture_time, ..Default::default() }; - let guard = - core.transfers - .begin(name.clone(), "", TransferDirection::Upload, bytes.len() as u64); + let guard = core.transfers.begin( + name.clone(), + "", + TransferDirection::Upload, + bytes.len() as u64, + ); let reader = CountingReader::new(std::io::Cursor::new(&bytes), &guard); match core.rt.block_on(photos.upload_photo_from( &name, diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs index 8f22bb7..03e62b1 100644 --- a/crates/pdfs-gui/src/app.rs +++ b/crates/pdfs-gui/src/app.rs @@ -32,7 +32,9 @@ use gtk4::glib::BoxedAnyObject; use pdfs_core::auth; use pdfs_core::config::AppDirs; -use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, SearchHit, send}; +use pdfs_core::control::{ + DirEntry, PhotoItem, Request, Response, SearchHit, TransferDirection, TransferItem, send, +}; use pdfs_core::service; /// How many photos to pull per [`Request::PhotosTimeline`] page. @@ -61,6 +63,15 @@ struct PinRow { unpin: Option, } +/// One rendered transfer row in the Activity group: a name + speed line over a +/// progress bar. Retained so [`repaint_transfers`] can update the bar and label +/// in place each tick when the active set is unchanged, instead of rebuilding. +struct TransferRow { + row: adw::PreferencesRow, + label: gtk4::Label, + bar: gtk4::ProgressBar, +} + /// All widgets the periodic refresh and the action handlers mutate, plus the /// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout /// closure share one instance. @@ -81,6 +92,12 @@ struct Ui { /// Whether a [`Request::Status`] round-trip is already in flight, so the 2s /// refresh tick doesn't pile worker threads up on a slow/wedged daemon. status_inflight: Cell, + /// Guards the [`Request::GetQueueStatus`] poll the same way: at most one + /// in-flight at a time so a wedged daemon can't stack worker threads. + transfers_inflight: Cell, + /// Activity group + its current rows, hidden when no transfer is in flight. + transfers_group: adw::PreferencesGroup, + transfer_rows: RefCell>, // Login page. email: adw::EntryRow, password: adw::PasswordEntryRow, @@ -278,6 +295,9 @@ fn build_window(app: &adw::Application) { login_status: login_widgets.3, account_row: main_widgets.account_row.clone(), mount_row: main_widgets.mount_row.clone(), + transfers_group: main_widgets.transfers_group.clone(), + transfer_rows: RefCell::new(Vec::new()), + transfers_inflight: Cell::new(false), cache_bar: main_widgets.cache_bar.clone(), cache_label: main_widgets.cache_label.clone(), autostart_row: main_widgets.autostart_row.clone(), @@ -441,6 +461,8 @@ fn build_login_page() -> ( struct MainWidgets { account_row: adw::ActionRow, mount_row: adw::ActionRow, + /// Live upload/download progress, populated by the refresh loop. + transfers_group: adw::PreferencesGroup, cache_bar: gtk4::ProgressBar, cache_label: gtk4::Label, pins_group: adw::PreferencesGroup, @@ -483,6 +505,14 @@ fn build_main_page() -> (gtk4::Widget, MainWidgets) { .build(); mount_group.add(&mount_row); + // Activity group: live upload/download progress. Hidden until the refresh + // loop sees an in-flight transfer from `Request::GetQueueStatus`. + let transfers_group = adw::PreferencesGroup::builder() + .title("Activity") + .description("Files moving to and from Proton Drive.") + .visible(false) + .build(); + // Storage group: a progress bar + "X of Y used" label, plus the cache-budget // editor and a purge button. `budget_row`/`purge_button` are wired in // `wire_settings`; the bar + label are repainted by the refresh loop. @@ -575,6 +605,7 @@ fn build_main_page() -> (gtk4::Widget, MainWidgets) { inner.set_margin_end(12); inner.append(&account_group); inner.append(&mount_group); + inner.append(&transfers_group); inner.append(&storage_group); inner.append(&system_group); inner.append(&pins_group); @@ -591,6 +622,7 @@ fn build_main_page() -> (gtk4::Widget, MainWidgets) { MainWidgets { account_row, mount_row, + transfers_group, cache_bar, cache_label, pins_group, @@ -937,6 +969,100 @@ fn refresh(ui: &Rc) { } refresh_status(ui); + refresh_transfers(ui); +} + +/// Poll the daemon's in-flight transfers on a worker thread and repaint the +/// Activity group. Independently inflight-guarded from [`refresh_status`] so the +/// two cheap polls on the 2s tick don't gate each other. The group hides itself +/// when nothing is moving, so an idle account shows no Activity section. +fn refresh_transfers(ui: &Rc) { + if ui.transfers_inflight.get() { + return; + } + ui.transfers_inflight.set(true); + let rx = spawn_request(ui.dirs.control_socket(), Request::GetQueueStatus); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.transfers_inflight.set(false); + match result { + Ok(Ok(Response::Transfers { items })) => repaint_transfers(&ui, &items), + // Daemon unreachable or odd reply: clear the section rather than + // leave stale progress bars frozen on screen. + _ => repaint_transfers(&ui, &[]), + } + }); +} + +/// Render the Activity group from `items`. Rebuilds the rows only when the set of +/// transfers changes (count differs); on the common steady tick it updates each +/// bar's fraction and the name/speed label in place, so progress animates without +/// flicker. Hides the whole group when nothing is in flight. +fn repaint_transfers(ui: &Rc, items: &[TransferItem]) { + if items.is_empty() { + if !ui.transfer_rows.borrow().is_empty() { + for tr in ui.transfer_rows.borrow_mut().drain(..) { + ui.transfers_group.remove(&tr.row); + } + } + ui.transfers_group.set_visible(false); + return; + } + + ui.transfers_group.set_visible(true); + + // Rebuild rows only when the count changes; otherwise reuse them in place. + if ui.transfer_rows.borrow().len() != items.len() { + for tr in ui.transfer_rows.borrow_mut().drain(..) { + ui.transfers_group.remove(&tr.row); + } + for _ in items { + let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + row_box.set_margin_top(8); + row_box.set_margin_bottom(8); + let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + label.add_css_class("dim-label"); + let bar = gtk4::ProgressBar::new(); + row_box.append(&label); + row_box.append(&bar); + let row = adw::PreferencesRow::builder() + .activatable(false) + .child(&row_box) + .build(); + ui.transfers_group.add(&row); + ui.transfer_rows + .borrow_mut() + .push(TransferRow { row, label, bar }); + } + } + + for (item, tr) in items.iter().zip(ui.transfer_rows.borrow().iter()) { + let arrow = match item.direction { + TransferDirection::Download => "↓", + TransferDirection::Upload => "↑", + }; + if item.bytes_total == 0 { + // Unknown size: pulse instead of a fraction, and omit the percentage. + tr.bar.pulse(); + tr.label.set_text(&format!( + "{arrow} {} — {} ({}/s)", + item.name, + human_bytes(item.bytes_completed), + human_bytes(item.speed_bytes_sec), + )); + } else { + let fraction = (item.bytes_completed as f64 / item.bytes_total as f64).min(1.0); + tr.bar.set_fraction(fraction); + tr.label.set_text(&format!( + "{arrow} {} — {} of {} ({}/s)", + item.name, + human_bytes(item.bytes_completed), + human_bytes(item.bytes_total), + human_bytes(item.speed_bytes_sec), + )); + } + } } /// Fetch mount status + cache stats from the daemon on a worker thread and repaint diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index e37a05c..77a5e34 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -18,7 +18,7 @@ use ksni::menu::StandardItem; use ksni::{MenuItem, Tray, TrayService}; use pdfs_core::auth; use pdfs_core::config::AppDirs; -use pdfs_core::control::{Request, Response, send}; +use pdfs_core::control::{Request, Response, TransferDirection, TransferItem, send}; use pdfs_core::service; /// How often the tray re-polls the daemon to refresh its menu. @@ -33,6 +33,42 @@ struct DriveState { mounted: bool, /// Mountpoint to act on (from the daemon when mounted, else the default). mountpoint: PathBuf, + /// One-line sync summary of in-flight transfers, empty when idle. Shown as a + /// disabled menu line under the status line. + sync: String, +} + +/// Summarise a transfer snapshot into one menu line, or empty when idle. A single +/// transfer names the file and its percentage; several collapse to counts so the +/// menu stays one line regardless of queue depth. +fn sync_line(items: &[TransferItem]) -> String { + match items { + [] => String::new(), + [t] => { + let arrow = match t.direction { + TransferDirection::Download => "↓", + TransferDirection::Upload => "↑", + }; + if t.bytes_total == 0 { + format!("{arrow} {}…", t.name) + } else { + let pct = (t.bytes_completed * 100 / t.bytes_total.max(1)).min(100); + format!("{arrow} {} ({pct}%)", t.name) + } + } + _ => { + let down = items + .iter() + .filter(|t| t.direction == TransferDirection::Download) + .count(); + let up = items.len() - down; + match (down, up) { + (d, 0) => format!("↓ {d} downloading"), + (0, u) => format!("↑ {u} uploading"), + (d, u) => format!("↓ {d} · ↑ {u}"), + } + } + } } struct DriveTray { @@ -49,12 +85,18 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { line: format!("Mounted at {mountpoint} ({pinned} pinned)"), mounted: true, mountpoint: PathBuf::from(mountpoint), + // Same daemon is up, so a cheap follow-up poll gives the sync line. + sync: match send(socket, &Request::GetQueueStatus) { + Ok(Response::Transfers { items }) => sync_line(&items), + _ => String::new(), + }, }, // Socket answered but with something unexpected — treat as up but odd. Ok(_) => DriveState { line: "Mount: unexpected daemon response".into(), mounted: true, mountpoint: default_mountpoint.to_path_buf(), + sync: String::new(), }, // No daemon: describe login state instead so the menu is still useful. Err(_) => { @@ -67,6 +109,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { line, mounted: false, mountpoint: default_mountpoint.to_path_buf(), + sync: String::new(), } } } @@ -116,6 +159,21 @@ impl Tray for DriveTray { ..Default::default() } .into(), + ]; + + // Live sync status, shown only while something is transferring. + if !self.state.sync.is_empty() { + items.push( + StandardItem { + label: self.state.sync.clone(), + enabled: false, + ..Default::default() + } + .into(), + ); + } + + items.extend([ MenuItem::Separator, StandardItem { label: "Open Manager".into(), @@ -123,7 +181,7 @@ impl Tray for DriveTray { ..Default::default() } .into(), - ]; + ]); if self.state.mounted { items.push(