mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
transfer ui
This commit is contained in:
1 parent
35bc957cd5
commit
5766bc4ed1
4 files changed
+204
-11
No files matched your search
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user