use crate::*; pub(crate) struct StatusState { /// 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. pub(crate) 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. pub(crate) transfers_inflight: Cell, /// Transfers group (on the Sync page) + its current rows, hidden when no /// transfer is in flight. pub(crate) transfers_group: adw::PreferencesGroup, pub(crate) transfer_rows: RefCell>, /// The Preferences dialog, built once and presented on demand. pub(crate) prefs: adw::PreferencesDialog, /// Sidebar footer: signed-in identity. pub(crate) account_name: gtk4::Label, pub(crate) avatar: adw::Avatar, /// Sidebar footer: the one-line sync state. The mount is driven by the /// systemd user service, not by the user — so this only reports. pub(crate) status_icon: gtk4::Image, /// Stands in for [`Self::status_icon`] until the first status arrives. pub(crate) status_spinner: gtk4::Spinner, pub(crate) status_title: gtk4::Label, pub(crate) status_detail: gtk4::Label, pub(crate) cache_bar: gtk4::ProgressBar, pub(crate) cache_label: gtk4::Label, /// Sidebar footer: account quota bar/label. Hidden until the first reading. pub(crate) quota_box: gtk4::Box, 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, pub(crate) quota_checked_at: Cell>, /// "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, /// Cache-budget editor (GiB). Populated once from config; user edits drive a /// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`]. pub(crate) budget_row: adw::SpinRow, /// Bandwidth caps (MiB/s, `0` = none). Populated once from config; an edit /// to either sends both in one `SetBandwidthLimits`. Guarded by /// [`Self::settings_suppress`]. pub(crate) upload_limit_row: adw::SpinRow, pub(crate) download_limit_row: adw::SpinRow, /// Shows where the primary mount lives; its Change button picks a new one. pub(crate) mountpoint_row: adw::ActionRow, /// "Proton theme" toggle. Guarded by [`Self::settings_suppress`]. pub(crate) theme_row: adw::SwitchRow, /// Street map on the Places map. Guarded by [`Self::settings_suppress`]. pub(crate) online_map_row: adw::SwitchRow, /// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`]. pub(crate) tray_row: adw::SwitchRow, /// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by /// [`Self::settings_suppress`]. pub(crate) language_row: adw::ComboRow, /// Set while a settings widget is being populated programmatically, so its /// change handler skips the IPC/systemd side effect. pub(crate) settings_suppress: Cell, /// Pending debounce for the cache-budget editor. Each `+` click is a value /// change, and firing a `SetCacheBudget` (and a toast) per click means a /// stack of toasts and a series of caps the user never asked to apply — so /// only the value they settle on is sent. pub(crate) budget_source: RefCell>, /// Pending debounce for the bandwidth editors, as for the cache budget. pub(crate) limit_source: RefCell>, pub(crate) pins_group: adw::PreferencesGroup, /// Whether the pin list is showing every pin or only the first /// [`PINS_COLLAPSED`]. A long pin list would otherwise push everything below /// it off the end of the Storage page. pub(crate) pins_expanded: Cell, /// Rows currently shown under [`Self::pins_group`], retained so a refresh can /// diff against them and only rebuild when the pin set actually changes. pub(crate) pin_rows: RefCell>, /// The pin paths last rendered, the diff baseline for [`repaint_pins`]. /// `None` = nothing built yet; `Some(empty)` = the placeholder is shown. pub(crate) pins_state: RefCell>>, /// The mount state the *last* desktop notification reported, so a flap only /// notifies on the edge. `None` until the first status reply, so a cold start /// doesn't announce "disconnected" before the service has had a chance to come /// up. pub(crate) notified_mounted: Cell>, /// How many transfers were in flight on the previous poll. A drop to zero is /// what "sync complete" means; there's no completion event on the wire. pub(crate) active_transfers: Cell, /// The run of transfers since the count last left zero, for deciding /// whether its end is worth a "Sync complete" notification. pub(crate) transfer_batch: RefCell>, } /// One run of transfers, from the first file to the count falling back to 0. pub(crate) struct TransferBatch { started: Instant, /// Every file seen in flight during the run, so the notification can count /// files rather than the handful still moving on the last poll. names: HashSet, } /// A run of transfers earns a "Sync complete" notification only when it moved /// at least this many files or lasted at least [`SYNC_NOTIFY_MIN_DURATION`]. /// Saving one document at a time would otherwise notify on every save. const SYNC_NOTIFY_MIN_FILES: usize = 5; const SYNC_NOTIFY_MIN_DURATION: Duration = Duration::from_secs(30); /// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's /// `sensitive` in place (when the pin set is unchanged) instead of rebuilding. pub(crate) struct PinRow { pub(crate) row: adw::ActionRow, /// The unpin button, absent on the placeholder row. pub(crate) unpin: Option, } /// One rendered row in the Activity group: a description 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. pub(crate) struct TransferRow { pub(crate) row: adw::PreferencesRow, pub(crate) label: gtk4::Label, pub(crate) bar: gtk4::ProgressBar, } /// What one Activity row should say this tick, and how far along it is — /// `None` meaning "no total known", which the bar shows by pulsing. Jobs and /// transfers both render to this, so the group is one list in the order the /// daemon reports: the jobs that frame the work, then the files moving under it. pub(crate) struct ActivityLine { pub(crate) text: String, pub(crate) fraction: Option, } /// Widgets the Preferences dialog and the sidebar footer hand back for the /// refresh loop and action wiring. pub(crate) struct MainWidgets { pub(crate) prefs: adw::PreferencesDialog, /// The sidebar footer: sync status, quota, account. pub(crate) footer: gtk4::Box, /// Opens the Sync page; the footer's status strip is this button. pub(crate) status_button: gtk4::Button, pub(crate) account_name: gtk4::Label, pub(crate) avatar: adw::Avatar, pub(crate) status_icon: gtk4::Image, pub(crate) status_spinner: gtk4::Spinner, pub(crate) status_title: gtk4::Label, pub(crate) status_detail: gtk4::Label, pub(crate) quota_box: gtk4::Box, 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, /// "Start on login" toggle, reflecting the systemd unit's enabled state. pub(crate) autostart_row: adw::SwitchRow, /// Cache soft-cap editor, in GiB; `0` = unlimited. pub(crate) budget_row: adw::SpinRow, /// Upload and download caps, in MiB/s; `0` = unlimited. pub(crate) upload_limit_row: adw::SpinRow, pub(crate) download_limit_row: adw::SpinRow, /// Purges all unpinned cached content. pub(crate) purge_button: gtk4::Button, /// Shows the active mountpoint; its suffix button picks a new one. pub(crate) mountpoint_row: adw::ActionRow, pub(crate) mountpoint_button: gtk4::Button, pub(crate) theme_row: adw::SwitchRow, pub(crate) online_map_row: adw::SwitchRow, pub(crate) tray_row: adw::SwitchRow, pub(crate) language_row: adw::ComboRow, } /// Build the two surfaces that replaced the old Settings page: /// /// - the Preferences dialog (General: start on login, mount location, /// appearance; Storage: cache usage, budget, clear, offline files), and /// - the sidebar footer (sync status strip, account quota, account menu). /// /// Live state (transfers, the folder list) lives on the Sync page instead, and /// the app version and user agent in About → Troubleshooting. pub(crate) fn build_main_page() -> MainWidgets { // ---- Preferences: General let startup_group = adw::PreferencesGroup::builder() .title(gettext("Startup")) .build(); let autostart_row = adw::SwitchRow::builder() .title(gettext("Start on login")) .subtitle(gettext( "Connect Proton Drive automatically when you log in", )) .build(); startup_group.add(&autostart_row); let location_group = adw::PreferencesGroup::builder() .title(gettext("Location")) .build(); let mountpoint_row = adw::ActionRow::builder() .title(gettext("Proton Drive folder")) .subtitle("—") .build(); mountpoint_row.add_css_class("property"); let mountpoint_button = gtk4::Button::builder() .label(gettext("Change…")) .tooltip_text(gettext("Choose where My files appears on this computer")) .valign(gtk4::Align::Center) .build(); mountpoint_button.add_css_class("flat"); mountpoint_row.add_suffix(&mountpoint_button); location_group.add(&mountpoint_row); // Bandwidth caps in MiB/s. 0 = unlimited; the smallest cap is one 0.5 step, // since anything slower would stall a single block for most of a minute. let network_group = adw::PreferencesGroup::builder() .title(gettext("Network")) .description(gettext( "Limit how fast files move, shared by every transfer. 0 means no limit.", )) .build(); let limit_row = |title: &str| { adw::SpinRow::builder() .title(title) .adjustment(>k4::Adjustment::new(0.0, 0.0, 1000.0, 0.5, 5.0, 0.0)) .digits(1) .build() }; let upload_limit_row = limit_row(&gettext("Upload limit (MiB/s)")); let download_limit_row = limit_row(&gettext("Download limit (MiB/s)")); network_group.add(&upload_limit_row); network_group.add(&download_limit_row); let appearance_group = adw::PreferencesGroup::builder() .title(gettext("Appearance")) .build(); let theme_row = adw::SwitchRow::builder() .title(gettext("Proton theme")) .subtitle(gettext( "Use Proton's colors instead of the system theme and accent color", )) .build(); appearance_group.add(&theme_row); let online_map_row = adw::SwitchRow::builder() .title(gettext("Street map for Places")) .subtitle(gettext( "Show streets and town names from OpenFreeMap. It learns which parts of the world you look at.", )) .build(); appearance_group.add(&online_map_row); let tray_row = adw::SwitchRow::builder() .title(gettext("Show tray icon")) .subtitle(gettext("Sync status and quick actions in the panel")) .build(); appearance_group.add(&tray_row); let language_row = build_language_row(); appearance_group.add(&language_row); let general = adw::PreferencesPage::builder() .title(gettext("General")) .icon_name("preferences-system-symbolic") .build(); general.add(&startup_group); general.add(&location_group); general.add(&network_group); general.add(&appearance_group); // ---- Preferences: Storage let storage_group = adw::PreferencesGroup::builder() .title(gettext("Cache")) .description(gettext( "Pinned and recently opened files, kept on this computer.", )) .build(); let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); storage_box.set_margin_top(12); storage_box.set_margin_bottom(12); storage_box.set_margin_start(12); storage_box.set_margin_end(12); let cache_bar = gtk4::ProgressBar::new(); let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); cache_label.add_css_class("dim-label"); storage_box.append(&cache_bar); storage_box.append(&cache_label); let usage_row = adw::PreferencesRow::builder() .activatable(false) .child(&storage_box) .build(); storage_group.add(&usage_row); // Cache budget, expressed in GiB. 0 = unlimited; the daemon applies a 0 cap // as "no eviction". Step in 0.5 GiB; the upper bound is generous. let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0); let budget_row = adw::SpinRow::builder() .title(gettext("Cache size limit (GiB)")) .subtitle(gettext( "Older files are removed past this size; 0 means no limit", )) .adjustment(&budget_adj) .digits(1) .build(); storage_group.add(&budget_row); let purge_row = adw::ActionRow::builder() .title(gettext("Clear cache")) .subtitle(gettext("Remove cached copies. Files kept offline stay.")) .build(); let purge_button = gtk4::Button::builder() .label(gettext("Clear…")) .valign(gtk4::Align::Center) .build(); purge_button.add_css_class("flat"); purge_row.add_suffix(&purge_button); storage_group.add(&purge_row); // Pins group: filled in by refresh. let pins_group = adw::PreferencesGroup::builder() .title(gettext("Available offline")) .description(gettext( "Files kept on this computer, even without a connection.", )) .build(); let storage = adw::PreferencesPage::builder() .title(gettext("Storage")) .icon_name("drive-harddisk-symbolic") .build(); storage.add(&storage_group); storage.add(&pins_group); let prefs = adw::PreferencesDialog::builder() .search_enabled(false) .build(); prefs.add(&general); prefs.add(&storage); // ---- Sidebar footer // Until the first status arrives there is no state to show, only that the // app is still asking. let status_icon = gtk4::Image::builder().visible(false).build(); let status_spinner = gtk4::Spinner::builder().spinning(true).build(); let status_title = gtk4::Label::builder() .label(gettext("Connecting…")) .halign(gtk4::Align::Start) .ellipsize(gtk4::pango::EllipsizeMode::End) .build(); status_title.add_css_class("heading"); let status_detail = gtk4::Label::builder() .halign(gtk4::Align::Start) .ellipsize(gtk4::pango::EllipsizeMode::End) .visible(false) .build(); status_detail.add_css_class("caption"); status_detail.add_css_class("dim-label"); let status_text = gtk4::Box::new(gtk4::Orientation::Vertical, 0); status_text.set_hexpand(true); status_text.append(&status_title); status_text.append(&status_detail); let status_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); status_content.append(&status_spinner); status_content.append(&status_icon); status_content.append(&status_text); let status_button = gtk4::Button::builder() .child(&status_content) .tooltip_text(gettext("Show sync status")) .build(); status_button.add_css_class("flat"); status_button.add_css_class("sidebar-status"); let quota_bar = gtk4::ProgressBar::new(); let quota_label = gtk4::Label::builder() .halign(gtk4::Align::Start) .ellipsize(gtk4::pango::EllipsizeMode::End) .build(); quota_label.add_css_class("caption"); quota_label.add_css_class("dim-label"); let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); quota_box.add_css_class("sidebar-quota"); quota_box.set_margin_start(8); quota_box.set_margin_end(8); quota_box.set_visible(false); quota_box.append("a_bar); quota_box.append("a_label); let avatar = adw::Avatar::new(28, None, true); let account_name = gtk4::Label::builder() .halign(gtk4::Align::Start) .hexpand(true) .ellipsize(gtk4::pango::EllipsizeMode::Middle) .build(); let account_menu = gio::Menu::new(); account_menu.append(Some(&gettext("Preferences")), Some("win.preferences")); let sign_out = gio::Menu::new(); sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out")); account_menu.append_section(None, &sign_out); let account_button = gtk4::MenuButton::builder() .icon_name("view-more-symbolic") .tooltip_text(gettext("Account")) .menu_model(&account_menu) .valign(gtk4::Align::Center) .build(); account_button.add_css_class("flat"); let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); account.add_css_class("sidebar-account"); account.append(&avatar); account.append(&account_name); account.append(&account_button); let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10); footer.add_css_class("sidebar-footer"); footer.append(&status_button); footer.append("a_box); footer.append(&account); MainWidgets { prefs, footer, status_button, account_name, avatar, status_icon, status_spinner, status_title, status_detail, quota_box, quota_bar, quota_label, cache_bar, cache_label, pins_group, autostart_row, budget_row, upload_limit_row, download_limit_row, purge_button, mountpoint_row, mountpoint_button, theme_row, online_map_row, tray_row, language_row, } } /// The Preferences language picker. Row 0 follows the system locale; the rest /// are [`i18n::LANGUAGES`] in order, under their own names. fn build_language_row() -> adw::ComboRow { let names = gtk4::StringList::new(&[]); names.append(&gettext("System Default")); for (_, name) in i18n::LANGUAGES { names.append(name); } adw::ComboRow::builder() .title(gettext("Language")) .subtitle(gettext("Takes effect the next time Proton Drive starts")) .model(&names) .build() } /// The picker row for a saved language code; unknown codes show as the system /// default, which is also how [`i18n::init`] treats them. pub(crate) fn language_index(code: Option<&str>) -> u32 { code.and_then(|code| i18n::LANGUAGES.iter().position(|(known, _)| *known == code)) .map_or(0, |at| at as u32 + 1) } /// The language code a picker row stands for; `None` is the system default. pub(crate) fn language_at(index: u32) -> Option<&'static str> { let at = usize::try_from(index).ok()?.checked_sub(1)?; i18n::LANGUAGES.get(at).map(|(code, _)| *code) } /// The Sync page's live-transfers group, hidden until the refresh loop sees an /// in-flight transfer from [`Request::GetQueueStatus`]. pub(crate) fn build_transfers_group() -> adw::PreferencesGroup { adw::PreferencesGroup::builder() .title(gettext("Transfers")) .description(gettext("Files moving to and from Proton Drive.")) .visible(false) .build() } /// How many pins the Storage preferences list before collapsing the rest behind a /// "Show all" row. Enough to recognise the list at a glance; short enough that /// the groups below it stay reachable. pub(crate) const PINS_COLLAPSED: usize = 6; /// How long the cache-budget editor waits after the last change before applying /// it, so a run of `+` clicks is one request rather than one per click. pub(crate) const BUDGET_DEBOUNCE: Duration = Duration::from_millis(600); /// Bytes per GiB, for the cache-budget editor's unit conversion. pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0; /// Bytes per MiB, for the bandwidth editors' unit conversion. pub(crate) const MIB: f64 = 1024.0 * 1024.0; /// Wire the Preferences controls: the cache-budget editor, the purge button, the /// start-on-login switch, the mountpoint chooser and the theme toggle. Initial widget state is /// read once from config / systemd here (the refresh loop owns only the live /// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the /// programmatic populate so the change handlers don't fire on it. pub(crate) fn wire_settings( ui: &Rc, purge_button: >k4::Button, mountpoint_button: >k4::Button, ) { let config = ui.dirs.load_config(); // Populate from persisted config + the systemd unit state, suppressed. ui.status.settings_suppress.set(true); ui.status .budget_row .set_value(config.resolved_cache_budget() as f64 / GIB); ui.status .upload_limit_row .set_value(config.upload_limit.unwrap_or(0) as f64 / MIB); ui.status .download_limit_row .set_value(config.download_limit.unwrap_or(0) as f64 / MIB); ui.status .mountpoint_row .set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string()); // systemctl can take a while, so ask off the main thread and fill the // switch in when it answers, without firing its change handler. let ui_auto = ui.clone(); glib::spawn_future_local(async move { let enabled = gio::spawn_blocking(service::is_enabled) .await .unwrap_or(false); let suppress = ui_auto.status.settings_suppress.replace(true); ui_auto.status.autostart_row.set_active(enabled); ui_auto.status.settings_suppress.set(suppress); }); ui.status .theme_row .set_active(config.proton_theme.unwrap_or(false)); ui.status.online_map_row.set_active(config.online_map); ui.status.tray_row.set_active(!config.tray_hidden); ui.status .language_row .set_selected(language_index(config.language.as_deref())); ui.status.settings_suppress.set(false); // Cache budget: a user edit applies the new soft cap on the daemon (which // also persists it to config). 0 GiB = unlimited. let ui_budget = ui.clone(); ui.status.budget_row.connect_value_notify(move |row| { if ui_budget.status.settings_suppress.get() { return; } // Replace any pending apply, so only the value the user stops on is sent. if let Some(src) = ui_budget.status.budget_source.borrow_mut().take() { src.remove(); } let bytes = (row.value() * GIB).round() as u64; let ui_fire = ui_budget.clone(); let src = glib::timeout_add_local_once(BUDGET_DEBOUNCE, move || { ui_fire.status.budget_source.borrow_mut().take(); settings_request( &ui_fire, Request::SetCacheBudget { bytes }, gettext("Cache budget updated"), gettext("Couldn't set cache budget"), ); }); *ui_budget.status.budget_source.borrow_mut() = Some(src); }); // Bandwidth: either editor sends both caps, since the daemon sets them as a // pair. Debounced like the budget. for row in [&ui.status.upload_limit_row, &ui.status.download_limit_row] { let ui_limit = ui.clone(); row.connect_value_notify(move |_| { if ui_limit.status.settings_suppress.get() { return; } if let Some(src) = ui_limit.status.limit_source.borrow_mut().take() { src.remove(); } let ui_fire = ui_limit.clone(); let src = glib::timeout_add_local_once(BUDGET_DEBOUNCE, move || { ui_fire.status.limit_source.borrow_mut().take(); let upload = (ui_fire.status.upload_limit_row.value() * MIB).round() as u64; let download = (ui_fire.status.download_limit_row.value() * MIB).round() as u64; settings_request( &ui_fire, Request::SetBandwidthLimits { upload, download }, gettext("Bandwidth limits updated"), gettext("Couldn't set bandwidth limits"), ); }); *ui_limit.status.limit_source.borrow_mut() = Some(src); }); } // Purge: confirm, then drop all unpinned cached content via the daemon. let ui_purge = ui.clone(); purge_button.connect_clicked(move |_| { let ui = ui_purge.clone(); let dialog = adw::AlertDialog::builder() .heading(gettext("Clear Cache?")) .body(gettext("Cached copies are removed from this computer and download again when you open them. Files kept available offline stay.")) .build(); dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("purge", &gettext("Clear Cache")); dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive); dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); dialog.connect_response(None, move |_, resp| { if resp == "purge" { settings_request( &ui, Request::PurgeCache, gettext("Cache cleared"), gettext("Couldn't clear the cache"), ); } }); dialog.present(Some(&ui_purge.status.prefs)); }); // Start on login: enable/disable the systemd unit without stopping a live // mount (the user can disconnect separately). let ui_auto = ui.clone(); ui.status.autostart_row.connect_active_notify(move |row| { if ui_auto.status.settings_suppress.get() { return; } let enable = row.is_active(); gio::spawn_blocking(move || { if enable { service::enable(); } else { service::disable(); } }); }); let ui_mp = ui.clone(); mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp)); // Theme: saved with the rest of the config, applied right away. let ui_theme = ui.clone(); ui.status.theme_row.connect_active_notify(move |row| { if ui_theme.status.settings_suppress.get() { return; } let on = row.is_active(); set_proton_theme(on); // Folders swap their icon with the theme; have every row bound again. let rows = ui_theme.browser.model.n_items(); ui_theme.browser.model.items_changed(0, rows, rows); let mut config = ui_theme.dirs.load_config(); config.proton_theme = Some(on); if let Err(e) = ui_theme.dirs.save_config(&config) { toast_error( &ui_theme, &gettext("Couldn't save the theme"), &e.to_string(), ); } }); // Street map: saved, and the Places map switches over right away. let ui_map = ui.clone(); ui.status.online_map_row.connect_active_notify(move |row| { if ui_map.status.settings_suppress.get() { return; } let on = row.is_active(); ui_map .gallery .places_map .set_online(on, ui_map.dirs.cache_dir()); let mut config = ui_map.dirs.load_config(); config.online_map = on; if let Err(e) = ui_map.dirs.save_config(&config) { toast_error( &ui_map, &gettext("Couldn't save the map setting"), &e.to_string(), ); } }); // Tray icon: the tray reads the same flag at start, so hiding it here also // keeps the login autostart from bringing it back. let ui_tray = ui.clone(); ui.status.tray_row.connect_active_notify(move |row| { if ui_tray.status.settings_suppress.get() { return; } let show = row.is_active(); let mut config = ui_tray.dirs.load_config(); config.tray_hidden = !show; if let Err(e) = ui_tray.dirs.save_config(&config) { toast_error( &ui_tray, &gettext("Couldn't save the tray setting"), &e.to_string(), ); return; } if show { spawn_tray(); } else { pdfs_core::tray::quit(&ui_tray.dirs); } }); // Language: read once at start by the app, the tray and the prompt, so a // change applies on the next launch. let ui_language = ui.clone(); ui.status.language_row.connect_selected_notify(move |row| { if ui_language.status.settings_suppress.get() { return; } let mut config = ui_language.dirs.load_config(); config.language = language_at(row.selected()).map(str::to_string); if let Err(e) = ui_language.dirs.save_config(&config) { toast_error( &ui_language, &gettext("Couldn't save the language"), &e.to_string(), ); return; } toast( &ui_language, &gettext("Restart Proton Drive to use the new language"), ); }); } /// Run a settings control-socket round-trip (budget / purge) on a worker thread, /// confirming with `done` or reporting the daemon's error under `failed`. Unlike /// [`run_mutation`] there's no browser reload; the next refresh tick repaints the /// cache read-out. pub(crate) fn settings_request(ui: &Rc, req: Request, done: String, failed: String) { ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), req); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); match result { Ok(Ok(Response::Ok { .. })) => toast(&ui, &done), Ok(Ok(Response::Error { message, kind })) => { toast_failure(&ui, &failed, &message, kind) } _ => toast_error( &ui, &failed, &gettext("The Proton Drive service didn't respond."), ), } }); } /// Prompt for a new mountpoint folder, persist it to config, and offer to restart /// the mount service so the daemon picks it up. pub(crate) fn prompt_mountpoint(ui: &Rc) { let win = ui_window(ui); let dialog = gtk4::FileDialog::builder() .title(gettext("Choose Proton Drive Folder")) .build(); let ui = ui.clone(); dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| { let Ok(folder) = res else { return }; let Some(path) = folder.path() else { return }; let path_str = path.display().to_string(); // Persist the choice to config so the next mount uses it. let mut config = ui.dirs.load_config(); config.mountpoint = Some(path_str.clone()); if let Err(e) = ui.dirs.save_config(&config) { toast_error(&ui, &gettext("Couldn't save the folder"), &e.to_string()); return; } ui.status.mountpoint_row.set_subtitle(&path_str); // The Locations page shows the same path as a row title; its cached rows // are now stale whether or not it is the visible page. ui.locations.loaded_at.set(None); if ui.stack.visible_child_name().as_deref() == Some("locations") { load_locations(&ui); } // The daemon only reads the mountpoint at mount time, so offer a restart. let confirm = adw::AlertDialog::builder() .heading(gettext("Restart Proton Drive?")) // Translators: {path} is the folder the drive is mounted at. .body(gettext_f( "My files now appears in “{path}” after Proton Drive restarts.", &[("path", &path_str)], )) .build(); confirm.add_response("later", &gettext("Later")); confirm.add_response("restart", &gettext("Restart Now")); confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested); confirm.set_default_response(Some("restart")); confirm.set_close_response("later"); let ui_restart = ui.clone(); confirm.connect_response(None, move |_, resp| { if resp == "restart" { restart_service_then(&ui_restart, |_| {}); } }); confirm.present(ui_window(&ui).as_ref()); }); } /// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] / /// [`gallery_unreachable`] when the mount is down): restart the systemd unit and /// reload the page. pub(crate) fn wire_retry(ui: &Rc) { let ui_browser = ui.clone(); ui.browser .retry .clone() .connect_clicked(move |_| restart_service_then(&ui_browser, load_browser)); let ui_gallery = ui.clone(); ui.gallery .retry .clone() .connect_clicked(move |_| restart_service_then(&ui_gallery, |ui| load_gallery(ui, false))); let ui_trash = ui.clone(); ui.trash .retry .clone() .connect_clicked(move |_| restart_service_then(&ui_trash, load_trash)); } /// Repaint the window from the cached login identity, then kick an async mount- /// status fetch. Runs on the 2s tick: the identity check is instant (no keyring), /// and the status round-trip is offloaded to a worker so the main loop never /// blocks on a slow or wedged daemon. pub(crate) fn refresh(ui: &Rc) { // Login identity decides which page is shown. Read the cached session — set // at startup and on login/logout — never the keyring. { let session = ui.session.borrow(); match session.as_ref() { Some(s) => { // Only pull the user onto a destination when they're sitting on the // login page; otherwise leave whichever page they navigated to. if ui.stack.visible_child_name().as_deref() == Some("login") { ui.stack.set_visible_child_name("browser"); } ui.nav.set_collapsed(false); if ui.status.account_name.label() != s.username { ui.status.account_name.set_label(&s.username); ui.status.account_name.set_tooltip_text(Some(&s.username)); ui.status.avatar.set_text(Some(&s.username)); } } None => { ui.stack.set_visible_child_name("login"); // Collapsed + showing content = the login page owns the window and // no destination is reachable without a session. ui.nav.set_collapsed(true); ui.nav.set_show_content(true); return; } } } refresh_status(ui); refresh_transfers(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. // A Takeout import outlives the page that started it, so it is polled // wherever the user has navigated to — otherwise leaving the page means // never being told it finished. ui.gallery .import_banner .set_revealed(ui.takeout.running.get()); if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() { refresh_takeout(ui); } // The quota sits in the sidebar footer, visible on every page; its TTL // keeps this from asking more than once a minute. refresh_quota(ui); match ui.stack.visible_child_name().as_deref() { Some("locations") => { refresh_locations(ui); refresh_queue(ui); refresh_conflicts(ui, false); } Some("activity") => refresh_activity(ui), _ => {} } } /// How long a quota reading stays fresh. Account storage barely moves, so the /// active-page 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 /// sidebar footer. A failed fetch leaves the last good reading in place. pub(crate) fn refresh_quota(ui: &Rc) { 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())); paint_account_quota(&ui, max_space, used_space); ui.status.quota_box.set_visible(true); } }); } fn paint_account_quota(ui: &Rc, max_space: i64, used_space: i64) { let (fraction, text) = quota_display(max_space, used_space); ui.status.quota_bar.set_fraction(fraction); ui.status.quota_label.set_text(&text); } fn quota_display(max_space: i64, used_space: i64) -> (f64, String) { let used = used_space.max(0) as u64; if max_space <= 0 { return ( 0.0, // Translators: {size} is an amount of storage, such as "1.2 GiB". gettext_f("{size} used", &[("size", &human_bytes(used))]), ); } let total = max_space as u64; let fraction = (used as f64 / total as f64).clamp(0.0, 1.0); let pct = (fraction * 100.0).round() as u64; ( fraction, // Translators: account storage. {used} and {total} are sizes such as // "1.2 GiB"; {percent} is a whole number, followed by the percent sign. gettext_f( "{used} of {total} used ({percent}%)", &[ ("used", &human_bytes(used)), ("total", &human_bytes(total)), ("percent", &pct.to_string()), ], ), ) } /// 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. /// /// The gating is the point — without it, New Folder / Upload / the details pane's /// actions stay clickable while the mount is down and each click buys a round-trip /// that can only fail. A greyed control says so up front. pub(crate) fn set_mounted(ui: &Rc, mounted: bool) { *ui.mounted.borrow_mut() = mounted; sync_mounted_actions(ui, mounted); ui.gallery.upload.set_sensitive(mounted); ui.details.details.pin_row.set_sensitive(mounted); ui.details.details.rename_button.set_sensitive(mounted); ui.details.details.trash_button.set_sensitive(mounted); ui.details.details.open_button.set_sensitive(mounted); ui.details.details.versions_button.set_sensitive(mounted); // Only notify on a real edge, and never for the first reading: at startup the // service is usually still coming up, and "disconnected" would be a lie. if ui.status.notified_mounted.get() == Some(mounted) { return; } let first = ui.status.notified_mounted.replace(Some(mounted)).is_none(); if first { return; } if mounted { notify( "mount-state", &gettext("Proton Drive connected"), &gettext("Your files are available."), ); } else { notify( "mount-state", &gettext("Proton Drive disconnected"), &gettext("The Proton Drive service stopped. Files aren't available until it restarts."), ); } } /// 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. pub(crate) fn refresh_transfers(ui: &Rc) { if ui.status.transfers_inflight.get() { return; } ui.status.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.status.transfers_inflight.set(false); match result { Ok(Ok(Response::Transfers { items, jobs })) => repaint_transfers(&ui, &items, &jobs), // 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 a work snapshot: the daemon's jobs (scans, /// batch counts, the local index) above the files moving under them. Rebuilds the /// rows only when the set changes (count differs); on the common steady tick it /// updates each bar's fraction and the label in place, so progress animates /// without flicker. Hides the whole group when the daemon is idle. pub(crate) fn repaint_transfers(ui: &Rc, items: &[TransferItem], jobs: &[JobItem]) { let lines: Vec = jobs .iter() .map(job_line) .chain(items.iter().map(transfer_line)) .collect(); // The wire carries in-flight transfers only, with no completion event: the // count falling to zero is what "the sync finished" looks like from here. // Jobs are deliberately not counted — a bulk upload retires its scan job and // starts its upload job mid-flight, which is not a thing finishing. // The Import page reads its progress off the same snapshot rather than // polling the daemon a second time for numbers already on the wire. takeout_progress(ui, jobs); let previous = ui.status.active_transfers.replace(items.len()); if !items.is_empty() { let mut batch = ui.status.transfer_batch.borrow_mut(); let batch = batch.get_or_insert_with(|| TransferBatch { started: Instant::now(), names: HashSet::new(), }); batch .names .extend(items.iter().map(|item| item.name.clone())); } if items.is_empty() && previous > 0 { // Nobody needs a notification for what they are watching happen. let watching = ui_window(ui).is_some_and(|window| window.is_active()); if let Some(batch) = ui.status.transfer_batch.take() && !watching && (batch.names.len() >= SYNC_NOTIFY_MIN_FILES || batch.started.elapsed() >= SYNC_NOTIFY_MIN_DURATION) { notify( "sync-complete", &gettext("Sync complete"), &ngettext_f( "{n} file finished transferring.", "{n} files finished transferring.", batch.names.len() as u64, &[], ), ); } // A just-finished batch may have added files (bulk upload) the current // listing doesn't show yet; refresh whichever listing is on screen. reload_listing(ui); } if lines.is_empty() { if !ui.status.transfer_rows.borrow().is_empty() { for tr in ui.status.transfer_rows.borrow_mut().drain(..) { ui.status.transfers_group.remove(&tr.row); } } ui.status.transfers_group.set_visible(false); return; } ui.status.transfers_group.set_visible(true); // Rebuild rows only when the count changes; otherwise reuse them in place. if ui.status.transfer_rows.borrow().len() != lines.len() { for tr in ui.status.transfer_rows.borrow_mut().drain(..) { ui.status.transfers_group.remove(&tr.row); } for _ in &lines { 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.status.transfers_group.add(&row); ui.status .transfer_rows .borrow_mut() .push(TransferRow { row, label, bar }); } } for (line, tr) in lines.iter().zip(ui.status.transfer_rows.borrow().iter()) { tr.label.set_text(&line.text); match line.fraction { Some(f) => tr.bar.set_fraction(f), // No total to divide by: pulse so the bar still reads as "working". None => tr.bar.pulse(), } } } /// One Activity row for a daemon job: its title, plus whatever it can say about /// where it is — a count when it has one, else what it is currently chewing on. pub(crate) fn job_line(j: &JobItem) -> ActivityLine { let done = j.done.to_string(); let total = j.total.to_string(); let args: [(&str, &str); 4] = [ ("title", &j.title), ("detail", &j.detail), ("done", &done), ("total", &total), ]; let text = match (j.total > 0, j.detail.is_empty()) { // Translators: a background job's progress. {title} is the job, such as // "Scanning Photos"; {done} and {total} are counts. (true, true) => gettext_f("{title} — {done} of {total}", &args), // Translators: a background job's progress. {title} is the job, {detail} // what it is working on; {done} and {total} are counts. (true, false) => gettext_f("{title} — {detail} ({done} of {total})", &args), // Translators: a background job still running. {title} is the job. (false, true) => gettext_f("{title}…", &args), // Translators: a background job still running. {title} is the job, // {detail} what it is working on. (false, false) => gettext_f("{title} — {detail}…", &args), }; ActivityLine { text, fraction: (j.total > 0).then(|| (j.done as f64 / j.total as f64).min(1.0)), } } /// One Activity row for a file in flight: which way it's going, how far, how fast. pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine { let arrow = match t.direction { TransferDirection::Download => "↓", TransferDirection::Upload => "↑", }; let done = human_bytes(t.bytes_completed); let total = human_bytes(t.bytes_total); let speed = human_bytes(t.speed_bytes_sec); let args: [(&str, &str); 5] = [ ("arrow", arrow), ("name", &t.name), ("done", &done), ("total", &total), ("speed", &speed), ]; if t.bytes_total == 0 { ActivityLine { // Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the // file name, {done} the size moved so far and {speed} a size such as // "1.2 MiB"; "/s" means per second. text: gettext_f("{arrow} {name} — {done} ({speed}/s)", &args), fraction: None, } } else { ActivityLine { // Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the // file name, {done} and {total} sizes and {speed} a size such as // "1.2 MiB"; "/s" means per second. text: gettext_f("{arrow} {name} — {done} of {total} ({speed}/s)", &args), fraction: Some((t.bytes_completed as f64 / t.bytes_total as f64).min(1.0)), } } } /// Fetch mount status + cache stats from the daemon on a worker thread and repaint /// the mount line, cache bar and pin list on the reply. The daemon owns the cache /// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the /// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so /// the tick can't stack threads on a stalled daemon. pub(crate) fn refresh_status(ui: &Rc) { if ui.status.status_inflight.get() { return; } ui.status.status_inflight.set(true); let rx = spawn_request(ui.dirs.control_socket(), Request::Status); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.status.status_inflight.set(false); match result { Ok(Ok(Response::Status { mountpoint, used, budget, pins, online, pending_uploads, pending_changes, failing_ops, failing_error, paused, paused_until, .. })) => { set_mounted(&ui, true); // The queue is the more useful thing to say when it has anything // in it: it is why a file that looks saved is not on the remote // yet, and offline is usually the reason it is still queued. let queued = i18n::pending_summary(pending_uploads, pending_changes); let state = if paused { SyncState::Paused { until: paused_until, queued, } } else if failing_ops > 0 { SyncState::Attention { count: failing_ops, error: failing_error, } } else if !online { SyncState::Offline { queued } } else if let Some(queued) = queued { SyncState::Syncing { queued } } else { SyncState::UpToDate { mountpoint } }; paint_sync_status(&ui, state); let fraction = if budget == 0 { 0.0 } else { (used as f64 / budget as f64).min(1.0) }; ui.status.cache_bar.set_fraction(fraction); // A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B // used" reads as a broken read-out, and there is no fraction to // draw against no limit. ui.status.cache_label.set_text(&if budget == 0 { // Translators: {used} is a size such as "1.2 GiB". gettext_f( "{used} cached — no limit set", &[("used", &human_bytes(used))], ) } else { // Translators: {used} and {limit} are sizes such as "1.2 GiB". gettext_f( "{used} of {limit} used", &[ ("used", &human_bytes(used)), ("limit", &human_bytes(budget)), ], ) }); repaint_pins(&ui, &pins, true); } // The daemon is up but stuck. Say so, and keep the mount state: // announcing "disconnected" for a slow reply would be wrong, and // the next tick asks again. Ok(Err(e)) if e.is_timeout() => paint_sync_status(&ui, SyncState::NotResponding), // Daemon unreachable (still starting, or down): report not-mounted and // grey out the unpin buttons in place, but leave the last-known pin // rows and cache read-out so the page doesn't flicker on a blip. _ => { set_mounted(&ui, false); paint_sync_status(&ui, SyncState::Disconnected); for r in ui.status.pin_rows.borrow().iter() { if let Some(b) = &r.unpin { b.set_sensitive(false); } } } } }); } /// What the sidebar's status strip reports, most urgent first. pub(crate) enum SyncState { /// The user paused syncing; nothing goes up until it resumes. Outranks /// everything else, because it is the reason for everything else. Paused { /// Unix second the pause ends by itself; `None` until resumed. until: Option, queued: Option, }, /// Operations keep failing; the user may have to act. Attention { count: u64, error: Option, }, /// No connection to Proton: cached files only, changes wait. Offline { queued: Option, }, /// Local changes are on their way up. Syncing { queued: String, }, UpToDate { mountpoint: String, }, /// No mount daemon answered: still starting, or the service is down. Disconnected, /// The daemon is running but didn't answer in time. NotResponding, } /// Paint the sidebar status strip and the Sync page's status card: icon, /// one-line state, and a detail line. fn paint_sync_status(ui: &Rc, state: SyncState) { let paused = matches!(state, SyncState::Paused { .. }); let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding); let (icon, class, title, detail) = match state { SyncState::Paused { until, queued } => ( "media-playback-pause-symbolic", Some("warning"), gettext("Sync paused"), Some(match (until, queued) { // Translators: {queued} says what is waiting, such as "3 changes // waiting"; {time} is when syncing resumes, such as "at 14:30". (Some(until), Some(queued)) => gettext_f( "{queued} · resumes {time}", &[("queued", &queued), ("time", &clock_time(until))], ), // Translators: {time} is when syncing resumes, such as "at 14:30" // or "Tue 09:00". (Some(until), None) => gettext_f("Resumes {time}", &[("time", &clock_time(until))]), (None, Some(queued)) => queued, (None, None) => gettext("Until you resume"), }), ), SyncState::Attention { count, error } => ( "dialog-warning-symbolic", Some("error"), ngettext_f( "{n} change needs attention", "{n} changes need attention", count, &[], ), error, ), SyncState::Offline { queued } => ( "network-offline-symbolic", Some("warning"), pgettext("state", "Offline"), Some(queued.unwrap_or_else(|| gettext("Cached files only"))), ), SyncState::Syncing { queued } => ( "pdfs-sync-symbolic", None, pgettext("state", "Syncing"), Some(queued), ), SyncState::UpToDate { mountpoint } => ( "pdfs-synced-symbolic", Some("success"), gettext("Up to date"), Some(mountpoint), ), SyncState::Disconnected => ( "network-offline-symbolic", Some("warning"), gettext("Not connected"), Some(gettext("Proton Drive isn't running")), ), SyncState::NotResponding => ( "dialog-warning-symbolic", Some("warning"), gettext("Not responding"), Some(gettext("Proton Drive is taking too long to answer")), ), }; let image = &ui.status.status_icon; image.set_icon_name(Some(icon)); image.set_visible(true); ui.status.status_spinner.set_visible(false); ui.status.status_spinner.set_spinning(false); for c in ["success", "warning", "error"] { image.remove_css_class(c); } if let Some(class) = class { image.add_css_class(class); } ui.status.status_title.set_label(&title); ui.status.status_detail.set_visible(detail.is_some()); ui.status .status_detail .set_label(detail.as_deref().unwrap_or_default()); ui.status.status_detail.set_tooltip_text(detail.as_deref()); paint_sync_card( ui, icon, class, &title, detail.as_deref(), paused, connected, ); } /// "at 14:30" today, "Tue 09:00" within the week, a date beyond that. pub(crate) fn clock_time(unix: i64) -> String { let (Ok(at), Ok(now)) = ( glib::DateTime::from_unix_local(unix), glib::DateTime::now_local(), ) else { // Translators: stands in for a time that could not be read, as in // "Resumes later". return gettext("later"); }; let format = if at.ymd() == now.ymd() { // Translators: strftime format for a time later today, such as "at 14:30". gettext("at %H:%M") } else if at.difference(&now).as_seconds() < 6 * 86_400 { // Translators: strftime format for a time within the next week, such as // "Tue 09:00". gettext("%a %H:%M") } else { // Translators: strftime format for a date further ahead, such as "23 Sep". gettext("%e %b") }; at.format(&format) .map(|s| s.trim().to_string()) .unwrap_or_else(|_| gettext("later")) } /// Render the pins group from `pins`, with the unpin buttons enabled only while a /// mount daemon is running (`mounted`). Diffs against the last batch by path: when /// the set is unchanged (the common case on the 2s tick) it only flips the unpin /// buttons' `sensitive` flag, avoiding the rebuild that used to flicker the list /// and drop scroll/selection every tick. pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: bool) { let desired: Vec = pins.iter().map(|p| p.path.clone()).collect(); if ui.status.pins_state.borrow().as_ref() == Some(&desired) { for r in ui.status.pin_rows.borrow().iter() { if let Some(b) = &r.unpin { b.set_sensitive(mounted); } } return; } for pr in ui.status.pin_rows.borrow_mut().drain(..) { ui.status.pins_group.remove(&pr.row); } *ui.status.pins_state.borrow_mut() = Some(desired); if pins.is_empty() { let row = adw::ActionRow::builder() .title(gettext("No files kept offline")) .subtitle(gettext( "Right-click a file and choose “Make available offline”.", )) .build(); ui.status.pins_group.add(&row); ui.status .pin_rows .borrow_mut() .push(PinRow { row, unpin: None }); return; } // Only the first page of pins is rendered; the rest sit behind the row // below, so a device with hundreds of pins doesn't bury the groups after it. let expanded = ui.status.pins_expanded.get(); let shown = if expanded { pins.len() } else { pins.len().min(PINS_COLLAPSED) }; for pin in &pins[..shown] { let name = Path::new(&pin.path) .file_name() .and_then(|n| n.to_str()) .unwrap_or(&pin.path) .to_string(); let row = adw::ActionRow::builder() .title(&name) .subtitle(&pin.path) .build(); let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic"); row.add_prefix(&icon); let unpin = gtk4::Button::builder() .icon_name("pdfs-online-only-symbolic") .valign(gtk4::Align::Center) .tooltip_text(gettext("Make online only")) .sensitive(mounted) .build(); unpin.add_css_class("flat"); let ui_btn = ui.clone(); let path = pin.path.clone(); unpin.connect_clicked(move |_| { let rx = spawn_request( ui_btn.dirs.control_socket(), Request::Unpin { path: path.clone() }, ); let ui = ui_btn.clone(); glib::spawn_future_local(async move { match rx.recv().await { Ok(Ok(Response::Error { message, kind })) => toast_failure( &ui, &gettext("Couldn't make it online only"), &message, kind, ), Ok(Ok(_)) => refresh(&ui), _ => toast_error( &ui, &gettext("Couldn't make it online only"), &gettext("The Proton Drive service didn't respond."), ), } }); }); row.add_suffix(&unpin); ui.status.pins_group.add(&row); ui.status.pin_rows.borrow_mut().push(PinRow { row, unpin: Some(unpin), }); } if pins.len() > PINS_COLLAPSED { let row = adw::ActionRow::builder() .title(if expanded { gettext("Show fewer") } else { ngettext_f( "Show all {n} file", "Show all {n} files", pins.len() as u64, &[], ) }) .activatable(true) .build(); row.add_suffix(>k4::Image::from_icon_name(if expanded { "go-up-symbolic" } else { "go-down-symbolic" })); let ui_more = ui.clone(); row.connect_activated(move |_| { ui_more .status .pins_expanded .set(!ui_more.status.pins_expanded.get()); // The diff guard compares pin *paths*; the same paths render // differently now, so the baseline has to be dropped for the next // repaint to actually rebuild. *ui_more.status.pins_state.borrow_mut() = None; refresh(&ui_more); }); ui.status.pins_group.add(&row); ui.status .pin_rows .borrow_mut() .push(PinRow { row, unpin: None }); } } #[cfg(test)] mod tests { use super::quota_display; #[test] fn quota_display_reports_used_total_and_percentage() { let gib = 1024_i64.pow(3); let (fraction, text) = quota_display(4 * gib, gib); assert!((fraction - 0.25).abs() < f64::EPSILON); assert_eq!(text, "1.0 GiB of 4.0 GiB used (25%)"); } #[test] fn quota_display_clamps_bad_api_values() { assert_eq!(quota_display(0, -1), (0.0, "0 bytes used".to_string())); assert_eq!(quota_display(100, 150).0, 1.0); } }