mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Preferences gets a switch that asks GitHub once a day for the latest release and shows a banner over the sidebar when it is newer. Off by default. Packages whose package manager brings updates install the marker share/proton-drive-linux/managed-updates, which hides the switch. Refs #42
1710 lines
66 KiB
Rust
1710 lines
66 KiB
Rust
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<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.
|
|
pub(crate) transfers_inflight: Cell<bool>,
|
|
/// 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<Vec<TransferRow>>,
|
|
/// 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: 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<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,
|
|
/// 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,
|
|
/// "Check for updates" toggle, not shown where a package manager brings
|
|
/// updates. Guarded by [`Self::settings_suppress`].
|
|
pub(crate) update_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<bool>,
|
|
/// 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<Option<glib::SourceId>>,
|
|
/// Pending debounce for the bandwidth editors, as for the cache budget.
|
|
pub(crate) limit_source: RefCell<Option<glib::SourceId>>,
|
|
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<bool>,
|
|
/// 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<Vec<PinRow>>,
|
|
/// 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<Option<Vec<String>>>,
|
|
/// 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<Option<bool>>,
|
|
/// 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<usize>,
|
|
/// 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<Option<TransferBatch>>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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: gtk4::ListBoxRow,
|
|
/// The unpin button, absent on the placeholder row.
|
|
pub(crate) unpin: Option<gtk4::Button>,
|
|
}
|
|
|
|
/// 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<f64>,
|
|
}
|
|
|
|
/// 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: 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) update_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 updates_group = adw::PreferencesGroup::builder()
|
|
.title(gettext("Updates"))
|
|
.build();
|
|
let update_row = adw::SwitchRow::builder()
|
|
.title(gettext("Check for updates"))
|
|
.subtitle(gettext(
|
|
"Ask GitHub once a day whether a new version is out. GitHub learns your IP address and which version you use.",
|
|
))
|
|
.build();
|
|
updates_group.add(&update_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);
|
|
// A package manager brings new versions, so there is nothing to tell.
|
|
if !updates_managed_here() {
|
|
general.add(&updates_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 = spinner();
|
|
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();
|
|
// The app's one menu, on the account row the way Proton's own apps keep it:
|
|
// app-wide entries first, the account's last. The whole row opens it, and
|
|
// F10 too, as the primary menu.
|
|
let app_section = gio::Menu::new();
|
|
app_section.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
|
app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
|
app_section.append(
|
|
Some(&gettext("About Proton Drive for Linux")),
|
|
Some("win.about"),
|
|
);
|
|
let account_section = gio::Menu::new();
|
|
account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
|
let account_menu = gio::Menu::new();
|
|
account_menu.append_section(None, &app_section);
|
|
account_menu.append_section(None, &account_section);
|
|
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
|
row.append(&avatar);
|
|
row.append(&account_name);
|
|
row.append(>k4::Image::from_icon_name("view-more-symbolic"));
|
|
let account = gtk4::MenuButton::builder()
|
|
.child(&row)
|
|
.tooltip_text(gettext("Main menu"))
|
|
.menu_model(&account_menu)
|
|
.direction(gtk4::ArrowType::Up)
|
|
.primary(true)
|
|
.build();
|
|
account.add_css_class("flat");
|
|
account.add_css_class("sidebar-account");
|
|
|
|
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,
|
|
update_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<Ui>,
|
|
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.update_row.set_active(config.update_check);
|
|
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.
|
|
for list in file_lists(&ui_theme) {
|
|
let rows = list.model.n_items();
|
|
list.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(),
|
|
);
|
|
}
|
|
});
|
|
|
|
// Update check: saved, and the first check runs right away if one is due.
|
|
let ui_update = ui.clone();
|
|
ui.status.update_row.connect_active_notify(move |row| {
|
|
if ui_update.status.settings_suppress.get() {
|
|
return;
|
|
}
|
|
let mut config = ui_update.dirs.load_config();
|
|
config.update_check = row.is_active();
|
|
if let Err(e) = ui_update.dirs.save_config(&config) {
|
|
toast_error(
|
|
&ui_update,
|
|
&gettext("Couldn't save the update setting"),
|
|
&e.to_string(),
|
|
);
|
|
return;
|
|
}
|
|
show_latest(&ui_update);
|
|
start_update_checks(&ui_update);
|
|
});
|
|
|
|
// 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<Ui>, 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<Ui>) {
|
|
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. Any other page
|
|
// reads it again on the next visit.
|
|
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<Ui>) {
|
|
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));
|
|
}
|
|
|
|
/// Show the sign-in page while a session is still stored, because Proton has
|
|
/// refused it. The sidebar stays, so the user can go back to cached files; a
|
|
/// login moves on to the files as from any sign-in ([`refresh`]).
|
|
pub(crate) fn show_login(ui: &Rc<Ui>) {
|
|
ui.stack.set_visible_child_name("login");
|
|
}
|
|
|
|
/// Repaint the window from the cached login identity, then ask the daemon for
|
|
/// its status and transfers. Runs at startup, after a login and after a service
|
|
/// restart; the daemon's event feed keeps both current in between. The identity
|
|
/// check is instant (no keyring), and the round-trips run on workers so the
|
|
/// main loop never blocks on a slow or wedged daemon.
|
|
pub(crate) fn refresh(ui: &Rc<Ui>) {
|
|
// 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(ui.narrow.get());
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
offer_unlock(ui);
|
|
refresh_status(ui);
|
|
refresh_transfers(ui);
|
|
}
|
|
|
|
/// How long a quota reading stays fresh. Account storage barely moves, so a
|
|
/// status change reads it again only this often.
|
|
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<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()));
|
|
paint_account_quota(&ui, max_space, used_space);
|
|
ui.status.quota_box.set_visible(true);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn paint_account_quota(ui: &Rc<Ui>, 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<Ui>, mounted: bool) {
|
|
*ui.mounted.borrow_mut() = mounted;
|
|
sync_mounted_actions(ui, mounted);
|
|
ui.gallery
|
|
.upload
|
|
.set_sensitive(mounted && ui.gallery.library.get());
|
|
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."),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Ask the daemon for its in-flight transfers on a worker thread and repaint the
|
|
/// Activity group. Independently inflight-guarded from [`refresh_status`] so the
|
|
/// two requests don't gate each other; the event feed pushes later snapshots. The group hides itself
|
|
/// when nothing is moving, so an idle account shows no Activity section.
|
|
pub(crate) fn refresh_transfers(ui: &Rc<Ui>) {
|
|
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<Ui>, items: &[TransferItem], jobs: &[JobItem]) {
|
|
let lines: Vec<ActivityLine> = 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);
|
|
// Ellipsized so a long file name cannot widen the page.
|
|
let label = gtk4::Label::builder()
|
|
.halign(gtk4::Align::Start)
|
|
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
|
.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
|
|
/// a stalled daemon can't pile up threads.
|
|
///
|
|
/// The event feed pushes every later change through [`paint_status`]; this asks
|
|
/// only when the feed (re)connects or loses the daemon.
|
|
pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
|
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(status @ Response::Status { .. })) => paint_status(&ui, status),
|
|
// The daemon is up but stuck. Say so, and keep the mount state:
|
|
// announcing "disconnected" for a slow reply would be wrong, and
|
|
// the feed asks again when it reconnects.
|
|
Ok(Err(e)) if e.is_timeout() => paint_sync_status(&ui, SyncState::NotResponding),
|
|
_ => paint_disconnected(&ui),
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Paint a [`Response::Status`], fetched or pushed: the mount state, the sync
|
|
/// status, the cache bar and the pin list.
|
|
pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
|
let Response::Status {
|
|
mountpoint,
|
|
used,
|
|
budget,
|
|
pins,
|
|
online,
|
|
pending_uploads,
|
|
pending_changes,
|
|
failing_ops,
|
|
failing_error,
|
|
paused,
|
|
paused_until,
|
|
session_expired,
|
|
tokens_unsaved,
|
|
version,
|
|
..
|
|
} = status
|
|
else {
|
|
return;
|
|
};
|
|
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 session_expired {
|
|
SyncState::SessionExpired { queued }
|
|
} else if tokens_unsaved {
|
|
SyncState::TokensUnsaved
|
|
} else if pdfs_core::control::daemon_outdated(&version) {
|
|
SyncState::Outdated { running: version }
|
|
} else 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);
|
|
ui.locations.failing.set(failing_ops);
|
|
paint_sync_banner(ui);
|
|
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);
|
|
}
|
|
|
|
/// 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.
|
|
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
|
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 {
|
|
/// Proton refused the session: nothing reaches it until the user signs in
|
|
/// again. Outranks a pause, which it makes moot.
|
|
SessionExpired {
|
|
queued: Option<String>,
|
|
},
|
|
/// The keyring refuses the session's refreshed tokens. Sync goes on, but
|
|
/// a reboot before the keyring takes them signs the user out.
|
|
TokensUnsaved,
|
|
/// The daemon is an older release than this window: an upgrade replaced
|
|
/// the files but left the service running, with the old release's bugs,
|
|
/// until it restarts (#33). `running` is empty when it is too old to say.
|
|
Outdated {
|
|
running: String,
|
|
},
|
|
/// 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<i64>,
|
|
queued: Option<String>,
|
|
},
|
|
/// Operations keep failing; the user may have to act.
|
|
Attention {
|
|
count: u64,
|
|
error: Option<String>,
|
|
},
|
|
/// No connection to Proton: cached files only, changes wait.
|
|
Offline {
|
|
queued: Option<String>,
|
|
},
|
|
/// 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<Ui>, state: SyncState) {
|
|
let paused = matches!(state, SyncState::Paused { .. });
|
|
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
|
let expired = matches!(state, SyncState::SessionExpired { .. });
|
|
let outdated = matches!(state, SyncState::Outdated { .. });
|
|
let (icon, class, title, detail) = match state {
|
|
SyncState::SessionExpired { queued } => (
|
|
"dialog-password-symbolic",
|
|
Some("error"),
|
|
gettext("Session expired"),
|
|
Some(match queued {
|
|
// Translators: {queued} says what is waiting, such as "3 uploads
|
|
// queued".
|
|
Some(queued) => {
|
|
gettext_f("{queued} · sign in again to sync", &[("queued", &queued)])
|
|
}
|
|
None => gettext("Sign in again to sync"),
|
|
}),
|
|
),
|
|
SyncState::TokensUnsaved => (
|
|
"dialog-warning-symbolic",
|
|
Some("warning"),
|
|
gettext("Sign-in not saved"),
|
|
Some(gettext(
|
|
"Unlock your keyring, or the next reboot signs you out",
|
|
)),
|
|
),
|
|
SyncState::Outdated { running } => (
|
|
"dialog-warning-symbolic",
|
|
Some("warning"),
|
|
gettext("Restart to finish updating"),
|
|
Some(if running.is_empty() {
|
|
gettext("An older version of Proton Drive is still running")
|
|
} else {
|
|
// Translators: {version} is a release number such as "3.2.0".
|
|
gettext_f(
|
|
"Proton Drive {version} is still running",
|
|
&[("version", &running)],
|
|
)
|
|
}),
|
|
),
|
|
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);
|
|
for c in ["success", "warning", "error"] {
|
|
image.remove_css_class(c);
|
|
}
|
|
if let Some(class) = class {
|
|
image.add_css_class(class);
|
|
}
|
|
ui.locations.card.sign_in.set_visible(expired);
|
|
ui.locations.card.restart.set_visible(outdated);
|
|
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<Ui>, pins: &[pdfs_core::cache::Pin], mounted: bool) {
|
|
let desired: Vec<String> = 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 or folder and choose “Available offline”.",
|
|
))
|
|
.build();
|
|
ui.status.pins_group.add(&row);
|
|
ui.status.pin_rows.borrow_mut().push(PinRow {
|
|
row: row.upcast(),
|
|
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)
|
|
.use_markup(false)
|
|
.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: row.upcast(),
|
|
unpin: Some(unpin),
|
|
});
|
|
}
|
|
|
|
if pins.len() > PINS_COLLAPSED {
|
|
let title = if expanded {
|
|
gettext("Show fewer")
|
|
} else {
|
|
ngettext_f(
|
|
"Show all {n} file",
|
|
"Show all {n} files",
|
|
pins.len() as u64,
|
|
&[],
|
|
)
|
|
};
|
|
let icon = if expanded {
|
|
"go-up-symbolic"
|
|
} else {
|
|
"go-down-symbolic"
|
|
};
|
|
let ui_more = ui.clone();
|
|
let row = button_row(&title, icon, 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);
|
|
}
|
|
}
|