transfer ui

This commit is contained in:
nvrl committed 2026-07-01 00:02:35 +02:00
1 parent 35bc957cd5
commit 5766bc4ed1
4 files changed
+204 -11

No files matched your search

+4 -1
View File
@@ -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()
};
+13 -7
View File
@@ -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,
+127 -1
View File
@@ -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<gtk4::Button>,
}
/// 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<bool>,
/// 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<bool>,
/// Activity group + its current rows, hidden when no transfer is in flight.
transfers_group: adw::PreferencesGroup,
transfer_rows: RefCell<Vec<TransferRow>>,
// 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<Ui>) {
}
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<Ui>) {
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<Ui>, 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
+60 -2
View File
@@ -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(