mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
added settings for this device + quota in settings
This commit is contained in:
1 parent
bf625bf8d9
commit
a3d9d05777
7 files changed
+205
No files matched your search
@@ -310,6 +310,8 @@ enum Command {
|
||||
#[arg(short, long, default_value_t = 50)]
|
||||
limit: usize,
|
||||
},
|
||||
/// Show account storage usage (used of total, across all Proton products).
|
||||
Quota,
|
||||
/// Inspect and maintain the local metadata database and content cache.
|
||||
Cache {
|
||||
#[command(subcommand)]
|
||||
@@ -547,6 +549,7 @@ fn main() -> Result<()> {
|
||||
Command::Invitations { action } => cmd_invitations(action),
|
||||
Command::Bookmarks { action } => cmd_bookmarks(action),
|
||||
Command::Activity { limit } => cmd_activity(limit),
|
||||
Command::Quota => cmd_quota(),
|
||||
Command::Cache { action } => cmd_cache(action),
|
||||
Command::Diagnose => cmd_diagnose(),
|
||||
}
|
||||
@@ -925,6 +928,35 @@ fn cmd_shared_get(uid: String, dest: Option<PathBuf>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_quota() -> Result<()> {
|
||||
let response = control_request(CtlRequest::AccountQuota)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::AccountQuota {
|
||||
max_space,
|
||||
used_space,
|
||||
} => {
|
||||
let used = used_space.max(0) as u64;
|
||||
if max_space > 0 {
|
||||
let total = max_space as u64;
|
||||
let pct = (used as f64 / total as f64 * 100.0).round() as u64;
|
||||
println!(
|
||||
"{} of {} used ({pct}%)",
|
||||
human_bytes(used),
|
||||
human_bytes(total)
|
||||
);
|
||||
} else {
|
||||
println!("{} used", human_bytes(used));
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_activity(limit: usize) -> Result<()> {
|
||||
let response = control_request(CtlRequest::ListActivity { limit })?;
|
||||
if emit_json(&response)? {
|
||||
|
||||
@@ -307,6 +307,11 @@ pub enum Request {
|
||||
/// Remove a saved bookmark by its `token`. Replies with [`Response::Ok`].
|
||||
DeleteBookmark { token: String },
|
||||
|
||||
// ---- account ----------------------------------------------------------
|
||||
/// Total account storage usage (used/total, all Proton products, not just
|
||||
/// Drive). Replies with [`Response::AccountQuota`].
|
||||
AccountQuota,
|
||||
|
||||
// ---- activity ---------------------------------------------------------
|
||||
/// Fetch the daemon's recent activity log, newest first, capped at `limit`
|
||||
/// entries. Replies with [`Response::Activity`]. The log is persisted, so it
|
||||
@@ -970,6 +975,11 @@ pub enum Response {
|
||||
Invitations { items: Vec<InvitationInfo> },
|
||||
/// Saved public links (reply to [`Request::ListBookmarks`]).
|
||||
Bookmarks { items: Vec<BookmarkInfo> },
|
||||
/// Account storage usage in bytes (reply to [`Request::AccountQuota`]).
|
||||
/// `max_space` is the total; `used_space` what is consumed. `max_space == 0`
|
||||
/// means the total is unknown, which a front-end shows as a plain used figure
|
||||
/// rather than a full bar.
|
||||
AccountQuota { max_space: i64, used_space: i64 },
|
||||
/// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]).
|
||||
SharedByMe { items: Vec<SharedItem> },
|
||||
/// The daemon's recent activity, newest first (reply to
|
||||
@@ -1283,6 +1293,7 @@ mod tests {
|
||||
Request::DeleteBookmark {
|
||||
token: "tok".into(),
|
||||
},
|
||||
Request::AccountQuota,
|
||||
];
|
||||
for req in reqs {
|
||||
let line = serde_json::to_string(&req).unwrap();
|
||||
|
||||
@@ -680,6 +680,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(entries) => CtlResponse::Entries { entries },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::AccountQuota) => match core.account_quota() {
|
||||
Ok((max_space, used_space)) => CtlResponse::AccountQuota {
|
||||
max_space,
|
||||
used_space,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListSharedFolder { uid }) => match core.list_shared_folder(&uid) {
|
||||
Ok(entries) => CtlResponse::Entries { entries },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
|
||||
@@ -392,6 +392,19 @@ impl Core {
|
||||
.map_err(|e| CoreError::from_api(&e, "delete bookmark"))
|
||||
}
|
||||
|
||||
// ---- account ----------------------------------------------------------
|
||||
|
||||
/// Total account storage usage `(max_space, used_space)` in bytes, across all
|
||||
/// Proton products (not Drive-only). A remote round-trip; nothing here is
|
||||
/// cached.
|
||||
pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> {
|
||||
let q = self
|
||||
.rt
|
||||
.block_on(self.client.quota())
|
||||
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
||||
Ok((q.max_space, q.used_space))
|
||||
}
|
||||
|
||||
// ---- shared by me -----------------------------------------------------
|
||||
|
||||
/// List the nodes I have shared with others, each with a summary of its share
|
||||
|
||||
@@ -400,6 +400,11 @@ fn build_window(app: &adw::Application) {
|
||||
transfers_inflight: Cell::new(false),
|
||||
cache_bar: main_widgets.cache_bar.clone(),
|
||||
cache_label: main_widgets.cache_label.clone(),
|
||||
quota_group: main_widgets.quota_group.clone(),
|
||||
quota_bar: main_widgets.quota_bar.clone(),
|
||||
quota_label: main_widgets.quota_label.clone(),
|
||||
quota_inflight: Cell::new(false),
|
||||
quota_checked_at: Cell::new(None),
|
||||
autostart_row: main_widgets.autostart_row.clone(),
|
||||
budget_row: main_widgets.budget_row.clone(),
|
||||
mountpoint_row: main_widgets.mountpoint_row.clone(),
|
||||
@@ -501,6 +506,8 @@ fn build_window(app: &adw::Application) {
|
||||
rows: RefCell::new(Vec::new()),
|
||||
sync_group: devices_widgets.sync_group.clone(),
|
||||
sync_rows: RefCell::new(Vec::new()),
|
||||
rename_this: devices_widgets.rename_this.clone(),
|
||||
this_device: RefCell::new(None),
|
||||
inflight: Cell::new(false),
|
||||
loaded_at: Cell::new(None),
|
||||
},
|
||||
|
||||
@@ -10,6 +10,13 @@ pub(crate) struct DevicesState {
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) sync_group: adw::PreferencesGroup,
|
||||
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
/// "Rename" action in the "This computer" header. Insensitive until the
|
||||
/// device list identifies this machine's own device.
|
||||
pub(crate) rename_this: gtk4::Button,
|
||||
/// This machine's own device `(uid, name)`, from the last device list. The
|
||||
/// current device is filtered out of the "Other computers" rows, so this is
|
||||
/// where its rename target lives. `None` until identified.
|
||||
pub(crate) this_device: RefCell<Option<(String, String)>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
}
|
||||
@@ -20,6 +27,8 @@ pub(crate) struct DevicesWidgets {
|
||||
pub(crate) status: adw::StatusPage,
|
||||
/// "This computer" — the local folders synced to this machine's device.
|
||||
pub(crate) sync_group: adw::PreferencesGroup,
|
||||
/// "Rename" in the "This computer" header — renames this machine's device.
|
||||
pub(crate) rename_this: gtk4::Button,
|
||||
/// "Other computers" — the account's *other* registered devices. This
|
||||
/// machine's own device is deliberately not among them; see
|
||||
/// [`repaint_devices`].
|
||||
@@ -75,6 +84,17 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
them as you open them.",
|
||||
)
|
||||
.build();
|
||||
// Rename control for *this* machine's device, in the section header. The
|
||||
// current device is filtered out of "Other computers", so this is the only
|
||||
// place it can be renamed; insensitive until the device list identifies it.
|
||||
let rename_this = gtk4::Button::builder()
|
||||
.icon_name("document-edit-symbolic")
|
||||
.tooltip_text("Rename this computer")
|
||||
.valign(gtk4::Align::Center)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
rename_this.add_css_class("flat");
|
||||
sync_group.set_header_suffix(Some(&rename_this));
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Other computers")
|
||||
.description("Other devices backing up to this account.")
|
||||
@@ -123,6 +143,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
content,
|
||||
status,
|
||||
sync_group,
|
||||
rename_this,
|
||||
group,
|
||||
add_folder,
|
||||
restore,
|
||||
@@ -148,6 +169,12 @@ pub(crate) fn wire_devices(
|
||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||
let ui_restore = ui.clone();
|
||||
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
||||
let ui_ren = ui.clone();
|
||||
ui.devices.rename_this.connect_clicked(move |_| {
|
||||
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
|
||||
prompt_rename_device(&ui_ren, &uid, &name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Devices list.
|
||||
@@ -282,6 +309,22 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
for row in ui.devices.rows.borrow_mut().drain(..) {
|
||||
ui.devices.group.remove(&row);
|
||||
}
|
||||
// Identify this machine's own device so the "This computer" header's Rename
|
||||
// action has a target. It never appears in the rows below, so this is the
|
||||
// only place its name/uid is captured.
|
||||
match devices.iter().find(|d| d.this_device) {
|
||||
Some(me) => {
|
||||
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
|
||||
ui.devices.rename_this.set_sensitive(true);
|
||||
ui.devices
|
||||
.rename_this
|
||||
.set_tooltip_text(Some(&format!("Rename this computer ({})", me.name)));
|
||||
}
|
||||
None => {
|
||||
*ui.devices.this_device.borrow_mut() = None;
|
||||
ui.devices.rename_this.set_sensitive(false);
|
||||
}
|
||||
}
|
||||
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
|
||||
if others.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
|
||||
@@ -17,6 +17,14 @@ pub(crate) struct StatusState {
|
||||
pub(crate) mount_row: adw::ActionRow,
|
||||
pub(crate) cache_bar: gtk4::ProgressBar,
|
||||
pub(crate) cache_label: gtk4::Label,
|
||||
/// Account-quota group + its bar/label. Hidden until the first reading.
|
||||
pub(crate) quota_group: adw::PreferencesGroup,
|
||||
pub(crate) quota_bar: gtk4::ProgressBar,
|
||||
pub(crate) quota_label: gtk4::Label,
|
||||
/// One `AccountQuota` in flight at a time, and when it last succeeded — quota
|
||||
/// barely moves, so [`refresh_quota`] refetches on a long TTL, not every tick.
|
||||
pub(crate) quota_inflight: Cell<bool>,
|
||||
pub(crate) quota_checked_at: Cell<Option<Instant>>,
|
||||
/// "Start on login" toggle. [`Self::settings_suppress`] guards programmatic
|
||||
/// sets so reflecting the systemd state doesn't fire the toggle handler.
|
||||
pub(crate) autostart_row: adw::SwitchRow,
|
||||
@@ -78,6 +86,10 @@ pub(crate) struct MainWidgets {
|
||||
pub(crate) mount_row: adw::ActionRow,
|
||||
/// Live upload/download progress, populated by the refresh loop.
|
||||
pub(crate) transfers_group: adw::PreferencesGroup,
|
||||
/// Account-quota group; hidden until `refresh_quota` gets a reading.
|
||||
pub(crate) quota_group: adw::PreferencesGroup,
|
||||
pub(crate) quota_bar: gtk4::ProgressBar,
|
||||
pub(crate) quota_label: gtk4::Label,
|
||||
pub(crate) cache_bar: gtk4::ProgressBar,
|
||||
pub(crate) cache_label: gtk4::Label,
|
||||
pub(crate) pins_group: adw::PreferencesGroup,
|
||||
@@ -111,6 +123,29 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
account_row.add_suffix(&logout_button);
|
||||
account_group.add(&account_row);
|
||||
|
||||
// Account storage: the Proton account quota (used of total), distinct from
|
||||
// the local content cache below. A progress bar + "X of Y used" line, painted
|
||||
// by `refresh_quota`. Hidden until the first successful read so a cold start
|
||||
// (or an account the API can't report) shows nothing rather than an empty bar.
|
||||
let quota_group = adw::PreferencesGroup::builder()
|
||||
.title("Account storage")
|
||||
.description("Your Proton storage across all products.")
|
||||
.visible(false)
|
||||
.build();
|
||||
let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
quota_box.set_margin_top(6);
|
||||
quota_box.set_margin_bottom(6);
|
||||
let quota_bar = gtk4::ProgressBar::new();
|
||||
let quota_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
quota_label.add_css_class("dim-label");
|
||||
quota_box.append("a_bar);
|
||||
quota_box.append("a_label);
|
||||
let quota_row = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child("a_box)
|
||||
.build();
|
||||
quota_group.add("a_row);
|
||||
|
||||
// Mount group: a read-only status line. The mount is managed automatically
|
||||
// by the systemd user service; there is no toggle to fiddle with.
|
||||
let mount_group = adw::PreferencesGroup::builder().title("Drive").build();
|
||||
@@ -219,6 +254,7 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&account_group);
|
||||
inner.append("a_group);
|
||||
inner.append(&mount_group);
|
||||
inner.append(&transfers_group);
|
||||
inner.append(&storage_group);
|
||||
@@ -238,6 +274,9 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
account_row,
|
||||
mount_row,
|
||||
transfers_group,
|
||||
quota_group,
|
||||
quota_bar,
|
||||
quota_label,
|
||||
cache_bar,
|
||||
cache_label,
|
||||
pins_group,
|
||||
@@ -463,12 +502,65 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
// Both of these pages show work as it happens, so they follow the tick while
|
||||
// they are on screen. Every other page loads on navigation only.
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("main") => refresh_quota(ui),
|
||||
Some("devices") => refresh_sync_folders(ui),
|
||||
Some("activity") => refresh_activity(ui),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a quota reading stays fresh. Account storage barely moves, so the
|
||||
/// Settings tick refetches it only this often rather than every 2s.
|
||||
const QUOTA_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Fetch the account quota (if the last reading is stale) and paint the Account
|
||||
/// storage group. Runs only while Settings is on screen. The group stays hidden
|
||||
/// until a reading lands, so an account the API can't report — or a still-starting
|
||||
/// daemon — shows nothing rather than an empty bar. A failed fetch leaves the last
|
||||
/// good reading in place.
|
||||
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
||||
if ui.status.quota_inflight.get() {
|
||||
return;
|
||||
}
|
||||
if let Some(at) = ui.status.quota_checked_at.get()
|
||||
&& at.elapsed() < QUOTA_TTL
|
||||
{
|
||||
return;
|
||||
}
|
||||
ui.status.quota_inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::AccountQuota);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.status.quota_inflight.set(false);
|
||||
if let Ok(Ok(Response::AccountQuota {
|
||||
max_space,
|
||||
used_space,
|
||||
})) = result
|
||||
{
|
||||
ui.status.quota_checked_at.set(Some(Instant::now()));
|
||||
let used = used_space.max(0) as u64;
|
||||
if max_space > 0 {
|
||||
let total = max_space as u64;
|
||||
let fraction = (used as f64 / total as f64).min(1.0);
|
||||
ui.status.quota_bar.set_fraction(fraction);
|
||||
let pct = (fraction * 100.0).round() as u64;
|
||||
ui.status.quota_label.set_text(&format!(
|
||||
"{} of {} used ({pct}%)",
|
||||
human_bytes(used),
|
||||
human_bytes(total)
|
||||
));
|
||||
} else {
|
||||
ui.status.quota_bar.set_fraction(0.0);
|
||||
ui.status
|
||||
.quota_label
|
||||
.set_text(&format!("{} used", human_bytes(used)));
|
||||
}
|
||||
ui.status.quota_group.set_visible(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Record the mount state seen by the last status poll: gate every control that
|
||||
/// needs a live daemon, and notify the desktop when the state actually flips.
|
||||
///
|
||||
|
||||
Reference in new issue
Block a user