mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +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 => "↑",
|
TransferDirection::Upload => "↑",
|
||||||
};
|
};
|
||||||
let pct = if t.bytes_total > 0 {
|
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 {
|
} else {
|
||||||
"—".to_string()
|
"—".to_string()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ use proton_drive_rs::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod transfers;
|
mod transfers;
|
||||||
use transfers::{CountingReader, CountingWriter, TransferRegistry};
|
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
use transfers::{CountingReader, CountingWriter, TransferRegistry};
|
||||||
|
|
||||||
/// Attribute/entry cache lifetime handed back to the kernel. Long because the
|
/// Attribute/entry cache lifetime handed back to the kernel. Long because the
|
||||||
/// Phase 2 event poller actively invalidates changed inodes; without a remote
|
/// Phase 2 event poller actively invalidates changed inodes; without a remote
|
||||||
@@ -776,8 +776,7 @@ impl Core {
|
|||||||
.transfers
|
.transfers
|
||||||
.begin(name, uid.to_string(), TransferDirection::Download, total);
|
.begin(name, uid.to_string(), TransferDirection::Download, total);
|
||||||
let mut out = CountingWriter::new(Vec::with_capacity(total as usize), &guard);
|
let mut out = CountingWriter::new(Vec::with_capacity(total as usize), &guard);
|
||||||
self.rt
|
self.rt.block_on(photos.download_photo_to(uid, &mut out))?;
|
||||||
.block_on(photos.download_photo_to(uid, &mut out))?;
|
|
||||||
Ok(out.into_inner())
|
Ok(out.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1134,7 +1133,11 @@ impl Core {
|
|||||||
if !e.node.is_file() {
|
if !e.node.is_file() {
|
||||||
return Err("not a regular file".into());
|
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) {
|
if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) {
|
||||||
return Ok(p);
|
return Ok(p);
|
||||||
@@ -2393,9 +2396,12 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
capture_time,
|
capture_time,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let guard =
|
let guard = core.transfers.begin(
|
||||||
core.transfers
|
name.clone(),
|
||||||
.begin(name.clone(), "", TransferDirection::Upload, bytes.len() as u64);
|
"",
|
||||||
|
TransferDirection::Upload,
|
||||||
|
bytes.len() as u64,
|
||||||
|
);
|
||||||
let reader = CountingReader::new(std::io::Cursor::new(&bytes), &guard);
|
let reader = CountingReader::new(std::io::Cursor::new(&bytes), &guard);
|
||||||
match core.rt.block_on(photos.upload_photo_from(
|
match core.rt.block_on(photos.upload_photo_from(
|
||||||
&name,
|
&name,
|
||||||
|
|||||||
+127
-1
@@ -32,7 +32,9 @@ use gtk4::glib::BoxedAnyObject;
|
|||||||
|
|
||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::config::AppDirs;
|
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;
|
use pdfs_core::service;
|
||||||
|
|
||||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||||
@@ -61,6 +63,15 @@ struct PinRow {
|
|||||||
unpin: Option<gtk4::Button>,
|
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
|
/// 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
|
/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout
|
||||||
/// closure share one instance.
|
/// closure share one instance.
|
||||||
@@ -81,6 +92,12 @@ struct Ui {
|
|||||||
/// Whether a [`Request::Status`] round-trip is already in flight, so the 2s
|
/// 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.
|
/// refresh tick doesn't pile worker threads up on a slow/wedged daemon.
|
||||||
status_inflight: Cell<bool>,
|
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.
|
// Login page.
|
||||||
email: adw::EntryRow,
|
email: adw::EntryRow,
|
||||||
password: adw::PasswordEntryRow,
|
password: adw::PasswordEntryRow,
|
||||||
@@ -278,6 +295,9 @@ fn build_window(app: &adw::Application) {
|
|||||||
login_status: login_widgets.3,
|
login_status: login_widgets.3,
|
||||||
account_row: main_widgets.account_row.clone(),
|
account_row: main_widgets.account_row.clone(),
|
||||||
mount_row: main_widgets.mount_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_bar: main_widgets.cache_bar.clone(),
|
||||||
cache_label: main_widgets.cache_label.clone(),
|
cache_label: main_widgets.cache_label.clone(),
|
||||||
autostart_row: main_widgets.autostart_row.clone(),
|
autostart_row: main_widgets.autostart_row.clone(),
|
||||||
@@ -441,6 +461,8 @@ fn build_login_page() -> (
|
|||||||
struct MainWidgets {
|
struct MainWidgets {
|
||||||
account_row: adw::ActionRow,
|
account_row: adw::ActionRow,
|
||||||
mount_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_bar: gtk4::ProgressBar,
|
||||||
cache_label: gtk4::Label,
|
cache_label: gtk4::Label,
|
||||||
pins_group: adw::PreferencesGroup,
|
pins_group: adw::PreferencesGroup,
|
||||||
@@ -483,6 +505,14 @@ fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
mount_group.add(&mount_row);
|
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
|
// 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
|
// editor and a purge button. `budget_row`/`purge_button` are wired in
|
||||||
// `wire_settings`; the bar + label are repainted by the refresh loop.
|
// `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.set_margin_end(12);
|
||||||
inner.append(&account_group);
|
inner.append(&account_group);
|
||||||
inner.append(&mount_group);
|
inner.append(&mount_group);
|
||||||
|
inner.append(&transfers_group);
|
||||||
inner.append(&storage_group);
|
inner.append(&storage_group);
|
||||||
inner.append(&system_group);
|
inner.append(&system_group);
|
||||||
inner.append(&pins_group);
|
inner.append(&pins_group);
|
||||||
@@ -591,6 +622,7 @@ fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
|||||||
MainWidgets {
|
MainWidgets {
|
||||||
account_row,
|
account_row,
|
||||||
mount_row,
|
mount_row,
|
||||||
|
transfers_group,
|
||||||
cache_bar,
|
cache_bar,
|
||||||
cache_label,
|
cache_label,
|
||||||
pins_group,
|
pins_group,
|
||||||
@@ -937,6 +969,100 @@ fn refresh(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
refresh_status(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
|
/// 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 ksni::{MenuItem, Tray, TrayService};
|
||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::config::AppDirs;
|
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;
|
use pdfs_core::service;
|
||||||
|
|
||||||
/// How often the tray re-polls the daemon to refresh its menu.
|
/// How often the tray re-polls the daemon to refresh its menu.
|
||||||
@@ -33,6 +33,42 @@ struct DriveState {
|
|||||||
mounted: bool,
|
mounted: bool,
|
||||||
/// Mountpoint to act on (from the daemon when mounted, else the default).
|
/// Mountpoint to act on (from the daemon when mounted, else the default).
|
||||||
mountpoint: PathBuf,
|
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 {
|
struct DriveTray {
|
||||||
@@ -49,12 +85,18 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
line: format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
line: format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
||||||
mounted: true,
|
mounted: true,
|
||||||
mountpoint: PathBuf::from(mountpoint),
|
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.
|
// Socket answered but with something unexpected — treat as up but odd.
|
||||||
Ok(_) => DriveState {
|
Ok(_) => DriveState {
|
||||||
line: "Mount: unexpected daemon response".into(),
|
line: "Mount: unexpected daemon response".into(),
|
||||||
mounted: true,
|
mounted: true,
|
||||||
mountpoint: default_mountpoint.to_path_buf(),
|
mountpoint: default_mountpoint.to_path_buf(),
|
||||||
|
sync: String::new(),
|
||||||
},
|
},
|
||||||
// No daemon: describe login state instead so the menu is still useful.
|
// No daemon: describe login state instead so the menu is still useful.
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -67,6 +109,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
line,
|
line,
|
||||||
mounted: false,
|
mounted: false,
|
||||||
mountpoint: default_mountpoint.to_path_buf(),
|
mountpoint: default_mountpoint.to_path_buf(),
|
||||||
|
sync: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +159,21 @@ impl Tray for DriveTray {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.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,
|
MenuItem::Separator,
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Open Manager".into(),
|
label: "Open Manager".into(),
|
||||||
@@ -123,7 +181,7 @@ impl Tray for DriveTray {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
];
|
]);
|
||||||
|
|
||||||
if self.state.mounted {
|
if self.state.mounted {
|
||||||
items.push(
|
items.push(
|
||||||
|
|||||||
Reference in new issue
Block a user