diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-cached-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-cached-symbolic.svg
new file mode 100644
index 0000000..ceb0994
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-cached-symbolic.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-details-pane-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-details-pane-symbolic.svg
new file mode 100644
index 0000000..5b3a375
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-details-pane-symbolic.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-link-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-link-symbolic.svg
new file mode 100644
index 0000000..b4848b0
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-link-symbolic.svg
@@ -0,0 +1,9 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-map-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-map-symbolic.svg
new file mode 100644
index 0000000..30c0d31
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-map-symbolic.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-menu-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-menu-symbolic.svg
new file mode 100644
index 0000000..5796a0f
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-menu-symbolic.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-people-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-people-symbolic.svg
new file mode 100644
index 0000000..0da341e
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-people-symbolic.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-share-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-share-symbolic.svg
new file mode 100644
index 0000000..f2098b2
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-share-symbolic.svg
@@ -0,0 +1,8 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-sync-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-sync-symbolic.svg
new file mode 100644
index 0000000..97bd3d3
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-sync-symbolic.svg
@@ -0,0 +1,10 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-synced-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-synced-symbolic.svg
new file mode 100644
index 0000000..aad698e
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-synced-symbolic.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-unshare-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-unshare-symbolic.svg
new file mode 100644
index 0000000..0d4af65
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-unshare-symbolic.svg
@@ -0,0 +1,9 @@
+
diff --git a/crates/pdfs-gui/resources/pdfs.gresource.xml b/crates/pdfs-gui/resources/pdfs.gresource.xml
index 62679a5..73120cb 100644
--- a/crates/pdfs-gui/resources/pdfs.gresource.xml
+++ b/crates/pdfs-gui/resources/pdfs.gresource.xml
@@ -10,6 +10,16 @@
scalable/actions/pdfs-filter-symbolic.svg
scalable/actions/pdfs-offline-symbolic.svg
scalable/actions/pdfs-online-only-symbolic.svg
+ scalable/actions/pdfs-cached-symbolic.svg
+ scalable/actions/pdfs-details-pane-symbolic.svg
+ scalable/actions/pdfs-link-symbolic.svg
+ scalable/actions/pdfs-map-symbolic.svg
+ scalable/actions/pdfs-menu-symbolic.svg
+ scalable/actions/pdfs-people-symbolic.svg
+ scalable/actions/pdfs-share-symbolic.svg
+ scalable/actions/pdfs-sync-symbolic.svg
+ scalable/actions/pdfs-synced-symbolic.svg
+ scalable/actions/pdfs-unshare-symbolic.svg
scalable/places/pdfs-proton-folder.svg
diff --git a/crates/pdfs-gui/resources/style.css b/crates/pdfs-gui/resources/style.css
index 94047c3..0867624 100644
--- a/crates/pdfs-gui/resources/style.css
+++ b/crates/pdfs-gui/resources/style.css
@@ -20,6 +20,13 @@
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
.sidebar-account { padding: 4px 4px 4px 8px; }
+/* Sync page status card: the state is the headline, the detail explains it */
+.sync-card .title { font-size: 1.15rem; font-weight: 700; }
+.sync-card-icon { padding: 6px 8px; }
+.sync-card-icon.success { color: var(--success-color); }
+.sync-card-icon.warning { color: var(--warning-color); }
+.sync-card-icon.error { color: var(--error-color); }
+
/* File browser */
.file-grid { padding: 6px; }
.file-tile { padding: 8px; border-radius: 10px; }
@@ -27,13 +34,11 @@
.file-tile:selected, .file-tile:hover:selected { background: color-mix(in srgb, var(--accent-bg-color) 20%, transparent); }
.file-thumbnail { border-radius: 7px; }
.file-badge { -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }
-.badge-pinned { color: var(--yellow-3); }
+.badge-pinned { color: var(--success-color); }
.badge-cached { color: var(--success-color); }
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
-scale.browser-status-meter trough, progressbar.browser-status-meter trough { min-width: 104px; }
-scale.browser-status-meter trough { min-height: 6px; }
-progressbar.browser-status-meter trough, progressbar.browser-status-meter progress { min-height: 6px; }
+scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
diff --git a/crates/pdfs-gui/src/app/dates.rs b/crates/pdfs-gui/src/app/dates.rs
new file mode 100644
index 0000000..15a9f6e
--- /dev/null
+++ b/crates/pdfs-gui/src/app/dates.rs
@@ -0,0 +1,139 @@
+//! Dates and times as the app shows them, so every page words a moment the
+//! same way.
+//!
+//! - [`relative`] for "when did this happen" in lists: "Just now", "5 min
+//! ago", "Yesterday, 14:05", "Tuesday", then a date.
+//! - [`short_date`] for date columns: "Sep 23", with the year only when it is
+//! not this year.
+//! - [`full`] for tooltips: the weekday, date, year and time.
+//!
+//! The formats are `strftime` patterns that go through gettext, so they follow
+//! the language picked in Preferences, not only the system locale.
+
+use gtk4::glib;
+
+use crate::{gettext, gettext_f, ngettext_f};
+
+/// Seconds in a day, for the "within the last week" checks.
+const DAY: i64 = 86_400;
+
+/// A past moment in epoch seconds, relative to now. Empty when the time can't
+/// be read.
+pub(crate) fn relative(secs: i64) -> String {
+ match (
+ glib::DateTime::from_unix_local(secs),
+ glib::DateTime::now_local(),
+ ) {
+ (Ok(at), Ok(now)) => relative_to(&at, &now),
+ _ => String::new(),
+ }
+}
+
+/// [`relative`] for a fixed `now`, so it can be tested.
+pub(crate) fn relative_to(at: &glib::DateTime, now: &glib::DateTime) -> String {
+ let ago = now.to_unix() - at.to_unix();
+ // A clock a little ahead of ours still reads as "just now".
+ if (-60..60).contains(&ago) {
+ return gettext("Just now");
+ }
+ if (60..3600).contains(&ago) {
+ let minutes = (ago / 60) as u64;
+ return ngettext_f("{n} min ago", "{n} min ago", minutes, &[]);
+ }
+ // Translators: strftime format for a time of day, such as "14:05".
+ let time = || format(at, &gettext("%H:%M"));
+ if at.ymd() == now.ymd() {
+ return time();
+ }
+ if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) {
+ // Translators: {time} is a time of day, such as "14:05".
+ return gettext_f("Yesterday, {time}", &[("time", &time())]);
+ }
+ if (0..6 * DAY).contains(&ago) {
+ return format(at, "%A");
+ }
+ short_date_to(at, now)
+}
+
+/// A date in epoch seconds, without the time. Empty when the time can't be
+/// read.
+pub(crate) fn short_date(secs: i64) -> String {
+ match (
+ glib::DateTime::from_unix_local(secs),
+ glib::DateTime::now_local(),
+ ) {
+ (Ok(at), Ok(now)) => short_date_to(&at, &now),
+ _ => String::new(),
+ }
+}
+
+/// [`short_date`] for a fixed `now`, so it can be tested.
+pub(crate) fn short_date_to(at: &glib::DateTime, now: &glib::DateTime) -> String {
+ let pattern = if at.year() == now.year() {
+ // Translators: strftime format for a date in this year, such as "Sep 23".
+ gettext("%b %-d")
+ } else {
+ // Translators: strftime format for a date in another year, such as "Sep 23, 2025".
+ gettext("%b %-d, %Y")
+ };
+ format(at, &pattern)
+}
+
+/// The whole moment in epoch seconds, for a tooltip. Empty when the time
+/// can't be read.
+pub(crate) fn full(secs: i64) -> String {
+ glib::DateTime::from_unix_local(secs)
+ .map(|at| {
+ // Translators: strftime format for a full date and time, such as
+ // "Wednesday, September 23, 2026, 14:05".
+ format(&at, &gettext("%A, %B %-d, %Y, %H:%M"))
+ })
+ .unwrap_or_default()
+}
+
+fn format(at: &glib::DateTime, pattern: &str) -> String {
+ at.format(pattern)
+ .map(|s| s.trim().to_string())
+ .unwrap_or_default()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn at(y: i32, m: i32, d: i32, h: i32, min: i32) -> glib::DateTime {
+ glib::DateTime::from_local(y, m, d, h, min, 0.0).unwrap()
+ }
+
+ #[test]
+ fn relative_times_get_coarser_with_age() {
+ let now = at(2026, 9, 23, 15, 0);
+ assert_eq!(relative_to(&at(2026, 9, 23, 14, 59), &now), "1 min ago");
+ assert_eq!(relative_to(&at(2026, 9, 23, 14, 55), &now), "5 min ago");
+ assert_eq!(relative_to(&at(2026, 9, 23, 9, 5), &now), "09:05");
+ assert_eq!(
+ relative_to(&at(2026, 9, 22, 14, 5), &now),
+ "Yesterday, 14:05"
+ );
+ assert_eq!(relative_to(&at(2026, 9, 20, 10, 0), &now), "Sunday");
+ assert_eq!(relative_to(&at(2026, 9, 1, 10, 0), &now), "Sep 1");
+ assert_eq!(relative_to(&at(2024, 3, 5, 10, 0), &now), "Mar 5, 2024");
+ }
+
+ #[test]
+ fn a_moment_ago_or_slightly_ahead_is_just_now() {
+ let now = at(2026, 9, 23, 15, 0);
+ assert_eq!(relative_to(&now, &now), "Just now");
+ assert_eq!(
+ relative_to(&at(2026, 9, 23, 15, 0), &now.add_seconds(30.0).unwrap()),
+ "Just now"
+ );
+ }
+
+ #[test]
+ fn short_dates_show_the_year_only_when_it_differs() {
+ let now = at(2026, 9, 23, 15, 0);
+ assert_eq!(short_date_to(&at(2026, 1, 2, 8, 0), &now), "Jan 2");
+ assert_eq!(short_date_to(&at(2025, 12, 31, 8, 0), &now), "Dec 31, 2025");
+ }
+}
diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs
index 8958c96..0a09cd8 100644
--- a/crates/pdfs-gui/src/app/main.rs
+++ b/crates/pdfs-gui/src/app/main.rs
@@ -1,5 +1,6 @@
#[path = "../activation.rs"]
pub(crate) mod activation;
+pub(crate) mod dates;
#[path = "../i18n.rs"]
pub(crate) mod i18n;
pub(crate) mod pages;
@@ -28,7 +29,9 @@ use widgets::share_dialog::*;
use widgets::thumbnails::*;
use widgets::versions_dialog::*;
-pub(crate) use i18n::{country_name, gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
+pub(crate) use i18n::{
+ country_name, gettext, gettext_f, gettext_noop, human_bytes, ngettext_f, pgettext,
+};
use std::cell::{Cell, RefCell};
@@ -72,6 +75,11 @@ const APP_ID: &str = "io.narl.proton-drive-linux";
/// How often the window re-reads mount status, cache usage and the pin list.
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
+/// While the window is open but not focused, only every this-many ticks
+/// refreshes (every 10 s). A minimised or hidden window skips them all, and
+/// focusing the window refreshes at once.
+const BACKGROUND_REFRESH_EVERY: u32 = 5;
+
/// Backoff between auto-retries of a Files/Photos load while the mount service
/// is still coming up (see [`load_browser`] / [`load_gallery`]).
const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2);
@@ -520,6 +528,7 @@ fn build_window(app: &adw::Application) {
account_name: main_widgets.account_name.clone(),
avatar: main_widgets.avatar.clone(),
status_icon: main_widgets.status_icon.clone(),
+ status_spinner: main_widgets.status_spinner.clone(),
status_title: main_widgets.status_title.clone(),
status_detail: main_widgets.status_detail.clone(),
transfer_rows: RefCell::new(Vec::new()),
@@ -549,6 +558,7 @@ fn build_window(app: &adw::Application) {
pins_state: RefCell::new(None),
notified_mounted: Cell::new(None),
active_transfers: Cell::new(0),
+ transfer_batch: RefCell::new(None),
},
browser: BrowserState {
model: browser_widgets.model.clone(),
@@ -588,9 +598,6 @@ fn build_window(app: &adw::Application) {
zoom: browser_widgets.zoom.clone(),
grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT),
grid_tiles: RefCell::new(Vec::new()),
- quota_box: browser_widgets.quota_box.clone(),
- quota: browser_widgets.quota.clone(),
- quota_text: browser_widgets.quota_text.clone(),
},
details: DetailsState {
details: browser_widgets.details,
@@ -627,7 +634,6 @@ fn build_window(app: &adw::Application) {
burst: Cell::new(false),
import_banner: gallery_widgets.import_banner.clone(),
upload: gallery_widgets.upload.clone(),
- import: gallery_widgets.import.clone(),
empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(),
albums: gallery_widgets.albums.clone(),
@@ -830,10 +836,6 @@ fn build_window(app: &adw::Application) {
&gallery_widgets.empty_upload,
&gallery_widgets.empty_import,
);
- let ui_duplicates = ui.clone();
- gallery_widgets
- .duplicates
- .connect_clicked(move |_| show_duplicates(&ui_duplicates));
wire_albums(&ui);
wire_places(&ui);
wire_trash(&ui, &trash_widgets);
@@ -919,12 +921,33 @@ fn build_window(app: &adw::Application) {
refresh(&ui);
// Periodic refresh while the window lives. The closure holds a strong `Rc`;
- // it is dropped when the source is removed on window close.
+ // it is dropped when the source is removed on window close. A window in the
+ // background refreshes less often, and one nobody can see not at all.
let ui_tick = ui.clone();
+ let window_tick = window.downgrade();
+ let skipped = Cell::new(0u32);
let source = glib::timeout_add_local(REFRESH_INTERVAL, move || {
+ let Some(window) = window_tick.upgrade() else {
+ return glib::ControlFlow::Continue;
+ };
+ if window_hidden(&window) {
+ return glib::ControlFlow::Continue;
+ }
+ if !window.is_active() && skipped.get() + 1 < BACKGROUND_REFRESH_EVERY {
+ skipped.set(skipped.get() + 1);
+ return glib::ControlFlow::Continue;
+ }
+ skipped.set(0);
refresh(&ui_tick);
glib::ControlFlow::Continue
});
+ // Catch up as soon as the user comes back to the window.
+ let ui_focus = ui.clone();
+ window.connect_is_active_notify(move |window| {
+ if window.is_active() {
+ refresh(&ui_focus);
+ }
+ });
let cell = RefCell::new(Some(source));
window.connect_close_request(move |_| {
if let Some(id) = cell.borrow_mut().take() {
@@ -936,6 +959,22 @@ fn build_window(app: &adw::Application) {
window.present();
}
+/// Whether `window` is minimised or otherwise out of sight (another workspace,
+/// fully covered), as far as the compositor tells us.
+fn window_hidden(window: &adw::ApplicationWindow) -> bool {
+ if !window.is_visible() {
+ return true;
+ }
+ window
+ .surface()
+ .and_downcast::()
+ .is_some_and(|toplevel| {
+ toplevel.state().intersects(
+ gtk4::gdk::ToplevelState::MINIMIZED | gtk4::gdk::ToplevelState::SUSPENDED,
+ )
+ })
+}
+
/// The sidebar destinations, in order: the row index is the index into this table,
/// and each entry is `(stack page name, label, icon)`. The places in the account
/// come first; the rows from [`SIDEBAR_SYNC_SECTION`] on are about this computer's
@@ -950,21 +989,17 @@ const DESTINATIONS: [(&str, &str, &str); 8] = [
(
"shared",
gettext_noop("Shared with me"),
- "system-users-symbolic",
+ "pdfs-people-symbolic",
),
(
"sharedbyme",
gettext_noop("Shared by me"),
- "emblem-shared-symbolic",
+ "pdfs-share-symbolic",
),
("devices", gettext_noop("Computers"), "computer-symbolic"),
("trash", gettext_noop("Trash"), "user-trash-symbolic"),
// Translators: sidebar entry for the page listing this computer's synced folders.
- (
- "locations",
- gettext_noop("Sync"),
- "emblem-synchronizing-symbolic",
- ),
+ ("locations", gettext_noop("Sync"), "pdfs-sync-symbolic"),
(
"activity",
gettext_noop("Activity"),
@@ -1358,7 +1393,7 @@ fn build_primary_menu() -> gtk4::MenuButton {
Some("win.about"),
);
gtk4::MenuButton::builder()
- .icon_name("open-menu-symbolic")
+ .icon_name("pdfs-menu-symbolic")
.tooltip_text(gettext("Main menu"))
.primary(true)
.menu_model(&menu)
@@ -1383,12 +1418,22 @@ fn install_window_actions(ui: &Rc, window: &adw::ApplicationWindow) {
sign_out_action.connect_activate(move |_, _| sign_out(&ui_out));
window.add_action(&sign_out_action);
- // The Photos page's import banner leads here.
+ // The Photos page's import banner and page menu lead here. The import is a
+ // staged, hours-long migration, so it gets its own page rather than a file
+ // chooser: the archives need reviewing before anything is sent, and the run
+ // needs somewhere to report.
let show_import = gio::SimpleAction::new("show-import", None);
let ui_import = ui.clone();
show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout"));
window.add_action(&show_import);
+ // The Photos page menu's Find Duplicates. The finder opens over the page:
+ // a review, not a view of it.
+ let find_duplicates = gio::SimpleAction::new("find-duplicates", None);
+ let ui_duplicates = ui.clone();
+ find_duplicates.connect_activate(move |_, _| show_duplicates(&ui_duplicates));
+ window.add_action(&find_duplicates);
+
// The Albums view's New Album button.
let new_album = gio::SimpleAction::new("new-album", None);
let ui_album = ui.clone();
@@ -1502,7 +1547,7 @@ fn install_launch_actions(ui: &Rc, app: &adw::Application, window: &adw::App
confirm_stop.connect_activate(move |_, _| {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Stop Proton Drive?"))
- .body(gettext("The drive unmounts and nothing syncs until you connect again. Proton Drive starts again at your next login."))
+ .body(gettext("My files goes offline and nothing syncs until you connect again. Proton Drive starts again at your next login."))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("stop", &pgettext("verb", "Stop"));
@@ -1523,7 +1568,7 @@ fn install_launch_actions(ui: &Rc, app: &adw::Application, window: &adw::App
toast_error(
&ui,
&gettext("Couldn't stop Proton Drive"),
- &gettext("The mount service did not stop."),
+ &gettext("The Proton Drive service did not stop."),
);
}
});
@@ -1810,6 +1855,77 @@ fn capitalize(s: &str) -> String {
}
}
+/// Whether the Proton Drive service is down (failed, or not running at all).
+/// Asks systemd on a worker thread: `systemctl` can take seconds to answer,
+/// and the window must not freeze while it does.
+pub(crate) async fn service_down() -> bool {
+ gio::spawn_blocking(|| service::is_failed() || !service::is_active())
+ .await
+ .unwrap_or(true)
+}
+
+/// Restart the Proton Drive service off the main thread, then run `then`.
+pub(crate) fn restart_service_then(ui: &Rc, then: impl FnOnce(&Rc) + 'static) {
+ let ui = ui.clone();
+ glib::spawn_future_local(async move {
+ let _ = gio::spawn_blocking(service::restart).await;
+ then(&ui);
+ });
+}
+
+/// A page's answer to "the daemon didn't reply". It tells *still starting*
+/// (poll again, no button) from *down* (an error with Retry, which restarts the
+/// service), so a cold start heals itself but a real failure stays visible.
+/// `status` paints the page's status view; `reload` loads the page again, and
+/// runs only while `page` is still the one on screen.
+pub(crate) fn service_unreachable(
+ ui: &Rc,
+ page: &'static str,
+ status: impl Fn(&Rc, &str, &str, &str, bool) + 'static,
+ reload: impl Fn(&Rc) + 'static,
+) {
+ let ui = ui.clone();
+ glib::spawn_future_local(async move {
+ if service_down().await {
+ status(
+ &ui,
+ "network-offline-symbolic",
+ &gettext("Not connected"),
+ &gettext("The Proton Drive service isn't running."),
+ true,
+ );
+ return;
+ }
+ status(
+ &ui,
+ "network-idle-symbolic",
+ &gettext("Connecting…"),
+ &gettext("Waiting for the Proton Drive service to start."),
+ false,
+ );
+ glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
+ if ui.stack.visible_child_name().as_deref() == Some(page) {
+ reload(&ui);
+ }
+ });
+ });
+}
+
+/// How long a copy button shows its check after copying.
+const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
+
+/// Confirm a copy on the button itself: its icon turns into a check for a
+/// moment, then back to `icon`.
+pub(crate) fn flash_copied(button: >k4::Button, icon: &'static str) {
+ button.set_icon_name("object-select-symbolic");
+ let button = button.downgrade();
+ glib::timeout_add_local_once(COPIED_FEEDBACK, move || {
+ if let Some(button) = button.upgrade() {
+ button.set_icon_name(icon);
+ }
+ });
+}
+
/// Open a local path with the handler the user configured — `xdg-open` unless
/// `open_with` in `config.json` overrides it for this kind of file.
fn open_path(path: &str) {
@@ -1824,17 +1940,6 @@ pub(crate) fn open_named_path(path: &str, name: &str) {
pdfs_core::opener::open_default_named(Path::new(path), name, false);
}
-/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`, or
-/// `512 bytes` below one KiB).
-///
-/// GLib does the formatting, so the units, the plural of "bytes" and the
-/// decimal separator follow the user's language from GLib's own catalog. GLib
-/// separates number and unit with a no-break space; it is turned back into a
-/// plain space so the text matches what the rest of the UI builds around it.
-fn human_bytes(bytes: u64) -> String {
- glib::format_size_full(bytes, glib::FormatSizeFlags::IEC_UNITS).replace('\u{a0}', " ")
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/pdfs-gui/src/app/pages/activity.rs b/crates/pdfs-gui/src/app/pages/activity.rs
index 809d82b..c0d5bcb 100644
--- a/crates/pdfs-gui/src/app/pages/activity.rs
+++ b/crates/pdfs-gui/src/app/pages/activity.rs
@@ -174,10 +174,7 @@ pub(crate) fn wire_activity(
filters: &[(ActivityFilter, gtk4::ToggleButton)],
) {
let ui_retry = ui.clone();
- retry.connect_clicked(move |_| {
- service::restart();
- load_activity(&ui_retry);
- });
+ retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_activity));
for (filter, button) in filters {
let ui = ui.clone();
let filter = *filter;
@@ -272,7 +269,7 @@ pub(crate) fn load_activity(ui: &Rc) {
&ui,
"dialog-warning-symbolic",
&gettext("Couldn't read activity"),
- &gettext("Unexpected reply from the mount service."),
+ &gettext("Unexpected reply from the Proton Drive service."),
false,
),
Ok(Err(_)) | Err(_) => activity_unreachable(&ui),
@@ -282,29 +279,7 @@ pub(crate) fn load_activity(ui: &Rc) {
/// The daemon didn't answer the Activity page.
pub(crate) fn activity_unreachable(ui: &Rc) {
- if service::is_failed() || !service::is_active() {
- activity_status(
- ui,
- "network-offline-symbolic",
- &gettext("Not connected"),
- &gettext("The Proton Drive mount service isn't running."),
- true,
- );
- return;
- }
- activity_status(
- ui,
- "folder-remote-symbolic",
- &gettext("Connecting…"),
- &gettext("Waiting for the Proton Drive mount service to come up."),
- false,
- );
- let ui = ui.clone();
- glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
- if ui.stack.visible_child_name().as_deref() == Some("activity") {
- load_activity(&ui);
- }
- });
+ service_unreachable(ui, "activity", activity_status, load_activity);
}
/// Poll the conflicts still waiting for a decision while the Activity page is
@@ -538,8 +513,8 @@ fn feed_row(
subtitle.push(capitalize(&a.detail));
}
if row.count > 1 {
- let since = activity_time(row.first);
- // Translators: {time} is a date and time such as "Sep 21, 14:05".
+ let since = dates::relative(row.first);
+ // Translators: {time} is a date or time such as "Sep 21" or "5 min ago".
subtitle.push(ngettext_f(
"{n} time since {time}",
"{n} times since {time}",
@@ -585,9 +560,7 @@ fn feed_row(
));
time.add_css_class("dim-label");
time.add_css_class("numeric");
- if let Some(at) = at.and_then(|at| at.format("%c").ok()) {
- time.set_tooltip_text(Some(&at));
- }
+ time.set_tooltip_text(Some(&dates::full(a.time)));
widget.add_suffix(&time);
if let Some(conflict) = pending {
@@ -645,28 +618,19 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
match kind {
ActivityKind::Upload => "pdfs-upload-symbolic",
ActivityKind::Download => "document-save-symbolic",
- ActivityKind::Sync => "emblem-synchronizing-symbolic",
+ ActivityKind::Sync => "pdfs-sync-symbolic",
ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic",
ActivityKind::Trash => "user-trash-symbolic",
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
ActivityKind::Restore => "edit-undo-symbolic",
- ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
- ActivityKind::Unshare => "action-unavailable-symbolic",
+ ActivityKind::Share | ActivityKind::PublicLink => "pdfs-share-symbolic",
+ ActivityKind::Unshare => "pdfs-unshare-symbolic",
ActivityKind::Conflict => "dialog-warning-symbolic",
}
}
-/// Format an epoch-seconds timestamp for the Activity feed, in local time.
-pub(crate) fn activity_time(secs: i64) -> String {
- glib::DateTime::from_unix_local(secs)
- // Translators: strftime format for a date and time in the activity feed, such as "Sep 23, 14:05".
- .and_then(|dt| dt.format(&gettext("%b %-d, %H:%M")))
- .map(|s| s.to_string())
- .unwrap_or_default()
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/pdfs-gui/src/app/pages/albums.rs b/crates/pdfs-gui/src/app/pages/albums.rs
index f554dde..60a86e7 100644
--- a/crates/pdfs-gui/src/app/pages/albums.rs
+++ b/crates/pdfs-gui/src/app/pages/albums.rs
@@ -83,7 +83,7 @@ pub(crate) fn load_albums(ui: &Rc) {
&ui,
"network-offline-symbolic",
&gettext("Not connected"),
- &gettext("The Proton Drive mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -286,7 +286,7 @@ pub(crate) fn prompt_new_album(ui: &Rc, photos: Vec) {
_ => toast_error(
&ui,
&gettext("Couldn't create the album"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -351,7 +351,11 @@ fn run_album_request(ui: &Rc, request: Request, done: String, failed: String
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
- _ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
+ _ => toast_error(
+ &ui,
+ &failed,
+ &gettext("The Proton Drive service didn't respond."),
+ ),
}
});
}
@@ -380,7 +384,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc, photos: Vec) {
toast_error(
&ui,
&gettext("Couldn't load albums"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
);
return;
}
@@ -487,7 +491,7 @@ fn add_to_album(ui: &Rc, album: String, photos: Vec) {
_ => toast_error(
&ui,
&gettext("Couldn't add to the album"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -559,7 +563,7 @@ pub(crate) fn remove_from_album(ui: &Rc, album: &AlbumInfo, photos: Vec, glib::WeakRef)>>,
- pub(crate) quota_box: gtk4::Box,
- pub(crate) quota: gtk4::ProgressBar,
- pub(crate) quota_text: gtk4::Label,
}
/// Idle pause after the last keystroke before a search query is sent, so typing
@@ -136,9 +133,6 @@ pub(crate) struct BrowserWidgets {
pub(crate) thumbnail_status: gtk4::Label,
pub(crate) summary: gtk4::Label,
pub(crate) zoom: gtk4::Scale,
- pub(crate) quota_box: gtk4::Box,
- pub(crate) quota: gtk4::ProgressBar,
- pub(crate) quota_text: gtk4::Label,
pub(crate) refresh: gtk4::Button,
/// Wraps the views + the details pane; the pane shows the selection while its header toggle is on.
pub(crate) split: adw::OverlaySplitView,
@@ -344,7 +338,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
let grid = gtk4::GridView::builder()
.model(&selection)
.min_columns(2)
- .max_columns(10)
+ .max_columns(32)
.enable_rubberband(true)
.build();
grid.add_css_class("file-grid");
@@ -436,9 +430,8 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.child(&bulk_box)
.build();
- // Mirror DolphinStatusBar's full-width order: contextual text (stretch 1),
- // "Zoom:", its slider, then capacity information. Both visible troughs use
- // the same CSS dimensions.
+ // The status bar: contextual text (stretch 1), then the icon-size slider.
+ // Account storage lives in the sidebar, so it isn't repeated here.
let summary = gtk4::Label::builder()
.label(gettext("Loading…"))
.halign(gtk4::Align::Start)
@@ -446,9 +439,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.ellipsize(gtk4::pango::EllipsizeMode::End)
.hexpand(true)
.build();
- // Translators: label in front of the icon-size slider in the status bar.
- let zoom_label = gtk4::Label::new(Some(&gettext("Zoom:")));
- zoom_label.set_valign(gtk4::Align::Center);
let zoom = gtk4::Scale::with_range(
gtk4::Orientation::Horizontal,
f64::from(GRID_THUMB_MIN),
@@ -465,50 +455,34 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
&[],
)));
zoom.add_css_class("browser-status-meter");
-
- let quota = gtk4::ProgressBar::new();
- quota.set_hexpand(false);
- quota.set_valign(gtk4::Align::Center);
- quota.set_tooltip_text(Some(&gettext("Proton account storage")));
- quota.add_css_class("browser-status-meter");
- let quota_text = gtk4::Label::builder()
- .label(gettext("Loading…"))
- .halign(gtk4::Align::Start)
- .valign(gtk4::Align::Center)
- .margin_end(6)
- .tooltip_text(gettext("Proton account storage"))
- .build();
- let quota_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
- quota_box.set_valign(gtk4::Align::Center);
- quota_box.append("a);
- quota_box.append("a_text);
- quota_box.set_visible(false);
+ zoom.update_property(&[gtk4::accessible::Property::Label(&gettext("Icon size"))]);
+ // Small and large icon glyphs at the ends of the slider say what it does
+ // without a text label.
+ let zoom_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2);
+ zoom_box.set_valign(gtk4::Align::Center);
+ zoom_box.append(>k4::Image::from_icon_name("zoom-out-symbolic"));
+ zoom_box.append(&zoom);
+ zoom_box.append(>k4::Image::from_icon_name("zoom-in-symbolic"));
let status_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
status_bar.add_css_class("browser-statusbar");
status_bar.set_margin_start(6);
status_bar.set_margin_end(2);
status_bar.append(&summary);
- status_bar.append(&zoom_label);
- status_bar.append(&zoom);
- status_bar.append("a_box);
+ status_bar.append(&zoom_box);
- let zoom_label_for_view = zoom_label.clone();
- let zoom_for_view = zoom.clone();
+ let zoom_for_view = zoom_box.clone();
let content_for_view = content.clone();
view_stack.connect_visible_child_name_notify(move |stack| {
let visible = content_for_view.visible_child_name().as_deref() == Some("views")
&& stack.visible_child_name().as_deref() == Some("grid");
- zoom_label_for_view.set_visible(visible);
zoom_for_view.set_visible(visible);
});
- let zoom_label_for_content = zoom_label.clone();
- let zoom_for_content = zoom.clone();
+ let zoom_for_content = zoom_box.clone();
let view_for_content = view_stack.clone();
content.connect_visible_child_name_notify(move |stack| {
let visible = stack.visible_child_name().as_deref() == Some("views")
&& view_for_content.visible_child_name().as_deref() == Some("grid");
- zoom_label_for_content.set_visible(visible);
zoom_for_content.set_visible(visible);
});
@@ -528,7 +502,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
page.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
page.append(&status_bar);
- let (frame, header, _) = page_frame(&gettext("My Files"), &page);
+ let (frame, header, _) = page_frame(&gettext("My files"), &page);
header.pack_start(&new_button);
header.pack_end(&folder_button);
header.pack_end(&refresh);
@@ -559,9 +533,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
thumbnail_status,
summary,
zoom,
- quota_box,
- quota,
- quota_text,
refresh,
split,
details,
@@ -699,7 +670,8 @@ pub(crate) fn run_bulk_delete(ui: &Rc, entries: Vec) {
failure.get_or_insert(message);
}
_ => {
- failure.get_or_insert_with(|| gettext("The mount service didn't respond."));
+ failure
+ .get_or_insert_with(|| gettext("The Proton Drive service didn't respond."));
}
}
}
@@ -783,7 +755,8 @@ pub(crate) fn run_bulk_pin(ui: &Rc, entries: Vec, pin: bool) {
failure.get_or_insert(message);
}
_ => {
- failure.get_or_insert_with(|| gettext("The mount service didn't respond."));
+ failure
+ .get_or_insert_with(|| gettext("The Proton Drive service didn't respond."));
}
}
}
@@ -994,7 +967,7 @@ pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k
}
}));
column_view.append_column(&text_column(&pgettext("column", "Modified"), |e| {
- format_modified(e.modified)
+ dates::short_date(e.modified)
}));
// Search hits come from anywhere in the Drive; this says where.
let location = text_column(&gettext(LOCATION_COLUMN), |e| hit_location(&e.path));
@@ -1087,7 +1060,7 @@ const LOCATION_COLUMN: &str = gettext_noop("Location");
fn hit_location(path: &str) -> String {
match path.rsplit_once('/') {
Some((parent, _)) => parent.to_string(),
- None => gettext("My Files"),
+ None => gettext("My files"),
}
}
@@ -1376,7 +1349,7 @@ pub(crate) fn open_entry_with(ui: &Rc, entry: &DirEntry) {
toast_error(
ui,
&gettext("Couldn't open file"),
- &gettext("Open With needs the Proton Drive folder to be mounted."),
+ &gettext("Open With needs the Proton Drive service to be running."),
);
return;
};
@@ -1427,7 +1400,7 @@ pub(crate) fn copy_entry_link(ui: &Rc, entry: &DirEntry) {
_ => toast_error(
&ui,
&gettext("Couldn't copy link"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -1535,7 +1508,7 @@ pub(crate) fn download_and_open(ui: &Rc, entry: &DirEntry) {
_ => toast_error(
&ui,
&gettext("Couldn't open file"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -1581,7 +1554,7 @@ pub(crate) fn toggle_pin(ui: &Rc, entry: &DirEntry) {
toast_error(
&ui,
&gettext("Couldn't change offline state"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
);
load_browser(&ui);
}
@@ -1618,9 +1591,12 @@ pub(crate) fn repaint_crumb(ui: &Rc, path: &str) {
} else {
path.split('/').collect()
};
- ui.browser
- .crumb
- .append(&crumb_node(ui, "Proton Drive", "", segments.is_empty()));
+ ui.browser.crumb.append(&crumb_node(
+ ui,
+ &gettext("My files"),
+ "",
+ segments.is_empty(),
+ ));
let mut acc = String::new();
for (i, seg) in segments.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
@@ -2003,7 +1979,7 @@ fn start_thumbnail_build(ui: &Rc) {
toast_failure(&ui, &gettext("Couldn't build thumbnails"), &message, kind);
}
_ => {
- let message = gettext("The mount service didn't respond.");
+ let message = gettext("The Proton Drive service didn't respond.");
thumbnail_build_failed(&ui);
toast_error(&ui, &gettext("Couldn't build thumbnails"), &message);
}
@@ -2041,7 +2017,7 @@ fn cancel_thumbnail_build(ui: &Rc) {
toast_error(
&ui,
&gettext("Couldn't cancel thumbnail build"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
);
}
}
@@ -2078,7 +2054,7 @@ fn schedule_thumbnail_build_poll(ui: &Rc) {
toast_error(
&ui_result,
&gettext("Thumbnail build stopped"),
- &gettext("The mount service stopped reporting thumbnail progress."),
+ &gettext("The Proton Drive service stopped reporting thumbnail progress."),
);
}
}
@@ -2227,7 +2203,11 @@ pub(crate) fn run_mutation(ui: &Rc, req: Request, done: String, failed: &'st
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
- _ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
+ _ => toast_error(
+ &ui,
+ &failed,
+ &gettext("The Proton Drive service didn't respond."),
+ ),
}
});
}
@@ -2527,7 +2507,7 @@ pub(crate) fn run_bulk_move(ui: &Rc, sources: Vec, target: String) {
_ => {
failure.get_or_insert_with(|| {
(
- gettext("The mount service didn't respond."),
+ gettext("The Proton Drive service didn't respond."),
ErrorKind::Internal,
)
});
@@ -2583,7 +2563,7 @@ pub(crate) fn prompt_new_folder(ui: &Rc) {
let win = ui_window(ui);
let parent = ui.browser.path.borrow().clone();
let dialog = adw::AlertDialog::builder()
- .heading(gettext("New folder"))
+ .heading(gettext("New Folder"))
.body(gettext("Create a folder in the current directory."))
.build();
let group = adw::PreferencesGroup::new();
@@ -2703,7 +2683,7 @@ pub(crate) fn start_upload(ui: &Rc, sources: Vec) {
_ => toast_error(
&ui,
&gettext("Couldn't upload"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -2719,7 +2699,7 @@ pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)
Some(if entry.pinned {
("pdfs-offline-symbolic", "badge-pinned")
} else if entry.cached {
- ("emblem-ok-symbolic", "badge-cached")
+ ("pdfs-cached-symbolic", "badge-cached")
} else {
("pdfs-online-only-symbolic", "badge-cloud")
})
@@ -2832,18 +2812,6 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
}
}
-/// Format an epoch-seconds modification time as a short local date.
-pub(crate) fn format_modified(secs: i64) -> String {
- match glib::DateTime::from_unix_local(secs) {
- Ok(dt) => dt
- // Translators: strftime format for a modification date, such as "23 Sep 2026".
- .format(&gettext("%-d %b %Y"))
- .map(|s| s.to_string())
- .unwrap_or_default(),
- Err(_) => String::new(),
- }
-}
-
/// Request the current browser directory from the daemon and repaint both views.
pub(crate) fn load_browser(ui: &Rc) {
cancel_file_thumbnails(ui);
@@ -2886,7 +2854,7 @@ pub(crate) fn load_browser(ui: &Rc) {
Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind),
Ok(Ok(_)) => browser_failed(
&ui,
- &gettext("Unexpected reply from the mount service."),
+ &gettext("Unexpected reply from the Proton Drive service."),
ErrorKind::Internal,
),
Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
@@ -2915,32 +2883,15 @@ pub(crate) fn browser_failed(ui: &Rc, message: &str, kind: ErrorKind) {
/// button) from *down* (actionable error + Retry), so a cold start self-heals
/// once the systemd mount comes up but a real failure stays visible.
pub(crate) fn browser_unreachable(ui: &Rc) {
- if service::is_failed() || !service::is_active() {
- ui.browser.summary.set_label(&gettext("Not connected"));
- browser_status(
- ui,
- "network-offline-symbolic",
- &gettext("Not connected"),
- &gettext("The Proton Drive mount service isn't running."),
- true,
- );
- return;
- }
- ui.browser.summary.set_label(&gettext("Connecting…"));
- browser_status(
+ service_unreachable(
ui,
- "folder-remote-symbolic",
- &gettext("Connecting…"),
- &gettext("Waiting for the Proton Drive mount service to come up."),
- false,
+ "browser",
+ |ui, icon, title, description, retry| {
+ ui.browser.summary.set_label(title);
+ browser_status(ui, icon, title, description, retry);
+ },
+ load_browser,
);
- let ui = ui.clone();
- glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
- // Only keep polling while the Files page is the one on screen.
- if ui.stack.visible_child_name().as_deref() == Some("browser") {
- load_browser(&ui);
- }
- });
}
/// Repopulate the shared model — folders first, then case-insensitive by name —
@@ -3072,7 +3023,7 @@ pub(crate) fn run_search(ui: &Rc, query: &str) {
Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind),
Ok(Ok(_)) => browser_failed(
&ui,
- &gettext("Unexpected reply from the mount service."),
+ &gettext("Unexpected reply from the Proton Drive service."),
ErrorKind::Internal,
),
Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
@@ -3204,7 +3155,7 @@ mod tests {
#[test]
fn a_search_hit_location_is_its_parent_folder() {
assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old");
- assert_eq!(hit_location("a.txt"), "My Files");
+ assert_eq!(hit_location("a.txt"), "My files");
}
#[test]
diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs
index f3bb6c2..c6de6fb 100644
--- a/crates/pdfs-gui/src/app/pages/devices.rs
+++ b/crates/pdfs-gui/src/app/pages/devices.rs
@@ -126,10 +126,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
/// Install the Devices page's retry button and the Rename action.
pub(crate) fn wire_devices(ui: &Rc, retry: >k4::Button) {
let ui_retry = ui.clone();
- retry.connect_clicked(move |_| {
- service::restart();
- load_devices(&ui_retry);
- });
+ retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_devices));
let ui_ren = ui.clone();
ui.devices.rename_this.connect_clicked(move |_| {
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
@@ -202,29 +199,7 @@ pub(crate) fn load_devices(ui: &Rc) {
/// The daemon didn't answer the Devices page.
pub(crate) fn devices_unreachable(ui: &Rc) {
- if service::is_failed() || !service::is_active() {
- devices_status(
- ui,
- "network-offline-symbolic",
- &gettext("Not connected"),
- &gettext("The Proton Drive mount service isn't running."),
- true,
- );
- return;
- }
- devices_status(
- ui,
- "folder-remote-symbolic",
- &gettext("Connecting…"),
- &gettext("Waiting for the Proton Drive mount service to come up."),
- false,
- );
- let ui = ui.clone();
- glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
- if ui.stack.visible_child_name().as_deref() == Some("devices") {
- load_devices(&ui);
- }
- });
+ service_unreachable(ui, "devices", devices_status, load_devices);
}
/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal
@@ -330,7 +305,7 @@ pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
"{platform} · last synced {time}",
&[
("platform", &dev.device_type),
- ("time", &activity_time(secs)),
+ ("time", &dates::relative(secs)),
],
),
// Translators: {platform} is the device's platform, such as "Linux".
@@ -358,7 +333,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc, folders: &[SyncFolderInfo]) {
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
let manage = gtk4::Button::builder()
.label(gettext("Open Sync"))
- .tooltip_text(gettext("Manage this computer's folders and mountpoint"))
+ .tooltip_text(gettext("Manage this computer's synced folders"))
.valign(gtk4::Align::Center)
.build();
manage.add_css_class("flat");
@@ -489,7 +464,7 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc) {
_ => toast_error(
&ui,
&gettext("Couldn't add folder"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -529,7 +504,7 @@ pub(crate) fn prompt_restore_folders(ui: &Rc, device: Option<(String, String
_ => toast_error(
&ui,
&gettext("Couldn't list folders to restore"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -577,7 +552,7 @@ fn show_restore_picker(
.child(&group)
.build();
let dialog = adw::AlertDialog::builder()
- .heading(gettext("Restore folders"))
+ .heading(gettext("Restore Folders"))
.body(restore_picker_body(
device.as_ref().map(|(_, n)| n.as_str()),
))
@@ -668,7 +643,7 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc, id: i64, mode: &'static str) {
Ok(Ok(Response::Ok { .. })) => toast(
&ui,
&if mode == "ondemand" {
- gettext("Folder will switch to on-demand")
+ gettext("Folder will switch to online only")
} else {
gettext("Folder will download and stay synced")
},
@@ -679,7 +654,7 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc, id: i64, mode: &'static str) {
_ => toast_error(
&ui,
&gettext("Couldn't change mode"),
- &gettext("The mount service didn't respond."),
+ &gettext("The Proton Drive service didn't respond."),
),
}
reload_sync_pages(&ui);
@@ -710,7 +685,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc, id: i64, path: &str, ondema
let body = if ondemand {
// Translators: {path} is a local folder path.
gettext_f(
- "Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton Drive, not on this disk, so the folder will be empty once it is unmounted. To keep a local copy, cancel, turn off On-demand, and wait for the download to finish before removing it.",
+ "Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
&[("path", path)],
)
} else {
@@ -721,7 +696,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc, id: i64, path: &str, ondema
)
};
let dialog = adw::AlertDialog::builder()
- .heading(gettext("Stop syncing folder"))
+ .heading(gettext("Stop Syncing Folder?"))
.body(body)
.build();
let group = adw::PreferencesGroup::new();
@@ -818,7 +793,7 @@ pub(crate) fn prompt_remove_device(ui: &Rc, uid: &str, name: &str) {
let group = adw::PreferencesGroup::new();
group.add(&confirm);
let dialog = adw::AlertDialog::builder()
- .heading(gettext("Remove Computer"))
+ .heading(gettext("Remove Computer?"))
// Translators: {name} is the name of the computer being removed.
.body(gettext_f(
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is deleted along with it. The files on that computer itself are not touched — but this cannot be undone from here.",
@@ -862,7 +837,7 @@ pub(crate) fn prompt_remove_device(ui: &Rc, uid: &str, name: &str) {
pub(crate) fn prompt_adopt_device(ui: &Rc, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
- .heading(gettext("Use this computer's identity"))
+ .heading(gettext("Use This Computer's Identity?"))
// Translators: {name} is the name of the other computer whose identity is adopted.
.body(gettext_f(
"Treat this machine as “{name}”?\n\nNew synced folders are created under that computer in Proton Drive, and this machine keeps that identity even if its hostname changes. Folders already synced here are not moved.\n\nUse “Restore folders” afterwards to bring back what “{name}” was syncing.",
@@ -870,7 +845,7 @@ pub(crate) fn prompt_adopt_device(ui: &Rc, uid: &str, name: &str) {
))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
- dialog.add_response("adopt", &gettext("Use identity"));
+ dialog.add_response("adopt", &gettext("Use Identity"));
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
@@ -909,7 +884,11 @@ pub(crate) fn run_devices_mutation(ui: &Rc, req: Request, done: &str, failed
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
- _ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
+ _ => toast_error(
+ &ui,
+ &failed,
+ &gettext("The Proton Drive service didn't respond."),
+ ),
}
});
}
diff --git a/crates/pdfs-gui/src/app/pages/duplicates.rs b/crates/pdfs-gui/src/app/pages/duplicates.rs
index cb60e3e..49c63c3 100644
--- a/crates/pdfs-gui/src/app/pages/duplicates.rs
+++ b/crates/pdfs-gui/src/app/pages/duplicates.rs
@@ -220,14 +220,14 @@ fn ask(view: &Rc, finder: Finder, generation: u32) {
view.status.set_icon_name(Some("dialog-warning-symbolic"));
view.status.set_title(&failed);
view.status
- .set_description(Some(&gettext("The mount service didn't respond.")));
+ .set_description(Some(&gettext("The Proton Drive service didn't respond.")));
view.progress.set_visible(false);
return;
}
};
view.progress.set_visible(false);
if sets.is_empty() {
- view.status.set_icon_name(Some("emblem-ok-symbolic"));
+ view.status.set_icon_name(Some("pdfs-synced-symbolic"));
match finder {
Finder::Exact => {
view.status.set_title(&gettext("No duplicates"));
diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs
index 10ea8ff..0375d8c 100644
--- a/crates/pdfs-gui/src/app/pages/locations.rs
+++ b/crates/pdfs-gui/src/app/pages/locations.rs
@@ -19,7 +19,7 @@ pub(crate) struct LocationsState {
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
- pub(crate) rows: RefCell>,
+ pub(crate) rows: RefCell>,
pub(crate) inflight: Cell,
pub(crate) loaded_at: Cell