feat(gui): polish pass over wording, icons and responsiveness

The Sync page gives each folder one card with its state, a Synced /
Online only switch and a menu for the rest; switching either way asks
first and the download prompt shows the free space on the disk.

Enabling, disabling and restarting the Proton Drive service ran
systemctl on GTK's main thread and froze the window. They now run in
the background, and every Retry button goes through one shared restart
helper.

Ten icons are bundled under resources/icons for glyphs no stock theme
has or that read ambiguously (sync, share, people, link, menu, map and
so on). A new test fails when the app or search prompt asks for a
symbolic icon that is neither bundled nor on a list checked against
Adwaita 50, libadwaita and GTK, or when a bundled icon is missing from
the resource manifest. The tray's sync icon, which Adwaita 50 no longer
ships, is now view-refresh.

Wording follows one glossary, recorded in docs/TRANSLATING.md: "Proton
Drive service" instead of "mount service" or "daemon", "Online only"
instead of "On-demand", "Proton Drive folder" instead of "mountpoint".
Dialog headings and buttons use title case and page titles match the
sidebar. Dates go through one formatter (app/dates.rs) and sizes
through i18n::human_bytes, and the cache label is translated.

The status tick stops while the window is hidden or minimized and runs
every fifth tick while it is unfocused, refreshing as soon as it gains
focus. "Sync complete" is announced only for large batches and only
when the window isn't focused. Smaller fixes: Empty Trash hides when
there is nothing to empty, copy buttons confirm with a check mark, the
sidebar status spins until the first report, and Photos moves Import
and Find Duplicates into a menu beside a labelled Select toggle.

All catalogs are updated and complete.
This commit is contained in:
nvrl committed 2026-09-28 13:26:59 +02:00
1 parent e872cad058
commit bd4f797d1f
48 files changed
+12684 -10991

No files matched your search

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- A copy is on this computer: a check in an outlined circle. Adwaita 50
no longer ships emblem-ok-symbolic. -->
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 8,2.5 A 5.5,5.5 0 1 1 8,13.5 A 5.5,5.5 0 1 1 8,2.5 Z"/>
<path fill="#222222" d="M 4.1,8.6 L 5.2,7.5 L 7,9.3 L 10.8,5.4 L 11.9,6.5 L 7,11.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 450 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Show or hide the details pane: a window with its right-hand pane
filled in. Some themes draw sidebar-show-right-symbolic as a solid block. -->
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
<path fill="#222222" d="M 9.5,5 H 12.5 V 11 H 9.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 564 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- A share link: two open chain ends and the bar that joins them, laid
on the diagonal. -->
<g transform="rotate(-45 8 8)">
<path fill="#222222" d="M 7,4.5 H 4.5 A 3.5,3.5 0 0 0 4.5,11.5 H 7 V 9.75 H 4.5 A 1.75,1.75 0 0 1 4.5,6.25 H 7 Z"/>
<path fill="#222222" d="M 9,4.5 H 11.5 A 3.5,3.5 0 0 1 11.5,11.5 H 9 V 9.75 H 11.5 A 1.75,1.75 0 0 0 11.5,6.25 H 9 Z"/>
<path fill="#222222" d="M 5,7.125 H 11 V 8.875 H 5 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 533 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Show on map: a folded map in three panels. No stock theme has a
map-symbolic. -->
<path fill="#222222" d="M 1,3.2 L 5,1.5 V 12.8 L 1,14.5 Z M 11,3.2 L 15,1.5 V 12.8 L 11,14.5 Z"/>
<path fill="#222222" opacity="0.55" d="M 6,1.5 L 10,3.2 V 14.5 L 6,12.8 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 441 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Shared with me: two people. system-users-symbolic survives only in
Adwaita's legacy folder. -->
<path fill="#222222" d="M 6,2.5 A 2.5,2.5 0 1 0 6,7.5 A 2.5,2.5 0 1 0 6,2.5 Z M 1.5,14.5 C 1.5,11 3.5,9 6,9 C 8.5,9 10.5,11 10.5,14.5 Z"/>
<path fill="#222222" d="M 11,2.4 A 2.1,2.1 0 1 0 11,6.6 A 2.1,2.1 0 1 0 11,2.4 Z M 9.8,8.4 C 10.2,8.3 10.6,8.25 11,8.25 C 13.3,8.25 15,10 15,13.5 H 11.8 C 11.7,11.5 11,9.7 9.8,8.4 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 529 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Shared by me: one node passing to two others. Adwaita 50 no longer
ships emblem-shared-symbolic. -->
<path fill="#222222" d="M 12,1.25 A 2.25,2.25 0 1 0 12,5.75 A 2.25,2.25 0 1 0 12,1.25 Z M 4,5.75 A 2.25,2.25 0 1 0 4,10.25 A 2.25,2.25 0 1 0 4,5.75 Z"/>
<path fill="#222222" d="M 4.37,8.65 L 12.37,4.15 L 11.63,2.85 L 3.63,7.35 Z"/>
<path fill="#222222" d="M 12,10.25 A 2.25,2.25 0 1 0 12,14.75 A 2.25,2.25 0 1 0 12,10.25 Z"/>
<path fill="#222222" d="M 3.63,8.65 L 11.63,13.15 L 12.37,11.85 L 4.37,7.35 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 621 B

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Syncing: two arrows chasing each other round a circle. Bundled because
Adwaita 50 no longer ships emblem-synchronizing-symbolic. -->
<path fill="#222222" d="M 2,8 A 6,6 0 0 1 12.91,4.56 L 11.48,5.56 A 4.25,4.25 0 0 0 3.75,8 Z"/>
<path fill="#222222" d="M 14.23,3.64 L 14.03,7.68 L 10.17,6.48 Z"/>
<g transform="rotate(180 8 8)">
<path fill="#222222" d="M 2,8 A 6,6 0 0 1 12.91,4.56 L 11.48,5.56 A 4.25,4.25 0 0 0 3.75,8 Z"/>
<path fill="#222222" d="M 14.23,3.64 L 14.03,7.68 L 10.17,6.48 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 619 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Up to date: a check knocked out of a filled circle. -->
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 4.1,8.6 L 5.2,7.5 L 7,9.3 L 10.8,5.4 L 11.9,6.5 L 7,11.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 305 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Sharing stopped: the share glyph with its last node crossed out. Not
a no-entry sign, which reads as an error. -->
<path fill="#222222" d="M 12,1.25 A 2.25,2.25 0 1 0 12,5.75 A 2.25,2.25 0 1 0 12,1.25 Z M 4,5.75 A 2.25,2.25 0 1 0 4,10.25 A 2.25,2.25 0 1 0 4,5.75 Z"/>
<path fill="#222222" d="M 4.37,8.65 L 12.37,4.15 L 11.63,2.85 L 3.63,7.35 Z"/>
<path fill="#222222" d="M 3.63,8.65 L 8.23,11.25 L 8.97,9.95 L 4.37,7.35 Z"/>
<path fill="#222222" d="M 9.28,11.02 L 13.48,15.22 L 14.72,13.98 L 10.52,9.78 Z"/>
<path fill="#222222" d="M 13.48,9.78 L 9.28,13.98 L 10.52,15.22 L 14.72,11.02 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 706 B

@@ -10,6 +10,16 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-filter-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-filter-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-cached-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-unshare-symbolic.svg</file>
<!-- The Proton theme's folder: a full-colour icon from the icon theme <!-- The Proton theme's folder: a full-colour icon from the icon theme
can't be recoloured by CSS. --> can't be recoloured by CSS. -->
<file preprocess="xml-stripblanks">scalable/places/pdfs-proton-folder.svg</file> <file preprocess="xml-stripblanks">scalable/places/pdfs-proton-folder.svg</file>
+9 -4
View File
@@ -20,6 +20,13 @@
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; } .sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
.sidebar-account { padding: 4px 4px 4px 8px; } .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 browser */
.file-grid { padding: 6px; } .file-grid { padding: 6px; }
.file-tile { padding: 8px; border-radius: 10px; } .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-tile:selected, .file-tile:hover:selected { background: color-mix(in srgb, var(--accent-bg-color) 20%, transparent); }
.file-thumbnail { border-radius: 7px; } .file-thumbnail { border-radius: 7px; }
.file-badge { -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); } .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-cached { color: var(--success-color); }
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); } .badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
.browser-statusbar { background-color: alpha(currentColor, 0.025); } .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-width: 104px; min-height: 6px; }
scale.browser-status-meter trough { min-height: 6px; }
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress { min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); } .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 { 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); } .dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
+139
View File
@@ -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");
}
}
+137 -32
View File
@@ -1,5 +1,6 @@
#[path = "../activation.rs"] #[path = "../activation.rs"]
pub(crate) mod activation; pub(crate) mod activation;
pub(crate) mod dates;
#[path = "../i18n.rs"] #[path = "../i18n.rs"]
pub(crate) mod i18n; pub(crate) mod i18n;
pub(crate) mod pages; pub(crate) mod pages;
@@ -28,7 +29,9 @@ use widgets::share_dialog::*;
use widgets::thumbnails::*; use widgets::thumbnails::*;
use widgets::versions_dialog::*; 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}; 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. /// How often the window re-reads mount status, cache usage and the pin list.
const REFRESH_INTERVAL: Duration = Duration::from_secs(2); 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 /// Backoff between auto-retries of a Files/Photos load while the mount service
/// is still coming up (see [`load_browser`] / [`load_gallery`]). /// is still coming up (see [`load_browser`] / [`load_gallery`]).
const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2); 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(), account_name: main_widgets.account_name.clone(),
avatar: main_widgets.avatar.clone(), avatar: main_widgets.avatar.clone(),
status_icon: main_widgets.status_icon.clone(), status_icon: main_widgets.status_icon.clone(),
status_spinner: main_widgets.status_spinner.clone(),
status_title: main_widgets.status_title.clone(), status_title: main_widgets.status_title.clone(),
status_detail: main_widgets.status_detail.clone(), status_detail: main_widgets.status_detail.clone(),
transfer_rows: RefCell::new(Vec::new()), transfer_rows: RefCell::new(Vec::new()),
@@ -549,6 +558,7 @@ fn build_window(app: &adw::Application) {
pins_state: RefCell::new(None), pins_state: RefCell::new(None),
notified_mounted: Cell::new(None), notified_mounted: Cell::new(None),
active_transfers: Cell::new(0), active_transfers: Cell::new(0),
transfer_batch: RefCell::new(None),
}, },
browser: BrowserState { browser: BrowserState {
model: browser_widgets.model.clone(), model: browser_widgets.model.clone(),
@@ -588,9 +598,6 @@ fn build_window(app: &adw::Application) {
zoom: browser_widgets.zoom.clone(), zoom: browser_widgets.zoom.clone(),
grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT), grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT),
grid_tiles: RefCell::new(Vec::new()), 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: DetailsState {
details: browser_widgets.details, details: browser_widgets.details,
@@ -627,7 +634,6 @@ fn build_window(app: &adw::Application) {
burst: Cell::new(false), burst: Cell::new(false),
import_banner: gallery_widgets.import_banner.clone(), import_banner: gallery_widgets.import_banner.clone(),
upload: gallery_widgets.upload.clone(), upload: gallery_widgets.upload.clone(),
import: gallery_widgets.import.clone(),
empty_actions: gallery_widgets.empty_actions.clone(), empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(), title: gallery_widgets.title.clone(),
albums: gallery_widgets.albums.clone(), albums: gallery_widgets.albums.clone(),
@@ -830,10 +836,6 @@ fn build_window(app: &adw::Application) {
&gallery_widgets.empty_upload, &gallery_widgets.empty_upload,
&gallery_widgets.empty_import, &gallery_widgets.empty_import,
); );
let ui_duplicates = ui.clone();
gallery_widgets
.duplicates
.connect_clicked(move |_| show_duplicates(&ui_duplicates));
wire_albums(&ui); wire_albums(&ui);
wire_places(&ui); wire_places(&ui);
wire_trash(&ui, &trash_widgets); wire_trash(&ui, &trash_widgets);
@@ -919,12 +921,33 @@ fn build_window(app: &adw::Application) {
refresh(&ui); refresh(&ui);
// Periodic refresh while the window lives. The closure holds a strong `Rc`; // 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 ui_tick = ui.clone();
let window_tick = window.downgrade();
let skipped = Cell::new(0u32);
let source = glib::timeout_add_local(REFRESH_INTERVAL, move || { 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); refresh(&ui_tick);
glib::ControlFlow::Continue 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)); let cell = RefCell::new(Some(source));
window.connect_close_request(move |_| { window.connect_close_request(move |_| {
if let Some(id) = cell.borrow_mut().take() { if let Some(id) = cell.borrow_mut().take() {
@@ -936,6 +959,22 @@ fn build_window(app: &adw::Application) {
window.present(); 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::<gtk4::gdk::Toplevel>()
.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, /// 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 /// 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 /// 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", "shared",
gettext_noop("Shared with me"), gettext_noop("Shared with me"),
"system-users-symbolic", "pdfs-people-symbolic",
), ),
( (
"sharedbyme", "sharedbyme",
gettext_noop("Shared by me"), gettext_noop("Shared by me"),
"emblem-shared-symbolic", "pdfs-share-symbolic",
), ),
("devices", gettext_noop("Computers"), "computer-symbolic"), ("devices", gettext_noop("Computers"), "computer-symbolic"),
("trash", gettext_noop("Trash"), "user-trash-symbolic"), ("trash", gettext_noop("Trash"), "user-trash-symbolic"),
// Translators: sidebar entry for the page listing this computer's synced folders. // Translators: sidebar entry for the page listing this computer's synced folders.
( ("locations", gettext_noop("Sync"), "pdfs-sync-symbolic"),
"locations",
gettext_noop("Sync"),
"emblem-synchronizing-symbolic",
),
( (
"activity", "activity",
gettext_noop("Activity"), gettext_noop("Activity"),
@@ -1358,7 +1393,7 @@ fn build_primary_menu() -> gtk4::MenuButton {
Some("win.about"), Some("win.about"),
); );
gtk4::MenuButton::builder() gtk4::MenuButton::builder()
.icon_name("open-menu-symbolic") .icon_name("pdfs-menu-symbolic")
.tooltip_text(gettext("Main menu")) .tooltip_text(gettext("Main menu"))
.primary(true) .primary(true)
.menu_model(&menu) .menu_model(&menu)
@@ -1383,12 +1418,22 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
sign_out_action.connect_activate(move |_, _| sign_out(&ui_out)); sign_out_action.connect_activate(move |_, _| sign_out(&ui_out));
window.add_action(&sign_out_action); 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 show_import = gio::SimpleAction::new("show-import", None);
let ui_import = ui.clone(); let ui_import = ui.clone();
show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout")); show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout"));
window.add_action(&show_import); 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. // The Albums view's New Album button.
let new_album = gio::SimpleAction::new("new-album", None); let new_album = gio::SimpleAction::new("new-album", None);
let ui_album = ui.clone(); let ui_album = ui.clone();
@@ -1502,7 +1547,7 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
confirm_stop.connect_activate(move |_, _| { confirm_stop.connect_activate(move |_, _| {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Stop Proton Drive?")) .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(); .build();
dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("stop", &pgettext("verb", "Stop")); dialog.add_response("stop", &pgettext("verb", "Stop"));
@@ -1523,7 +1568,7 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
toast_error( toast_error(
&ui, &ui,
&gettext("Couldn't stop Proton Drive"), &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<Ui>, then: impl FnOnce(&Rc<Ui>) + '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<Ui>,
page: &'static str,
status: impl Fn(&Rc<Ui>, &str, &str, &str, bool) + 'static,
reload: impl Fn(&Rc<Ui>) + '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: &gtk4::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 a local path with the handler the user configured — `xdg-open` unless
/// `open_with` in `config.json` overrides it for this kind of file. /// `open_with` in `config.json` overrides it for this kind of file.
fn open_path(path: &str) { 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); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+9 -45
View File
@@ -174,10 +174,7 @@ pub(crate) fn wire_activity(
filters: &[(ActivityFilter, gtk4::ToggleButton)], filters: &[(ActivityFilter, gtk4::ToggleButton)],
) { ) {
let ui_retry = ui.clone(); let ui_retry = ui.clone();
retry.connect_clicked(move |_| { retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_activity));
service::restart();
load_activity(&ui_retry);
});
for (filter, button) in filters { for (filter, button) in filters {
let ui = ui.clone(); let ui = ui.clone();
let filter = *filter; let filter = *filter;
@@ -272,7 +269,7 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
&gettext("Couldn't read activity"), &gettext("Couldn't read activity"),
&gettext("Unexpected reply from the mount service."), &gettext("Unexpected reply from the Proton Drive service."),
false, false,
), ),
Ok(Err(_)) | Err(_) => activity_unreachable(&ui), Ok(Err(_)) | Err(_) => activity_unreachable(&ui),
@@ -282,29 +279,7 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
/// The daemon didn't answer the Activity page. /// The daemon didn't answer the Activity page.
pub(crate) fn activity_unreachable(ui: &Rc<Ui>) { pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() { service_unreachable(ui, "activity", activity_status, load_activity);
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);
}
});
} }
/// Poll the conflicts still waiting for a decision while the Activity page is /// 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)); subtitle.push(capitalize(&a.detail));
} }
if row.count > 1 { if row.count > 1 {
let since = activity_time(row.first); let since = dates::relative(row.first);
// Translators: {time} is a date and time such as "Sep 21, 14:05". // Translators: {time} is a date or time such as "Sep 21" or "5 min ago".
subtitle.push(ngettext_f( subtitle.push(ngettext_f(
"{n} time since {time}", "{n} time since {time}",
"{n} times since {time}", "{n} times since {time}",
@@ -585,9 +560,7 @@ fn feed_row(
)); ));
time.add_css_class("dim-label"); time.add_css_class("dim-label");
time.add_css_class("numeric"); time.add_css_class("numeric");
if let Some(at) = at.and_then(|at| at.format("%c").ok()) { time.set_tooltip_text(Some(&dates::full(a.time)));
time.set_tooltip_text(Some(&at));
}
widget.add_suffix(&time); widget.add_suffix(&time);
if let Some(conflict) = pending { if let Some(conflict) = pending {
@@ -645,28 +618,19 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
match kind { match kind {
ActivityKind::Upload => "pdfs-upload-symbolic", ActivityKind::Upload => "pdfs-upload-symbolic",
ActivityKind::Download => "document-save-symbolic", ActivityKind::Download => "document-save-symbolic",
ActivityKind::Sync => "emblem-synchronizing-symbolic", ActivityKind::Sync => "pdfs-sync-symbolic",
ActivityKind::Rename => "document-edit-symbolic", ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic", ActivityKind::Move => "go-jump-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic", ActivityKind::CreateFolder => "folder-new-symbolic",
ActivityKind::Trash => "user-trash-symbolic", ActivityKind::Trash => "user-trash-symbolic",
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic", ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
ActivityKind::Restore => "edit-undo-symbolic", ActivityKind::Restore => "edit-undo-symbolic",
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic", ActivityKind::Share | ActivityKind::PublicLink => "pdfs-share-symbolic",
ActivityKind::Unshare => "action-unavailable-symbolic", ActivityKind::Unshare => "pdfs-unshare-symbolic",
ActivityKind::Conflict => "dialog-warning-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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+10 -6
View File
@@ -83,7 +83,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
&ui, &ui,
"network-offline-symbolic", "network-offline-symbolic",
&gettext("Not connected"), &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<Ui>, photos: Vec<String>) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't create the album"), &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<Ui>, request: Request, done: String, failed: String
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &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<Ui>, photos: Vec<String>) {
toast_error( toast_error(
&ui, &ui,
&gettext("Couldn't load albums"), &gettext("Couldn't load albums"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
); );
return; return;
} }
@@ -487,7 +491,7 @@ fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't add to the album"), &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<Ui>, album: &AlbumInfo, photos: Vec<Stri
toast_error( toast_error(
&ui, &ui,
&gettext("Couldn't remove from the album"), &gettext("Couldn't remove from the album"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
) )
} }
} }
+53 -102
View File
@@ -81,9 +81,6 @@ pub(crate) struct BrowserState {
/// Weak references to realised grid cells. Zoom resizes only these visible, /// Weak references to realised grid cells. Zoom resizes only these visible,
/// recycled surfaces instead of invalidating the whole list model. /// recycled surfaces instead of invalidating the whole list model.
pub(crate) grid_tiles: RefCell<Vec<(glib::WeakRef<gtk4::Overlay>, glib::WeakRef<gtk4::Label>)>>, pub(crate) grid_tiles: RefCell<Vec<(glib::WeakRef<gtk4::Overlay>, glib::WeakRef<gtk4::Label>)>>,
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 /// 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) thumbnail_status: gtk4::Label,
pub(crate) summary: gtk4::Label, pub(crate) summary: gtk4::Label,
pub(crate) zoom: gtk4::Scale, 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, pub(crate) refresh: gtk4::Button,
/// Wraps the views + the details pane; the pane shows the selection while its header toggle is on. /// Wraps the views + the details pane; the pane shows the selection while its header toggle is on.
pub(crate) split: adw::OverlaySplitView, pub(crate) split: adw::OverlaySplitView,
@@ -344,7 +338,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
let grid = gtk4::GridView::builder() let grid = gtk4::GridView::builder()
.model(&selection) .model(&selection)
.min_columns(2) .min_columns(2)
.max_columns(10) .max_columns(32)
.enable_rubberband(true) .enable_rubberband(true)
.build(); .build();
grid.add_css_class("file-grid"); grid.add_css_class("file-grid");
@@ -436,9 +430,8 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.child(&bulk_box) .child(&bulk_box)
.build(); .build();
// Mirror DolphinStatusBar's full-width order: contextual text (stretch 1), // The status bar: contextual text (stretch 1), then the icon-size slider.
// "Zoom:", its slider, then capacity information. Both visible troughs use // Account storage lives in the sidebar, so it isn't repeated here.
// the same CSS dimensions.
let summary = gtk4::Label::builder() let summary = gtk4::Label::builder()
.label(gettext("Loading…")) .label(gettext("Loading…"))
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
@@ -446,9 +439,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.ellipsize(gtk4::pango::EllipsizeMode::End) .ellipsize(gtk4::pango::EllipsizeMode::End)
.hexpand(true) .hexpand(true)
.build(); .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( let zoom = gtk4::Scale::with_range(
gtk4::Orientation::Horizontal, gtk4::Orientation::Horizontal,
f64::from(GRID_THUMB_MIN), 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"); zoom.add_css_class("browser-status-meter");
zoom.update_property(&[gtk4::accessible::Property::Label(&gettext("Icon size"))]);
let quota = gtk4::ProgressBar::new(); // Small and large icon glyphs at the ends of the slider say what it does
quota.set_hexpand(false); // without a text label.
quota.set_valign(gtk4::Align::Center); let zoom_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2);
quota.set_tooltip_text(Some(&gettext("Proton account storage"))); zoom_box.set_valign(gtk4::Align::Center);
quota.add_css_class("browser-status-meter"); zoom_box.append(&gtk4::Image::from_icon_name("zoom-out-symbolic"));
let quota_text = gtk4::Label::builder() zoom_box.append(&zoom);
.label(gettext("Loading…")) zoom_box.append(&gtk4::Image::from_icon_name("zoom-in-symbolic"));
.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(&quota);
quota_box.append(&quota_text);
quota_box.set_visible(false);
let status_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); let status_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
status_bar.add_css_class("browser-statusbar"); status_bar.add_css_class("browser-statusbar");
status_bar.set_margin_start(6); status_bar.set_margin_start(6);
status_bar.set_margin_end(2); status_bar.set_margin_end(2);
status_bar.append(&summary); status_bar.append(&summary);
status_bar.append(&zoom_label); status_bar.append(&zoom_box);
status_bar.append(&zoom);
status_bar.append(&quota_box);
let zoom_label_for_view = zoom_label.clone(); let zoom_for_view = zoom_box.clone();
let zoom_for_view = zoom.clone();
let content_for_view = content.clone(); let content_for_view = content.clone();
view_stack.connect_visible_child_name_notify(move |stack| { view_stack.connect_visible_child_name_notify(move |stack| {
let visible = content_for_view.visible_child_name().as_deref() == Some("views") let visible = content_for_view.visible_child_name().as_deref() == Some("views")
&& stack.visible_child_name().as_deref() == Some("grid"); && stack.visible_child_name().as_deref() == Some("grid");
zoom_label_for_view.set_visible(visible);
zoom_for_view.set_visible(visible); zoom_for_view.set_visible(visible);
}); });
let zoom_label_for_content = zoom_label.clone(); let zoom_for_content = zoom_box.clone();
let zoom_for_content = zoom.clone();
let view_for_content = view_stack.clone(); let view_for_content = view_stack.clone();
content.connect_visible_child_name_notify(move |stack| { content.connect_visible_child_name_notify(move |stack| {
let visible = stack.visible_child_name().as_deref() == Some("views") let visible = stack.visible_child_name().as_deref() == Some("views")
&& view_for_content.visible_child_name().as_deref() == Some("grid"); && view_for_content.visible_child_name().as_deref() == Some("grid");
zoom_label_for_content.set_visible(visible);
zoom_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(&gtk4::Separator::new(gtk4::Orientation::Horizontal)); page.append(&gtk4::Separator::new(gtk4::Orientation::Horizontal));
page.append(&status_bar); 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_start(&new_button);
header.pack_end(&folder_button); header.pack_end(&folder_button);
header.pack_end(&refresh); header.pack_end(&refresh);
@@ -559,9 +533,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
thumbnail_status, thumbnail_status,
summary, summary,
zoom, zoom,
quota_box,
quota,
quota_text,
refresh, refresh,
split, split,
details, details,
@@ -699,7 +670,8 @@ pub(crate) fn run_bulk_delete(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
failure.get_or_insert(message); 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<Ui>, entries: Vec<DirEntry>, pin: bool) {
failure.get_or_insert(message); 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<Ui>, grid: &gtk4::GridView, column_view: &gtk
} }
})); }));
column_view.append_column(&text_column(&pgettext("column", "Modified"), |e| { 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. // Search hits come from anywhere in the Drive; this says where.
let location = text_column(&gettext(LOCATION_COLUMN), |e| hit_location(&e.path)); 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 { fn hit_location(path: &str) -> String {
match path.rsplit_once('/') { match path.rsplit_once('/') {
Some((parent, _)) => parent.to_string(), 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<Ui>, entry: &DirEntry) {
toast_error( toast_error(
ui, ui,
&gettext("Couldn't open file"), &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; return;
}; };
@@ -1427,7 +1400,7 @@ pub(crate) fn copy_entry_link(ui: &Rc<Ui>, entry: &DirEntry) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't copy link"), &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<Ui>, entry: &DirEntry) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't open file"), &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<Ui>, entry: &DirEntry) {
toast_error( toast_error(
&ui, &ui,
&gettext("Couldn't change offline state"), &gettext("Couldn't change offline state"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
); );
load_browser(&ui); load_browser(&ui);
} }
@@ -1618,9 +1591,12 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
} else { } else {
path.split('/').collect() path.split('/').collect()
}; };
ui.browser ui.browser.crumb.append(&crumb_node(
.crumb ui,
.append(&crumb_node(ui, "Proton Drive", "", segments.is_empty())); &gettext("My files"),
"",
segments.is_empty(),
));
let mut acc = String::new(); let mut acc = String::new();
for (i, seg) in segments.iter().enumerate() { for (i, seg) in segments.iter().enumerate() {
let sep = gtk4::Label::new(Some("›")); let sep = gtk4::Label::new(Some("›"));
@@ -2003,7 +1979,7 @@ fn start_thumbnail_build(ui: &Rc<Ui>) {
toast_failure(&ui, &gettext("Couldn't build thumbnails"), &message, kind); 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); thumbnail_build_failed(&ui);
toast_error(&ui, &gettext("Couldn't build thumbnails"), &message); toast_error(&ui, &gettext("Couldn't build thumbnails"), &message);
} }
@@ -2041,7 +2017,7 @@ fn cancel_thumbnail_build(ui: &Rc<Ui>) {
toast_error( toast_error(
&ui, &ui,
&gettext("Couldn't cancel thumbnail build"), &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<Ui>) {
toast_error( toast_error(
&ui_result, &ui_result,
&gettext("Thumbnail build stopped"), &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<Ui>, req: Request, done: String, failed: &'st
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &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<Ui>, sources: Vec<String>, target: String) {
_ => { _ => {
failure.get_or_insert_with(|| { failure.get_or_insert_with(|| {
( (
gettext("The mount service didn't respond."), gettext("The Proton Drive service didn't respond."),
ErrorKind::Internal, ErrorKind::Internal,
) )
}); });
@@ -2583,7 +2563,7 @@ pub(crate) fn prompt_new_folder(ui: &Rc<Ui>) {
let win = ui_window(ui); let win = ui_window(ui);
let parent = ui.browser.path.borrow().clone(); let parent = ui.browser.path.borrow().clone();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("New folder")) .heading(gettext("New Folder"))
.body(gettext("Create a folder in the current directory.")) .body(gettext("Create a folder in the current directory."))
.build(); .build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
@@ -2703,7 +2683,7 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't upload"), &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 { Some(if entry.pinned {
("pdfs-offline-symbolic", "badge-pinned") ("pdfs-offline-symbolic", "badge-pinned")
} else if entry.cached { } else if entry.cached {
("emblem-ok-symbolic", "badge-cached") ("pdfs-cached-symbolic", "badge-cached")
} else { } else {
("pdfs-online-only-symbolic", "badge-cloud") ("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. /// Request the current browser directory from the daemon and repaint both views.
pub(crate) fn load_browser(ui: &Rc<Ui>) { pub(crate) fn load_browser(ui: &Rc<Ui>) {
cancel_file_thumbnails(ui); cancel_file_thumbnails(ui);
@@ -2886,7 +2854,7 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind), Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind),
Ok(Ok(_)) => browser_failed( Ok(Ok(_)) => browser_failed(
&ui, &ui,
&gettext("Unexpected reply from the mount service."), &gettext("Unexpected reply from the Proton Drive service."),
ErrorKind::Internal, ErrorKind::Internal,
), ),
Ok(Err(_)) | Err(_) => browser_unreachable(&ui), Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
@@ -2915,32 +2883,15 @@ pub(crate) fn browser_failed(ui: &Rc<Ui>, message: &str, kind: ErrorKind) {
/// button) from *down* (actionable error + Retry), so a cold start self-heals /// button) from *down* (actionable error + Retry), so a cold start self-heals
/// once the systemd mount comes up but a real failure stays visible. /// once the systemd mount comes up but a real failure stays visible.
pub(crate) fn browser_unreachable(ui: &Rc<Ui>) { pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() { service_unreachable(
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(
ui, ui,
"folder-remote-symbolic", "browser",
&gettext("Connecting…"), |ui, icon, title, description, retry| {
&gettext("Waiting for the Proton Drive mount service to come up."), ui.browser.summary.set_label(title);
false, 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 — /// Repopulate the shared model — folders first, then case-insensitive by name —
@@ -3072,7 +3023,7 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind), Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind),
Ok(Ok(_)) => browser_failed( Ok(Ok(_)) => browser_failed(
&ui, &ui,
&gettext("Unexpected reply from the mount service."), &gettext("Unexpected reply from the Proton Drive service."),
ErrorKind::Internal, ErrorKind::Internal,
), ),
Ok(Err(_)) | Err(_) => browser_unreachable(&ui), Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
@@ -3204,7 +3155,7 @@ mod tests {
#[test] #[test]
fn a_search_hit_location_is_its_parent_folder() { fn a_search_hit_location_is_its_parent_folder() {
assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old"); 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] #[test]
+19 -40
View File
@@ -126,10 +126,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
/// Install the Devices page's retry button and the Rename action. /// Install the Devices page's retry button and the Rename action.
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button) { pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button) {
let ui_retry = ui.clone(); let ui_retry = ui.clone();
retry.connect_clicked(move |_| { retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_devices));
service::restart();
load_devices(&ui_retry);
});
let ui_ren = ui.clone(); let ui_ren = ui.clone();
ui.devices.rename_this.connect_clicked(move |_| { ui.devices.rename_this.connect_clicked(move |_| {
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() { if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
@@ -202,29 +199,7 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
/// The daemon didn't answer the Devices page. /// The daemon didn't answer the Devices page.
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) { pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() { service_unreachable(ui, "devices", devices_status, load_devices);
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);
}
});
} }
/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal /// 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} · last synced {time}",
&[ &[
("platform", &dev.device_type), ("platform", &dev.device_type),
("time", &activity_time(secs)), ("time", &dates::relative(secs)),
], ],
), ),
// Translators: {platform} is the device's platform, such as "Linux". // Translators: {platform} is the device's platform, such as "Linux".
@@ -358,7 +333,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic")); row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let manage = gtk4::Button::builder() let manage = gtk4::Button::builder()
.label(gettext("Open Sync")) .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) .valign(gtk4::Align::Center)
.build(); .build();
manage.add_css_class("flat"); manage.add_css_class("flat");
@@ -489,7 +464,7 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't add folder"), &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<Ui>, device: Option<(String, String
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't list folders to restore"), &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) .child(&group)
.build(); .build();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Restore folders")) .heading(gettext("Restore Folders"))
.body(restore_picker_body( .body(restore_picker_body(
device.as_ref().map(|(_, n)| n.as_str()), device.as_ref().map(|(_, n)| n.as_str()),
)) ))
@@ -668,7 +643,7 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
Ok(Ok(Response::Ok { .. })) => toast( Ok(Ok(Response::Ok { .. })) => toast(
&ui, &ui,
&if mode == "ondemand" { &if mode == "ondemand" {
gettext("Folder will switch to on-demand") gettext("Folder will switch to online only")
} else { } else {
gettext("Folder will download and stay synced") gettext("Folder will download and stay synced")
}, },
@@ -679,7 +654,7 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't change mode"), &gettext("Couldn't change mode"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
reload_sync_pages(&ui); reload_sync_pages(&ui);
@@ -710,7 +685,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
let body = if ondemand { let body = if ondemand {
// Translators: {path} is a local folder path. // Translators: {path} is a local folder path.
gettext_f( 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)], &[("path", path)],
) )
} else { } else {
@@ -721,7 +696,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
) )
}; };
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Stop syncing folder")) .heading(gettext("Stop Syncing Folder?"))
.body(body) .body(body)
.build(); .build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
@@ -818,7 +793,7 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
group.add(&confirm); group.add(&confirm);
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Remove Computer")) .heading(gettext("Remove Computer?"))
// Translators: {name} is the name of the computer being removed. // Translators: {name} is the name of the computer being removed.
.body(gettext_f( .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.", "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<Ui>, uid: &str, name: &str) {
pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) { pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = adw::AlertDialog::builder() 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. // Translators: {name} is the name of the other computer whose identity is adopted.
.body(gettext_f( .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.", "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<Ui>, uid: &str, name: &str) {
)) ))
.build(); .build();
dialog.add_response("cancel", &gettext("Cancel")); 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_response_appearance("adopt", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -909,7 +884,11 @@ pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &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."),
),
} }
}); });
} }
+2 -2
View File
@@ -220,14 +220,14 @@ fn ask(view: &Rc<FinderView>, finder: Finder, generation: u32) {
view.status.set_icon_name(Some("dialog-warning-symbolic")); view.status.set_icon_name(Some("dialog-warning-symbolic"));
view.status.set_title(&failed); view.status.set_title(&failed);
view.status 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); view.progress.set_visible(false);
return; return;
} }
}; };
view.progress.set_visible(false); view.progress.set_visible(false);
if sets.is_empty() { if sets.is_empty() {
view.status.set_icon_name(Some("emblem-ok-symbolic")); view.status.set_icon_name(Some("pdfs-synced-symbolic"));
match finder { match finder {
Finder::Exact => { Finder::Exact => {
view.status.set_title(&gettext("No duplicates")); view.status.set_title(&gettext("No duplicates"));
+391 -217
View File
@@ -19,7 +19,7 @@ pub(crate) struct LocationsState {
pub(crate) status: adw::StatusPage, pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button, pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup, pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>, pub(crate) rows: RefCell<Vec<LocationRow>>,
pub(crate) inflight: Cell<bool>, pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>, pub(crate) loaded_at: Cell<Option<Instant>>,
pub(crate) card: SyncCard, pub(crate) card: SyncCard,
@@ -36,6 +36,28 @@ pub(crate) struct SyncCard {
pub(crate) paused: Cell<bool>, pub(crate) paused: Cell<bool>,
} }
/// What a folder row's controls are built from. The subtitle and progress bar
/// change on every tick of a running pass and are updated in place; anything
/// in here changes which controls the row has, so a new key rebuilds it.
pub(crate) type LocationKey = (
i64,
MountKind,
String,
MountMode,
Option<MountMode>,
bool,
MountAccess,
);
/// One row of the folder list, kept so the refresh tick can update it in place
/// instead of replacing it under the pointer every two seconds.
pub(crate) struct LocationRow {
/// `None` for the "No locations" placeholder.
key: Option<LocationKey>,
row: adw::ActionRow,
progress: gtk4::ProgressBar,
}
/// What a queue row shows that can change: id, attempts, next attempt, parked. /// What a queue row shows that can change: id, attempts, next attempt, parked.
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool); pub(crate) type QueueKey = (i64, i64, Option<i64>, bool);
@@ -106,24 +128,24 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
.visible(false) .visible(false)
.build(); .build();
// Same warning the Computers page carried, for the same reason: the
// on-demand switch removes the local copy, which is not a thing to discover
// after the fact.
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title(gettext("On this computer")) .title(gettext("Folders"))
.description(gettext("Where Proton Drive lives on this machine. Synced folders keep a full copy on this disk; on-demand folders keep the files in Proton Drive only and fetch them as you open them.")) .description(gettext("Synced folders keep a full copy on this computer. Online-only folders fetch each file when you open it."))
.build(); .build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); // The folder list is the page's settings, so it sits right under what needs
// a decision; the transfer and queue lists come and go below it instead of
// pushing it down.
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
groups.append(&card); groups.append(&card);
groups.append(&conflicts_group); groups.append(&conflicts_group);
groups.append(&queue_group);
groups.append(&transfers_group);
groups.append(&group); groups.append(&group);
groups.append(&transfers_group);
groups.append(&queue_group);
let clamp = adw::Clamp::builder().child(&groups).build(); let clamp = adw::Clamp::builder().child(&groups).build();
// Never scroll sideways: a location's title is a full path, and letting the // Never scroll sideways: a location's subtitle holds a full path, and letting
// row grow to fit one pushes the controls at its end off screen. Constrained, // the row grow to fit one pushes the controls at its end off screen.
// the path ellipsizes and the switch/remove/open stay reachable. // Constrained, the path ellipsizes and the controls stay reachable.
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true) .vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never) .hscrollbar_policy(gtk4::PolicyType::Never)
@@ -209,12 +231,12 @@ fn build_sync_card() -> (
adw::SplitButton, adw::SplitButton,
) { ) {
let icon = gtk4::Image::builder() let icon = gtk4::Image::builder()
.icon_name("emblem-synchronizing-symbolic") .icon_name("pdfs-sync-symbolic")
.pixel_size(32) .pixel_size(32)
.margin_top(6) .margin_top(6)
.margin_bottom(6) .margin_bottom(6)
.build(); .build();
icon.add_css_class("sidebar-status"); icon.add_css_class("sync-card-icon");
let menu = gio::Menu::new(); let menu = gio::Menu::new();
menu.append( menu.append(
Some(&gettext("Pause for 1 Hour")), Some(&gettext("Pause for 1 Hour")),
@@ -242,7 +264,9 @@ fn build_sync_card() -> (
.title_lines(1) .title_lines(1)
.subtitle_lines(2) .subtitle_lines(2)
.build(); .build();
row.add_css_class("property"); // The state is the headline and the detail explains it, so the title is
// large and the subtitle stays the usual dim line.
row.add_css_class("sync-card");
row.add_prefix(&icon); row.add_prefix(&icon);
row.add_suffix(&pause); row.add_suffix(&pause);
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
@@ -339,7 +363,11 @@ fn set_sync_paused(ui: &Rc<Ui>, paused: bool, until: Option<i64>) {
}, },
), ),
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, &what, &message, kind), Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, &what, &message, kind),
_ => toast_error(&ui, &what, &gettext("The mount service didn't respond.")), _ => toast_error(
&ui,
&what,
&gettext("The Proton Drive service didn't respond."),
),
} }
ui.locations.card.pause.set_sensitive(true); ui.locations.card.pause.set_sensitive(true);
refresh_status(&ui); refresh_status(&ui);
@@ -438,7 +466,7 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
"rename" => ("document-edit-symbolic", gettext("Rename or move")), "rename" => ("document-edit-symbolic", gettext("Rename or move")),
"trash" => ("user-trash-symbolic", gettext("Move to trash")), "trash" => ("user-trash-symbolic", gettext("Move to trash")),
// Translators: a queued change of an unknown kind. // Translators: a queued change of an unknown kind.
_ => ("emblem-synchronizing-symbolic", pgettext("noun", "Change")), _ => ("pdfs-sync-symbolic", pgettext("noun", "Change")),
}; };
let state = match (op.parked, op.next_attempt_at) { let state = match (op.parked, op.next_attempt_at) {
(true, _) => gettext("waiting for the app writing it to finish"), (true, _) => gettext("waiting for the app writing it to finish"),
@@ -483,7 +511,7 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
let waiting = !op.parked && op.attempts > 0 && op.next_attempt_at.is_some_and(|at| at > now); let waiting = !op.parked && op.attempts > 0 && op.next_attempt_at.is_some_and(|at| at > now);
if waiting { if waiting {
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
.icon_name("view-refresh-symbolic") .label(gettext("Retry"))
.tooltip_text(gettext("Retry now")) .tooltip_text(gettext("Retry now"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
@@ -513,7 +541,7 @@ fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't retry"), &gettext("Couldn't retry"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
@@ -584,12 +612,12 @@ fn repaint_conflicts(ui: &Rc<Ui>, items: &[ConflictInfo]) {
/// "12.3 MB, changed at 14:30": one side of a conflict. /// "12.3 MB, changed at 14:30": one side of a conflict.
fn conflict_side(size: u64, modified: i64) -> String { fn conflict_side(size: u64, modified: i64) -> String {
// Translators: one side of a conflict; {size} is a file size such as "12.3 MB", {time} when it was changed, such as "14:30". // Translators: one side of a conflict; {size} is a file size such as "12.3 MB", {time} when it was changed, such as "5 min ago" or "Sep 21".
gettext_f( gettext_f(
"{size}, changed {time}", "{size}, changed {time}",
&[ &[
("size", &glib::format_size(size)), ("size", &human_bytes(size)),
("time", &clock_time(modified)), ("time", &dates::relative(modified)),
], ],
) )
} }
@@ -657,7 +685,7 @@ pub(crate) fn prompt_resolve_conflict(ui: &Rc<Ui>, conflict: &ConflictInfo) {
} }
}; };
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Resolve conflict")) .heading(gettext("Resolve Conflict"))
.body(body) .body(body)
.build(); .build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
@@ -736,7 +764,7 @@ fn resolve_conflict(ui: &Rc<Ui>, path: String, keep: ConflictKeep) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't resolve the conflict"), &gettext("Couldn't resolve the conflict"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
@@ -837,218 +865,312 @@ pub(crate) fn refresh_locations(ui: &Rc<Ui>) {
/// The daemon didn't answer the Locations page. /// The daemon didn't answer the Locations page.
pub(crate) fn locations_unreachable(ui: &Rc<Ui>) { pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() { service_unreachable(ui, "locations", locations_status, load_locations);
locations_status(
ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive mount service isn't running."),
true,
);
return;
}
locations_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("locations") {
load_locations(&ui);
}
});
} }
/// Paint the folder list. The rows are rebuilt only when what they offer
/// changes (a mode switch, a pause, a folder added); otherwise the tick updates
/// their subtitle and progress in place, so a menu, tooltip or keyboard focus
/// on a row survives the refresh.
pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) { pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
for row in ui.locations.rows.borrow_mut().drain(..) { let keys: Vec<Option<LocationKey>> = if items.is_empty() {
ui.locations.group.remove(&row); vec![None]
} else {
items.iter().map(|spec| Some(location_key(spec))).collect()
};
let unchanged = {
let rows = ui.locations.rows.borrow();
rows.len() == keys.len() && rows.iter().zip(&keys).all(|(row, key)| row.key == *key)
};
if !unchanged {
rebuild_locations(ui, items, keys);
} }
for (row, spec) in ui.locations.rows.borrow().iter().zip(items) {
update_location_row(row, spec);
}
}
fn location_key(spec: &MountSpec) -> LocationKey {
(
spec.id,
spec.kind.clone(),
spec.local_path.clone(),
spec.mode,
spec.pending_mode,
spec.paused,
spec.access,
)
}
fn rebuild_locations(ui: &Rc<Ui>, items: &[MountSpec], keys: Vec<Option<LocationKey>>) {
for row in ui.locations.rows.borrow_mut().drain(..) {
ui.locations.group.remove(&row.row);
}
let mut rows = Vec::new();
if items.is_empty() { if items.is_empty() {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(gettext("No locations")) .title(gettext("No folders yet"))
.subtitle(gettext( .subtitle(gettext(
"The mount service hasn't reported a mountpoint yet.", "The Proton Drive service hasn't reported its folder yet.",
)) ))
.build(); .build();
row.add_prefix(&gtk4::Image::from_icon_name("drive-harddisk-symbolic")); row.add_prefix(&gtk4::Image::from_icon_name("folder-symbolic"));
ui.locations.group.add(&row); ui.locations.group.add(&row);
*ui.locations.rows.borrow_mut() = vec![row.upcast()]; rows.push(LocationRow {
return; key: None,
row,
progress: gtk4::ProgressBar::new(),
});
} }
for (spec, key) in items.iter().zip(keys) {
let mut rows: Vec<gtk4::Widget> = Vec::new(); let row = location_row(ui, spec);
for spec in items { ui.locations.group.add(&row.row);
let row = adw::ActionRow::builder() rows.push(LocationRow { key, ..row });
.title(&spec.local_path)
.subtitle(location_subtitle(spec))
// One line each: a wrapped path would reflow the whole list every
// time a sync state changed under it.
.title_lines(1)
.subtitle_lines(1)
.build();
row.add_prefix(&gtk4::Image::from_icon_name(location_icon(&spec.kind)));
// A read-only location cannot be written through even where the files are
// visible; saying so on the row is cheaper than letting the user find out
// from a save dialog.
if spec.access == MountAccess::Ro {
let badge = gtk4::Label::new(Some(&gettext("Read-only")));
badge.add_css_class("dim-label");
badge.add_css_class("caption");
badge.set_valign(gtk4::Align::Center);
row.add_suffix(&badge);
}
// Same rule as the Computers page: a first pass has no estimate to draw
// against, so the bar appears only once real counts exist.
if let Some(p) = &spec.progress
&& p.total > 0
{
let bar = gtk4::ProgressBar::builder()
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
.valign(gtk4::Align::Center)
.width_request(120)
.build();
row.add_suffix(&bar);
}
match &spec.kind {
MountKind::MyFiles => add_my_files_controls(ui, &row),
MountKind::Device { sync_folder_id } => {
add_device_controls(ui, &row, spec, *sync_folder_id)
}
// A standalone shared mount has no local mode to switch and is not
// this device's to remove.
MountKind::Shared { .. } => {}
}
let open = gtk4::Button::builder()
.icon_name("folder-open-symbolic")
.tooltip_text(gettext("Open this folder"))
.valign(gtk4::Align::Center)
.build();
open.add_css_class("flat");
let path = spec.local_path.clone();
open.connect_clicked(move |_| open_path(&path));
row.add_suffix(&open);
ui.locations.group.add(&row);
rows.push(row.upcast());
} }
*ui.locations.rows.borrow_mut() = rows; *ui.locations.rows.borrow_mut() = rows;
} }
/// The primary mount's only control: where it lives. Changing it rewrites config /// The parts of a folder row that move while a pass runs.
/// and offers a service restart, which is why it goes through the shared fn update_location_row(row: &LocationRow, spec: &MountSpec) {
/// [`prompt_mountpoint`] dialog. // Translators: a synced folder's location and state, such as "~/Documents · Synced · up to date".
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) { let subtitle = gettext_f(
let change = gtk4::Button::builder() "{path} · {state}",
.label(pgettext("verb", "Change")) &[
.tooltip_text(gettext( ("path", &tilde_path(&spec.local_path)),
"Choose a different folder for the Proton Drive mount", ("state", &location_subtitle(spec)),
)) ],
.valign(gtk4::Align::Center) );
.build(); let subtitle = glib::markup_escape_text(&subtitle);
change.add_css_class("flat"); if row.row.subtitle().as_deref() != Some(subtitle.as_str()) {
let ui_mp = ui.clone(); row.row.set_subtitle(&subtitle);
change.connect_clicked(move |_| prompt_mountpoint(&ui_mp)); }
row.add_suffix(&change); // A first pass has no estimate to draw against, so the bar appears only
// once real counts exist.
match &spec.progress {
Some(p) if p.total > 0 => {
row.progress
.set_fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0));
row.progress.set_visible(true);
}
_ => row.progress.set_visible(false),
}
} }
/// Pause or resume and Sync now (mirror only), the on-demand switch, and Stop /// A folder row: its name, where it is and what it is doing, the folder's mode
/// syncing for one synced folder. /// as a button that names it, and one menu for everything else. Activating the
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) { /// row opens the folder.
if spec.mode != MountMode::OnDemand { fn location_row(ui: &Rc<Ui>, spec: &MountSpec) -> LocationRow {
let (icon, tooltip) = match spec.paused { let row = adw::ActionRow::builder()
true => ( .title(glib::markup_escape_text(&location_name(spec)).as_str())
"media-playback-start-symbolic", .tooltip_text(&spec.local_path)
gettext("Resume syncing this folder"), // One line each: a wrapped path would reflow the whole list every time
), // a sync state changed under it.
false => ( .title_lines(1)
"media-playback-pause-symbolic", .subtitle_lines(1)
gettext("Pause syncing this folder"), .activatable(true)
), .build();
}; row.add_prefix(&gtk4::Image::from_icon_name(location_icon(&spec.kind)));
let pause = gtk4::Button::builder() let path = spec.local_path.clone();
.icon_name(icon) row.connect_activated(move |_| open_path(&path));
.tooltip_text(tooltip)
.valign(gtk4::Align::Center) // A read-only location cannot be written through even where the files are
.build(); // visible; saying so on the row is cheaper than letting the user find out
pause.add_css_class("flat"); // from a save dialog.
let ui_pause = ui.clone(); if spec.access == MountAccess::Ro {
let paused = spec.paused; let badge = gtk4::Label::new(Some(&gettext("Read-only")));
pause.connect_clicked(move |_| set_folder_paused(&ui_pause, id, !paused)); badge.add_css_class("dim-label");
row.add_suffix(&pause); badge.add_css_class("caption");
} badge.set_valign(gtk4::Align::Center);
// A paused folder skips its passes, so Sync now would do nothing. row.add_suffix(&badge);
if spec.mode != MountMode::OnDemand && !spec.paused {
let sync_now = gtk4::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text(gettext("Sync this folder now"))
.valign(gtk4::Align::Center)
.build();
sync_now.add_css_class("flat");
let ui_sync = ui.clone();
sync_now.connect_clicked(move |_| sync_folder_now(&ui_sync, id));
row.add_suffix(&sync_now);
} }
// A queued switch paints as already flipped: the daemon accepted it and will let progress = gtk4::ProgressBar::builder()
// act on it, so snapping back would read as "it didn't take". The state is
// set before the handler is wired so painting the current mode doesn't fire
// a spurious request.
let target = spec.pending_mode.unwrap_or(spec.mode);
let ondemand = gtk4::Switch::builder()
.tooltip_text(gettext("On-demand: free this disk by keeping the files in Proton Drive only, fetching each as you open it. Turn off to download them back and keep a full local copy."))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.active(target == MountMode::OnDemand) .width_request(120)
.visible(false)
.build();
row.add_suffix(&progress);
let mut menu = ActionMenu::new();
let path = spec.local_path.clone();
menu.item(&gettext("Open in Files"), move || open_path(&path));
menu.section();
match &spec.kind {
MountKind::MyFiles => {
let ui = ui.clone();
menu.item(&gettext("Change Location…"), move || {
prompt_mountpoint(&ui)
});
}
MountKind::Device { sync_folder_id } => {
row.add_suffix(&mode_button(ui, spec, *sync_folder_id));
add_device_items(ui, &mut menu, spec, *sync_folder_id);
}
// A standalone shared mount has no local mode to switch and is not
// this device's to remove.
MountKind::Shared { .. } => {}
}
row.add_suffix(&menu.button());
let row = LocationRow {
key: None,
row,
progress,
};
update_location_row(&row, spec);
row
}
/// What a folder row is called: the folder's own name, which is the part of
/// its path a person recognises.
fn location_name(spec: &MountSpec) -> String {
Path::new(&spec.local_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&spec.local_path)
.to_string()
}
/// `path` with the home directory written as `~`.
pub(crate) fn tilde_path(path: &str) -> String {
let home = glib::home_dir();
match Path::new(path).strip_prefix(&home) {
Ok(rest) if rest.as_os_str().is_empty() => "~".to_string(),
Ok(rest) => format!("~/{}", rest.display()),
Err(_) => path.to_string(),
}
}
/// The button naming a synced folder's mode, whose popover switches it. It
/// shows the mode the folder is heading for: a queued switch was accepted and
/// will happen, so showing the old mode would read as "it didn't take".
fn mode_button(ui: &Rc<Ui>, spec: &MountSpec, id: i64) -> gtk4::MenuButton {
let target = spec.pending_mode.unwrap_or(spec.mode);
let ondemand = target == MountMode::OnDemand;
let button = gtk4::MenuButton::builder()
.label(folder_mode_label(ondemand))
.tooltip_text(gettext(
"Choose whether this folder keeps a full copy on this computer",
))
.valign(gtk4::Align::Center)
.always_show_arrow(true)
// The daemon refuses a mode switch on a paused folder. // The daemon refuses a mode switch on a paused folder.
.sensitive(!spec.paused) .sensitive(!spec.paused)
.build(); .build();
let ui_mode = ui.clone(); button.add_css_class("flat");
let mode_path = spec.local_path.clone();
ondemand.connect_state_set(move |_, on| {
if on {
// Going on-demand deletes the local copies, so it is asked for rather
// than done on a flick of the switch. Stop leaves the switch's state
// alone; the next repaint paints whatever the daemon then reports.
confirm_ondemand(&ui_mode, id, &mode_path);
glib::Propagation::Stop
} else {
set_sync_folder_mode(&ui_mode, id, "mirror");
glib::Propagation::Proceed
}
});
row.add_suffix(&ondemand);
let remove = gtk4::Button::builder() let options = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
.icon_name("media-playback-stop-symbolic") let popover = gtk4::Popover::builder().child(&options).build();
.tooltip_text(gettext("Stop syncing this folder")) popover.add_css_class("menu");
.valign(gtk4::Align::Center) for (choice, title, description) in [
.build(); (
remove.add_css_class("flat"); false,
let ui_rm = ui.clone(); folder_mode_label(false),
gettext("Keep every file on this computer and in Proton Drive."),
),
(
true,
folder_mode_label(true),
gettext("Keep the files in Proton Drive and download each one when you open it."),
),
] {
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
text.append(
&gtk4::Label::builder()
.label(&title)
.xalign(0.0)
.css_classes(["heading"])
.build(),
);
text.append(
&gtk4::Label::builder()
.label(&description)
.xalign(0.0)
.wrap(true)
.max_width_chars(36)
.css_classes(["caption", "dim-label"])
.build(),
);
let check = gtk4::Image::from_icon_name("object-select-symbolic");
check.set_opacity(if choice == ondemand { 1.0 } else { 0.0 });
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
content.append(&text);
content.append(&check);
text.set_hexpand(true);
let option = gtk4::Button::builder().child(&content).build();
option.add_css_class("flat");
let ui = ui.clone();
let path = spec.local_path.clone();
let popover_ref = popover.downgrade();
option.connect_clicked(move |_| {
if let Some(popover) = popover_ref.upgrade() {
popover.popdown();
}
if choice == ondemand {
return;
}
if choice {
confirm_online_only(&ui, id, &path);
} else {
confirm_synced(&ui, id, &path);
}
});
options.append(&option);
}
button.set_popover(Some(&popover));
button
}
/// A synced folder's mode, as the row's mode button names it.
fn folder_mode_label(ondemand: bool) -> String {
if ondemand {
// Translators: a folder mode: files are kept in Proton Drive and fetched when opened.
pgettext("folder mode", "Online only")
} else {
// Translators: a folder mode: a full copy is kept on this computer.
pgettext("folder mode", "Synced")
}
}
/// Sync now, pause or resume (synced folders only), and Stop syncing, in a
/// synced folder's menu.
fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i64) {
if spec.mode != MountMode::OnDemand {
// A paused folder skips its passes, so Sync now would do nothing.
if !spec.paused {
let ui = ui.clone();
menu.item(&gettext("Sync Now"), move || sync_folder_now(&ui, id));
}
let paused = spec.paused;
let label = if paused {
gettext("Resume Syncing")
} else {
gettext("Pause Syncing")
};
let ui = ui.clone();
menu.item(&label, move || set_folder_paused(&ui, id, !paused));
menu.section();
}
let ui = ui.clone();
let path = spec.local_path.clone(); let path = spec.local_path.clone();
// The folder's *current* mode, not a queued one: a switch that hasn't landed // The folder's *current* mode, not a queued one: a switch that hasn't landed
// yet has not moved the files anywhere. // yet has not moved the files anywhere.
let is_ondemand = spec.mode == MountMode::OnDemand; let is_ondemand = spec.mode == MountMode::OnDemand;
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, is_ondemand)); menu.item(&gettext("Stop Syncing…"), move || {
row.add_suffix(&remove); prompt_remove_sync_folder(&ui, id, &path, is_ondemand)
});
} }
/// Ask before switching a synced folder to on-demand: the switch frees disk /// Ask before making a synced folder online only: the switch frees disk space
/// space by removing the local copies, which is not something to do by accident. /// by removing the local copies, which is not something to do by accident.
fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) { fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Make Folder On-Demand?")) .heading(gettext("Make Folder Online Only?"))
// Translators: {path} is a local folder path. // Translators: {path} is a local folder path.
.body(gettext_f("Files in {path} will be removed from this computer and kept in Proton Drive only. Each file downloads again when you open it.", &[("path", path)])) .body(gettext_f("Files in {path} will be removed from this computer and kept in Proton Drive only. Each file downloads again when you open it.", &[("path", path)]))
.build(); .build();
dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("ondemand", &gettext("Make On-Demand")); dialog.add_response("ondemand", &gettext("Make Online Only"));
dialog.set_response_appearance("ondemand", adw::ResponseAppearance::Destructive); dialog.set_response_appearance("ondemand", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel")); dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -1057,13 +1179,62 @@ fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) {
dialog.connect_response(None, move |_, response| { dialog.connect_response(None, move |_, response| {
if response == "ondemand" { if response == "ondemand" {
set_sync_folder_mode(&ui, id, "ondemand"); set_sync_folder_mode(&ui, id, "ondemand");
} else {
refresh_locations(&ui);
} }
}); });
dialog.present(win.as_ref()); dialog.present(win.as_ref());
} }
/// Ask before making an online-only folder synced: every file downloads, which
/// can fill the disk. The free space is read from the folder's parent, which is
/// on the local disk; the folder itself is a FUSE mount that may not answer.
fn confirm_synced(ui: &Rc<Ui>, id: i64, path: &str) {
let ui = ui.clone();
let path = path.to_string();
glib::spawn_future_local(async move {
let parent = Path::new(&path)
.parent()
.map(gio::File::for_path)
.unwrap_or_else(|| gio::File::for_path(&path));
let free = parent
.query_filesystem_info_future(
gio::FILE_ATTRIBUTE_FILESYSTEM_FREE,
glib::Priority::DEFAULT,
)
.await
.ok()
.map(|info| info.attribute_uint64(gio::FILE_ATTRIBUTE_FILESYSTEM_FREE));
let body = match free {
// Translators: {path} is a local folder path, {free} the free disk space such as "12.3 GiB".
Some(free) => gettext_f(
"Every file in {path} will download to this computer and stay synced. {free} is free on this disk.",
&[("path", &path), ("free", &human_bytes(free))],
),
// Translators: {path} is a local folder path.
None => gettext_f(
"Every file in {path} will download to this computer and stay synced.",
&[("path", &path)],
),
};
let dialog = adw::AlertDialog::builder()
.heading(gettext("Download Folder?"))
.body(body)
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("mirror", &gettext("Download"));
dialog.set_response_appearance("mirror", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("mirror"));
dialog.set_close_response("cancel");
let win = ui_window(&ui);
let ui = ui.clone();
dialog.connect_response(None, move |_, response| {
if response == "mirror" {
set_sync_folder_mode(&ui, id, "mirror");
}
});
dialog.present(win.as_ref());
});
}
/// Pause or resume one synced folder, then repaint so its row shows the result. /// Pause or resume one synced folder, then repaint so its row shows the result.
fn set_folder_paused(ui: &Rc<Ui>, id: i64, paused: bool) { fn set_folder_paused(ui: &Rc<Ui>, id: i64, paused: bool) {
let rx = spawn_request( let rx = spawn_request(
@@ -1087,7 +1258,11 @@ fn set_folder_paused(ui: &Rc<Ui>, id: i64, paused: bool) {
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &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."),
),
} }
refresh_locations(&ui); refresh_locations(&ui);
}); });
@@ -1106,7 +1281,7 @@ fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't sync"), &gettext("Couldn't sync"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
@@ -1116,7 +1291,7 @@ pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
match kind { match kind {
MountKind::MyFiles => "folder-remote-symbolic", MountKind::MyFiles => "folder-remote-symbolic",
MountKind::Device { .. } => "folder-symbolic", MountKind::Device { .. } => "folder-symbolic",
MountKind::Shared { .. } => "system-users-symbolic", MountKind::Shared { .. } => "pdfs-people-symbolic",
} }
} }
@@ -1125,22 +1300,20 @@ pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
/// Ordered by how likely it is to be what the user came to check: a queued mode /// Ordered by how likely it is to be what the user came to check: a queued mode
/// switch leads (they just asked for it), then the resting mode, then the sync /// switch leads (they just asked for it), then the resting mode, then the sync
/// state, then — only when it is surprising — the fact that no session owns the /// state, then — only when it is surprising — the fact that no session owns the
/// path. A mirror folder is a plain directory with no FUSE session, so "not /// path. A mirror folder is a plain directory with no FUSE session, so having
/// mounted" is its normal state and saying it would be noise. /// none is its normal state and saying so would be noise.
pub(crate) fn location_subtitle(spec: &MountSpec) -> String { pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
match &spec.kind { match &spec.kind {
MountKind::MyFiles => parts.push(gettext("My files")), MountKind::MyFiles => parts.push(gettext("My files")),
MountKind::Shared { .. } => parts.push(gettext("Shared folder")), MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) { MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
(Some(MountMode::OnDemand), _) => parts.push(gettext("Going on-demand")), (Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")),
(Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")), (Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
(Some(MountMode::Unknown) | None, MountMode::OnDemand) => { (Some(MountMode::Unknown) | None, MountMode::OnDemand) => {
// Translators: a folder mode: files are kept online and fetched when opened. parts.push(folder_mode_label(true))
parts.push(pgettext("folder mode", "On-demand"))
} }
// Translators: a folder mode: a full copy is kept on this computer. (Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)),
(Some(MountMode::Unknown) | None, _) => parts.push(pgettext("folder mode", "Synced")),
}, },
} }
if matches!(spec.kind, MountKind::Device { .. }) { if matches!(spec.kind, MountKind::Device { .. }) {
@@ -1156,7 +1329,8 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|| spec.mode == MountMode::OnDemand || spec.mode == MountMode::OnDemand
|| spec.pending_mode == Some(MountMode::OnDemand); || spec.pending_mode == Some(MountMode::OnDemand);
if expects_session && !spec.mounted { if expects_session && !spec.mounted {
parts.push(gettext("not mounted")); // Translators: a folder's files can't be opened right now.
parts.push(gettext("not available"));
} }
parts.join(" · ") parts.join(" · ")
} }
@@ -1215,7 +1389,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
location_subtitle(&spec), location_subtitle(&spec),
"On-demand · up to date · not mounted" "Online only · up to date · not available"
); );
} }
@@ -1227,7 +1401,7 @@ mod tests {
true, true,
); );
spec.pending_mode = Some(MountMode::OnDemand); spec.pending_mode = Some(MountMode::OnDemand);
assert!(location_subtitle(&spec).starts_with("Going on-demand")); assert!(location_subtitle(&spec).starts_with("Going online only"));
spec.mode = MountMode::OnDemand; spec.mode = MountMode::OnDemand;
spec.pending_mode = Some(MountMode::Mirror); spec.pending_mode = Some(MountMode::Mirror);
assert!(location_subtitle(&spec).starts_with("Switching to synced")); assert!(location_subtitle(&spec).starts_with("Switching to synced"));
@@ -1240,7 +1414,7 @@ mod tests {
let mut spec = spec(MountKind::MyFiles, MountMode::OnDemand, true); let mut spec = spec(MountKind::MyFiles, MountMode::OnDemand, true);
assert_eq!(location_subtitle(&spec), "My files"); assert_eq!(location_subtitle(&spec), "My files");
spec.mounted = false; spec.mounted = false;
assert_eq!(location_subtitle(&spec), "My files · not mounted"); assert_eq!(location_subtitle(&spec), "My files · not available");
} }
#[test] #[test]
+16 -8
View File
@@ -170,7 +170,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
// Cache the new identity so `refresh` never hits the keyring. // Cache the new identity so `refresh` never hits the keyring.
*ui.session.borrow_mut() = auth::load().ok(); *ui.session.borrow_mut() = auth::load().ok();
// Enable+start the mount service now that we have a session. // Enable+start the mount service now that we have a session.
service::enable_start(); let _ = gio::spawn_blocking(service::enable_start).await;
refresh(&ui); refresh(&ui);
} }
Err(e) => ui.login.login_status.set_text(&e), Err(e) => ui.login.login_status.set_text(&e),
@@ -295,7 +295,7 @@ pub(crate) fn spawn_login(
/// the worker reads as a cancelled login. /// the worker reads as a cancelled login.
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) { pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Two-factor authentication")) .heading(gettext("Two-Factor Authentication"))
.body(gettext( .body(gettext(
"Enter the code from your authenticator app, or one of your recovery codes.", "Enter the code from your authenticator app, or one of your recovery codes.",
)) ))
@@ -344,12 +344,20 @@ pub(crate) fn sign_out(ui: &Rc<Ui>) {
), ),
&gettext("Sign Out"), &gettext("Sign Out"),
move || { move || {
service::disable_stop(); // systemctl and the keyring both block, so run them on a worker.
if let Err(e) = auth::logout() { let ui = ui.clone();
tracing::error!("logout failed: {e}"); glib::spawn_future_local(async move {
} let result = gio::spawn_blocking(|| {
*ui.session.borrow_mut() = None; service::disable_stop();
refresh(&ui); auth::logout().map_err(|e| e.to_string())
})
.await;
if let Ok(Err(e)) = result {
tracing::error!("logout failed: {e}");
}
*ui.session.borrow_mut() = None;
refresh(&ui);
});
}, },
); );
} }
@@ -307,7 +307,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
} }
}, },
Ok(Ok(Response::Error { message, .. })) => fail(&message), Ok(Ok(Response::Error { message, .. })) => fail(&message),
Ok(Ok(_)) => fail(&gettext("Unexpected reply from the mount service.")), Ok(Ok(_)) => fail(&gettext("Unexpected reply from the Proton Drive service.")),
Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")), Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")),
} }
}); });
@@ -1136,7 +1136,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
.child( .child(
&adw::ButtonContent::builder() &adw::ButtonContent::builder()
.label(gettext("Show on map")) .label(gettext("Show on map"))
.icon_name("map-symbolic") .icon_name("pdfs-map-symbolic")
.build(), .build(),
) )
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
@@ -1426,7 +1426,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
None None
} }
Ok(Ok(Response::Error { message, .. })) => Some(message), Ok(Ok(Response::Error { message, .. })) => Some(message),
_ => Some(gettext("The mount service didn't respond.")), _ => Some(gettext("The Proton Drive service didn't respond.")),
}; };
if let Some(detail) = failed { if let Some(detail) = failed {
toast_error( toast_error(
+38 -68
View File
@@ -40,8 +40,6 @@ pub(crate) struct GalleryState {
/// Says a Google Photos import is running, with a way to its page. /// Says a Google Photos import is running, with a way to its page.
pub(crate) import_banner: adw::Banner, pub(crate) import_banner: adw::Banner,
pub(crate) upload: gtk4::Button, pub(crate) upload: gtk4::Button,
/// Opens the Google Photos Takeout import chooser.
pub(crate) import: gtk4::Button,
/// Upload / Import offered on the *empty timeline* status page, so a fresh /// Upload / Import offered on the *empty timeline* status page, so a fresh
/// account is a place to start rather than a dead end. Hidden on every other /// account is a place to start rather than a dead end. Hidden on every other
/// status: a load error is not the moment to offer an upload. /// status: a load error is not the moment to offer an upload.
@@ -401,12 +399,10 @@ pub(crate) struct GalleryWidgets {
pub(crate) scroll: gtk4::ScrolledWindow, pub(crate) scroll: gtk4::ScrolledWindow,
pub(crate) retry: gtk4::Button, pub(crate) retry: gtk4::Button,
pub(crate) upload: gtk4::Button, pub(crate) upload: gtk4::Button,
pub(crate) import: gtk4::Button,
pub(crate) empty_actions: gtk4::Box, pub(crate) empty_actions: gtk4::Box,
pub(crate) empty_upload: gtk4::Button, pub(crate) empty_upload: gtk4::Button,
pub(crate) empty_import: gtk4::Button, pub(crate) empty_import: gtk4::Button,
pub(crate) refresh: gtk4::Button, pub(crate) refresh: gtk4::Button,
pub(crate) duplicates: gtk4::Button,
/// The Select toggle and the bar it reveals. /// The Select toggle and the bar it reveals.
pub(crate) select_btn: gtk4::ToggleButton, pub(crate) select_btn: gtk4::ToggleButton,
pub(crate) select_bar: gtk4::Revealer, pub(crate) select_bar: gtk4::Revealer,
@@ -567,34 +563,36 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.build(); .build();
upload.add_css_class("suggested-action"); upload.add_css_class("suggested-action");
// Importing an export is a rarer, heavier action than adding one photo, so
// it sits beside Upload as a plain button rather than a second accent one.
let import = gtk4::Button::builder()
.icon_name("folder-download-symbolic")
.tooltip_text(gettext("Import a Google Photos Takeout export"))
.valign(gtk4::Align::Center)
.build();
import.add_css_class("flat");
import.add_css_class("circular");
let refresh = refresh_button(); let refresh = refresh_button();
// The duplicate finder opens over the page: a review, not a view of it. // Importing an export and hunting for duplicates are rarer, heavier jobs
let duplicates = gtk4::Button::builder() // than adding one photo, so they wait in the page menu rather than taking
.icon_name("edit-copy-symbolic") // a header button each.
.tooltip_text(gettext("Find duplicates")) let page_menu = gio::Menu::new();
page_menu.append(
Some(&gettext("Import from Google Photos…")),
Some("win.show-import"),
);
page_menu.append(
Some(&gettext("Find Duplicates…")),
Some("win.find-duplicates"),
);
let more = gtk4::MenuButton::builder()
.icon_name("view-more-symbolic")
.tooltip_text(gettext("More"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.menu_model(&page_menu)
.build(); .build();
duplicates.add_css_class("flat"); more.add_css_class("flat");
duplicates.add_css_class("circular");
// Picking photos is a mode, so its control is a toggle rather than a button. // Picking photos is a mode, so its control is a toggle rather than a
// button. It has a label: no icon says "select" in every theme.
let select_btn = gtk4::ToggleButton::builder() let select_btn = gtk4::ToggleButton::builder()
.icon_name("selection-mode-symbolic") .label(pgettext("verb", "Select"))
.tooltip_text(gettext("Select photos")) .tooltip_text(gettext("Select photos"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
select_btn.add_css_class("flat"); select_btn.add_css_class("flat");
select_btn.add_css_class("circular");
// What the selection can do, revealed with the mode. A revealer rather than // What the selection can do, revealed with the mode. A revealer rather than
// a hidden box so the grid slides down instead of jumping. // a hidden box so the grid slides down instead of jumping.
@@ -798,8 +796,12 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
albums_stack.add_named(&albums_scroll, Some("grid")); albums_stack.add_named(&albums_scroll, Some("grid"));
albums_stack.add_named(&albums_status, Some("status")); albums_stack.add_named(&albums_status, Some("status"));
let new_album = gtk4::Button::builder() let new_album = gtk4::Button::builder()
.icon_name("list-add-symbolic") .child(
.label(gettext("New Album…")) &adw::ButtonContent::builder()
.label(gettext("New Album…"))
.icon_name("list-add-symbolic")
.build(),
)
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.action_name("win.new-album") .action_name("win.new-album")
.build(); .build();
@@ -896,10 +898,9 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
let (frame, header, title) = page_frame(&gettext("Photos"), &body); let (frame, header, title) = page_frame(&gettext("Photos"), &body);
header.pack_start(&back); header.pack_start(&back);
header.pack_start(&upload); header.pack_start(&upload);
header.pack_end(&more);
header.pack_end(&refresh); header.pack_end(&refresh);
header.pack_end(&select_btn); header.pack_end(&select_btn);
header.pack_end(&import);
header.pack_end(&duplicates);
( (
frame.upcast(), frame.upcast(),
@@ -916,12 +917,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
scroll, scroll,
retry, retry,
upload, upload,
import,
empty_actions, empty_actions,
empty_upload, empty_upload,
empty_import, empty_import,
refresh, refresh,
duplicates,
select_btn, select_btn,
select_bar, select_bar,
select_label, select_label,
@@ -1408,14 +1407,6 @@ pub(crate) fn wire_gallery(
load_gallery(&ui_dates, false); load_gallery(&ui_dates, false);
}); });
// The import is a staged, hours-long migration, so the button hands over to
// the Import page rather than opening a file chooser here: the archives need
// reviewing before anything is sent, and the run needs somewhere to report.
let ui_import = ui.clone();
ui.gallery
.import
.connect_clicked(move |_| ui_import.stack.set_visible_child_name("takeout"));
let ui_upload = ui.clone(); let ui_upload = ui.clone();
ui.gallery.upload.connect_clicked(move |_| { ui.gallery.upload.connect_clicked(move |_| {
let dialog = gtk4::FileDialog::builder() let dialog = gtk4::FileDialog::builder()
@@ -1493,7 +1484,7 @@ pub(crate) fn wire_gallery(
toast_error( toast_error(
&ui_clone, &ui_clone,
&gettext("Couldn't upload photo"), &gettext("Couldn't upload photo"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
); );
} }
} }
@@ -1647,9 +1638,10 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
// A shot stored as more than one file says so, in the corner the caption // A shot stored as more than one file says so, in the corner the caption
// does not use. "RAW" is the useful word when one of the members is a raw // does not use. "RAW" is the useful word when one of the members is a raw
// file — that is what the person wants to find — and a plain count covers // file — that is what the person wants to find — and a plain count covers
// the rest (a live photo, a burst). Zoomed out to years the badge would // the rest (a live photo, a burst). Only the Day zoom shows it: on the
// cover most of a tile, so it is left off there. // smaller Month and Year tiles a badge on every raw+JPEG shot turns the
if (tile.photo.has_raw || tile.photo.group_size > 1) && tile.grouping != Grouping::Year { // grid into noise.
if (tile.photo.has_raw || tile.photo.group_size > 1) && tile.grouping == Grouping::Day {
let badge = gtk4::Label::builder() let badge = gtk4::Label::builder()
.label(if tile.photo.has_raw { .label(if tile.photo.has_raw {
// Translators: badge on a photo tile whose shot includes a raw camera file. // Translators: badge on a photo tile whose shot includes a raw camera file.
@@ -2060,7 +2052,7 @@ pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
), ),
}; };
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Move to Trash")) .heading(gettext("Move to Trash?"))
.body(body) .body(body)
.build(); .build();
dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("cancel", &gettext("Cancel"));
@@ -2148,7 +2140,7 @@ pub(crate) fn trash_photos(ui: &Rc<Ui>, uids: Vec<String>, files_only: bool) {
toast_error( toast_error(
&ui, &ui,
&gettext("Couldn't move to Trash"), &gettext("Couldn't move to Trash"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
); );
} }
} }
@@ -3287,7 +3279,7 @@ fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't change the favorite"), &gettext("Couldn't change the favorite"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
@@ -3470,13 +3462,13 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
&gettext("Couldn't load photos"), &gettext("Couldn't load photos"),
&gettext("Unexpected reply from the mount service."), &gettext("Unexpected reply from the Proton Drive service."),
false, false,
), ),
Ok(Err(_)) | Err(_) if append => toast_error( Ok(Err(_)) | Err(_) if append => toast_error(
&ui, &ui,
&gettext("Couldn't load more photos"), &gettext("Couldn't load more photos"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
Ok(Err(_)) | Err(_) => gallery_unreachable(&ui), Ok(Err(_)) | Err(_) => gallery_unreachable(&ui),
} }
@@ -3500,29 +3492,7 @@ pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
/// Photos counterpart of [`browser_unreachable`]: auto-retry while the mount is /// Photos counterpart of [`browser_unreachable`]: auto-retry while the mount is
/// still starting, surface an actionable error + Retry once it's actually down. /// still starting, surface an actionable error + Retry once it's actually down.
pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) { pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() { service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
gallery_status(
ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive mount service isn't running."),
true,
);
return;
}
gallery_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("gallery") {
load_gallery(&ui, false);
}
});
} }
/// Play a video with an external player. Prefers `mpv` — it sniffs the container /// Play a video with an external player. Prefers `mpv` — it sniffs the container
+1 -1
View File
@@ -102,7 +102,7 @@ pub(crate) fn load_places(ui: &Rc<Ui>) {
&ui, &ui,
"network-offline-symbolic", "network-offline-symbolic",
&gettext("Not connected"), &gettext("Not connected"),
&gettext("The Proton Drive mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
+28 -47
View File
@@ -46,12 +46,13 @@ pub(crate) struct SharedWidgets {
/// links I've bookmarked (open / remove). All three live outside the mount tree, /// links I've bookmarked (open / remove). All three live outside the mount tree,
/// so the page addresses them by uid/id/token and always re-lists from the daemon. /// so the page addresses them by uid/id/token and always re-lists from the daemon.
pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
// A label, not a bookmark glyph: nothing about the icon says what a Drive
// bookmark is.
let add_bookmark = gtk4::Button::builder() let add_bookmark = gtk4::Button::builder()
.icon_name("bookmark-new-symbolic") .label(gettext("Add Bookmark…"))
.tooltip_text(gettext( .tooltip_text(gettext("Save a public link to open it from here"))
"Add Bookmark — save a public link to open it from here",
))
.build(); .build();
add_bookmark.add_css_class("flat");
let refresh = refresh_button(); let refresh = refresh_button();
// Only sections with something in them are shown, so an account with one // Only sections with something in them are shown, so an account with one
@@ -87,7 +88,7 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
retry.add_css_class("suggested-action"); retry.add_css_class("suggested-action");
retry.set_visible(false); retry.set_visible(false);
let status = adw::StatusPage::builder() let status = adw::StatusPage::builder()
.icon_name("emblem-shared-symbolic") .icon_name("pdfs-people-symbolic")
.vexpand(true) .vexpand(true)
.child(&retry) .child(&retry)
.build(); .build();
@@ -111,7 +112,7 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
.tooltip_text(gettext("Back")) .tooltip_text(gettext("Back"))
.visible(false) .visible(false)
.build(); .build();
let (frame, header, title) = page_frame(&gettext("Shared with Me"), &inner); let (frame, header, title) = page_frame(&gettext("Shared with me"), &inner);
header.pack_start(&back); header.pack_start(&back);
header.pack_end(&refresh); header.pack_end(&refresh);
header.pack_end(&add_bookmark); header.pack_end(&add_bookmark);
@@ -141,10 +142,7 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, retry: &gtk4::Button, add_bookmark: &gtk4
load_shared(&ui_back); load_shared(&ui_back);
}); });
let ui_retry = ui.clone(); let ui_retry = ui.clone();
retry.connect_clicked(move |_| { retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared));
service::restart();
load_shared(&ui_retry);
});
let ui_add = ui.clone(); let ui_add = ui.clone();
add_bookmark.connect_clicked(move |_| prompt_add_bookmark(&ui_add)); add_bookmark.connect_clicked(move |_| prompt_add_bookmark(&ui_add));
} }
@@ -177,7 +175,7 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
ui.shared.inflight.set(true); ui.shared.inflight.set(true);
shared_status( shared_status(
ui, ui,
"emblem-shared-symbolic", "pdfs-people-symbolic",
&gettext("Loading…"), &gettext("Loading…"),
&gettext("Reading your shared items."), &gettext("Reading your shared items."),
false, false,
@@ -429,7 +427,7 @@ pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
if !entry.shared_by.is_empty() { if !entry.shared_by.is_empty() {
parts.push(entry.shared_by.clone()); parts.push(entry.shared_by.clone());
if entry.shared_at > 0 { if entry.shared_at > 0 {
parts.push(format_modified(entry.shared_at)); parts.push(dates::short_date(entry.shared_at));
} }
} }
if !entry.is_dir { if !entry.is_dir {
@@ -507,7 +505,7 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't open file"), &gettext("Couldn't open file"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
@@ -516,29 +514,7 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
/// The daemon didn't answer the Shared page. Same still-starting vs. down split /// The daemon didn't answer the Shared page. Same still-starting vs. down split
/// as the other pages. /// as the other pages.
pub(crate) fn shared_unreachable(ui: &Rc<Ui>) { pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() { service_unreachable(ui, "shared", shared_status, load_shared);
shared_status(
ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive mount service isn't running."),
true,
);
return;
}
shared_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("shared") {
load_shared(&ui);
}
});
} }
/// Rebuild the three Shared sections. Rows added last time are removed first — /// Rebuild the three Shared sections. Rows added last time are removed first —
@@ -559,7 +535,7 @@ pub(crate) fn repaint_shared(
if shared.is_empty() && invitations.is_empty() && bookmarks.is_empty() { if shared.is_empty() && invitations.is_empty() && bookmarks.is_empty() {
shared_status( shared_status(
ui, ui,
"emblem-shared-symbolic", "pdfs-people-symbolic",
&gettext("Nothing Shared with You"), &gettext("Nothing Shared with You"),
&gettext( &gettext(
"Files and folders other people share with you appear here. Public links you bookmark are kept here too.", "Files and folders other people share with you appear here. Public links you bookmark are kept here too.",
@@ -593,7 +569,7 @@ pub(crate) fn repaint_shared(
rows.push((ui.shared.with_me_group.clone(), row.upcast())); rows.push((ui.shared.with_me_group.clone(), row.upcast()));
} }
// Invitations: inviter + item, Accept / Reject. // Invitations: inviter + item, Accept / Decline.
for inv in invitations { for inv in invitations {
let item = inv.name.clone().unwrap_or_else(|| gettext("a shared item")); let item = inv.name.clone().unwrap_or_else(|| gettext("a shared item"));
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
@@ -606,12 +582,13 @@ pub(crate) fn repaint_shared(
} else { } else {
"text-x-generic-symbolic" "text-x-generic-symbolic"
})); }));
// Labelled: a close glyph here reads as "dismiss this row".
let reject = gtk4::Button::builder() let reject = gtk4::Button::builder()
.icon_name("window-close-symbolic") .label(gettext("Decline"))
.tooltip_text(gettext("Reject"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
reject.add_css_class("flat"); reject.add_css_class("flat");
reject.add_css_class("pill");
let accept = gtk4::Button::builder() let accept = gtk4::Button::builder()
.label(gettext("Accept")) .label(gettext("Accept"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
@@ -631,13 +608,13 @@ pub(crate) fn repaint_shared(
let id = id_rej.clone(); let id = id_rej.clone();
confirm_destructive( confirm_destructive(
btn, btn,
&gettext("Reject Invitation?"), &gettext("Decline Invitation?"),
// Translators: {name} is the shared item's name, or "this item". // Translators: {name} is the shared item's name, or "this item".
&gettext_f( &gettext_f(
"You won't have access to {name} unless it is shared again.", "You won't have access to {name} unless it is shared again.",
&[("name", &name_rej)], &[("name", &name_rej)],
), ),
&gettext("Reject"), &gettext("Decline"),
move || respond_invitation(&ui, &id, false), move || respond_invitation(&ui, &id, false),
); );
}); });
@@ -662,7 +639,7 @@ pub(crate) fn repaint_shared(
.build(); .build();
remove.add_css_class("flat"); remove.add_css_class("flat");
let open = gtk4::Button::builder() let open = gtk4::Button::builder()
.icon_name("external-link-symbolic") .icon_name("adw-external-link-symbolic")
.tooltip_text(gettext("Open in browser")) .tooltip_text(gettext("Open in browser"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
@@ -707,7 +684,7 @@ pub(crate) fn respond_invitation(ui: &Rc<Ui>, id: &str, accept: bool) {
pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) { pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Leave shared item")) .heading(gettext("Leave Shared Item?"))
// Translators: {name} is the shared item's name. // Translators: {name} is the shared item's name.
.body(gettext_f( .body(gettext_f(
"Leave “{name}”? You'll lose access until you're invited again.", "Leave “{name}”? You'll lose access until you're invited again.",
@@ -738,7 +715,7 @@ pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) { pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Remove bookmark")) .heading(gettext("Remove Bookmark?"))
// Translators: {name} is the bookmark's name. // Translators: {name} is the bookmark's name.
.body(gettext_f( .body(gettext_f(
"Remove the bookmark for “{name}”?", "Remove the bookmark for “{name}”?",
@@ -771,7 +748,7 @@ pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) { pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Add bookmark")) .heading(gettext("Add Bookmark"))
.body(gettext("Paste a Proton Drive public link to save it here.")) .body(gettext("Paste a Proton Drive public link to save it here."))
.build(); .build();
let group = adw::PreferencesGroup::new(); let group = adw::PreferencesGroup::new();
@@ -835,7 +812,11 @@ pub(crate) fn run_shared_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed:
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &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."),
),
} }
}); });
} }
+10 -34
View File
@@ -42,7 +42,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
retry.add_css_class("suggested-action"); retry.add_css_class("suggested-action");
retry.set_visible(false); retry.set_visible(false);
let status = adw::StatusPage::builder() let status = adw::StatusPage::builder()
.icon_name("emblem-shared-symbolic") .icon_name("pdfs-share-symbolic")
.vexpand(true) .vexpand(true)
.child(&retry) .child(&retry)
.build(); .build();
@@ -60,7 +60,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
inner.set_margin_start(18); inner.set_margin_start(18);
inner.set_margin_end(18); inner.set_margin_end(18);
inner.append(&content); inner.append(&content);
let (frame, header, _) = page_frame(&gettext("Shared by Me"), &inner); let (frame, header, _) = page_frame(&gettext("Shared by me"), &inner);
header.pack_end(&refresh); header.pack_end(&refresh);
( (
@@ -78,10 +78,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
/// Install the Shared (by-me) page's retry button. /// Install the Shared (by-me) page's retry button.
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: &gtk4::Button) { pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: &gtk4::Button) {
let ui_retry = ui.clone(); let ui_retry = ui.clone();
retry.connect_clicked(move |_| { retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared_by_me));
service::restart();
load_shared_by_me(&ui_retry);
});
} }
/// Show a status page in place of the Shared (by-me) list. /// Show a status page in place of the Shared (by-me) list.
@@ -108,7 +105,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
ui.shared_by_me.inflight.set(true); ui.shared_by_me.inflight.set(true);
shared_by_me_status( shared_by_me_status(
ui, ui,
"emblem-shared-symbolic", "pdfs-share-symbolic",
&gettext("Loading…"), &gettext("Loading…"),
&gettext("Reading what you've shared."), &gettext("Reading what you've shared."),
false, false,
@@ -136,7 +133,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
&gettext("Couldn't read your shares"), &gettext("Couldn't read your shares"),
&gettext("Unexpected reply from the mount service."), &gettext("Unexpected reply from the Proton Drive service."),
false, false,
), ),
Ok(Err(_)) | Err(_) => { Ok(Err(_)) | Err(_) => {
@@ -149,29 +146,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
/// The daemon didn't answer the Shared (by-me) page. /// The daemon didn't answer the Shared (by-me) page.
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) { pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() { service_unreachable(ui, "sharedbyme", shared_by_me_status, load_shared_by_me);
shared_by_me_status(
ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive mount service isn't running."),
true,
);
return;
}
shared_by_me_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("sharedbyme") {
load_shared_by_me(&ui);
}
});
} }
/// Rebuild the Shared (by-me) section from a fresh listing. /// Rebuild the Shared (by-me) section from a fresh listing.
@@ -182,7 +157,7 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
if items.is_empty() { if items.is_empty() {
shared_by_me_status( shared_by_me_status(
ui, ui,
"emblem-shared-symbolic", "pdfs-share-symbolic",
&gettext("Nothing shared yet"), &gettext("Nothing shared yet"),
&gettext("Items you share with people or by link show up here."), &gettext("Items you share with people or by link show up here."),
false, false,
@@ -211,7 +186,7 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
// The one action people come here for most gets its own button. // The one action people come here for most gets its own button.
if let Some(url) = url.clone() { if let Some(url) = url.clone() {
let copy = gtk4::Button::builder() let copy = gtk4::Button::builder()
.icon_name("edit-copy-symbolic") .icon_name("pdfs-link-symbolic")
.tooltip_text(gettext("Copy link")) .tooltip_text(gettext("Copy link"))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
@@ -219,6 +194,7 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
let ui_copy = ui.clone(); let ui_copy = ui.clone();
copy.connect_clicked(move |btn| { copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url); btn.clipboard().set_text(&url);
flash_copied(btn, "pdfs-link-symbolic");
toast(&ui_copy, &gettext("Link copied")); toast(&ui_copy, &gettext("Link copied"));
}); });
row.add_suffix(&copy); row.add_suffix(&copy);
@@ -270,7 +246,7 @@ fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4
/// Confirm, then remove every member, invitation and the public link. /// Confirm, then remove every member, invitation and the public link.
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) { pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Stop sharing?")) .heading(gettext("Stop Sharing?"))
// Translators: {name} is the shared item's name. // Translators: {name} is the shared item's name.
.body(gettext_f( .body(gettext_f(
"Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.", "Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
+123 -106
View File
@@ -19,6 +19,8 @@ pub(crate) struct StatusState {
/// Sidebar footer: the one-line sync state. The mount is driven by the /// Sidebar footer: the one-line sync state. The mount is driven by the
/// systemd user service, not by the user — so this only reports. /// systemd user service, not by the user — so this only reports.
pub(crate) status_icon: gtk4::Image, pub(crate) status_icon: gtk4::Image,
/// Stands in for [`Self::status_icon`] until the first status arrives.
pub(crate) status_spinner: gtk4::Spinner,
pub(crate) status_title: gtk4::Label, pub(crate) status_title: gtk4::Label,
pub(crate) status_detail: gtk4::Label, pub(crate) status_detail: gtk4::Label,
pub(crate) cache_bar: gtk4::ProgressBar, pub(crate) cache_bar: gtk4::ProgressBar,
@@ -82,8 +84,25 @@ pub(crate) struct StatusState {
/// How many transfers were in flight on the previous poll. A drop to zero is /// 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. /// what "sync complete" means; there's no completion event on the wire.
pub(crate) active_transfers: Cell<usize>, 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 /// 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. /// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
pub(crate) struct PinRow { pub(crate) struct PinRow {
@@ -121,6 +140,7 @@ pub(crate) struct MainWidgets {
pub(crate) account_name: gtk4::Label, pub(crate) account_name: gtk4::Label,
pub(crate) avatar: adw::Avatar, pub(crate) avatar: adw::Avatar,
pub(crate) status_icon: gtk4::Image, pub(crate) status_icon: gtk4::Image,
pub(crate) status_spinner: gtk4::Spinner,
pub(crate) status_title: gtk4::Label, pub(crate) status_title: gtk4::Label,
pub(crate) status_detail: gtk4::Label, pub(crate) status_detail: gtk4::Label,
pub(crate) quota_box: gtk4::Box, pub(crate) quota_box: gtk4::Box,
@@ -178,9 +198,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
mountpoint_row.add_css_class("property"); mountpoint_row.add_css_class("property");
let mountpoint_button = gtk4::Button::builder() let mountpoint_button = gtk4::Button::builder()
.label(gettext("Change…")) .label(gettext("Change…"))
.tooltip_text(gettext( .tooltip_text(gettext("Choose where My files appears on this computer"))
"Choose a different folder for the Proton Drive mount",
))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
mountpoint_button.add_css_class("flat"); mountpoint_button.add_css_class("flat");
@@ -310,7 +328,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
prefs.add(&storage); prefs.add(&storage);
// ---- Sidebar footer // ---- Sidebar footer
let status_icon = gtk4::Image::from_icon_name("content-loading-symbolic"); // Until the first status arrives there is no state to show, only that the
// app is still asking.
let status_icon = gtk4::Image::builder().visible(false).build();
let status_spinner = gtk4::Spinner::builder().spinning(true).build();
let status_title = gtk4::Label::builder() let status_title = gtk4::Label::builder()
.label(gettext("Connecting…")) .label(gettext("Connecting…"))
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
@@ -329,6 +350,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
status_text.append(&status_title); status_text.append(&status_title);
status_text.append(&status_detail); status_text.append(&status_detail);
let status_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); let status_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
status_content.append(&status_spinner);
status_content.append(&status_icon); status_content.append(&status_icon);
status_content.append(&status_text); status_content.append(&status_text);
let status_button = gtk4::Button::builder() let status_button = gtk4::Button::builder()
@@ -390,6 +412,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
account_name, account_name,
avatar, avatar,
status_icon, status_icon,
status_spinner,
status_title, status_title,
status_detail, status_detail,
quota_box, quota_box,
@@ -491,7 +514,17 @@ pub(crate) fn wire_settings(
ui.status ui.status
.mountpoint_row .mountpoint_row
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string()); .set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
ui.status.autostart_row.set_active(service::is_enabled()); // 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 ui.status
.theme_row .theme_row
.set_active(config.proton_theme.unwrap_or(false)); .set_active(config.proton_theme.unwrap_or(false));
@@ -587,11 +620,14 @@ pub(crate) fn wire_settings(
if ui_auto.status.settings_suppress.get() { if ui_auto.status.settings_suppress.get() {
return; return;
} }
if row.is_active() { let enable = row.is_active();
service::enable(); gio::spawn_blocking(move || {
} else { if enable {
service::disable(); service::enable();
} } else {
service::disable();
}
});
}); });
let ui_mp = ui.clone(); let ui_mp = ui.clone();
@@ -706,7 +742,11 @@ pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed:
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &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."),
),
} }
}); });
} }
@@ -716,7 +756,7 @@ pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed:
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) { pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
let win = ui_window(ui); let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder() let dialog = gtk4::FileDialog::builder()
.title(gettext("Choose mountpoint folder")) .title(gettext("Choose Proton Drive Folder"))
.build(); .build();
let ui = ui.clone(); let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| { dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
@@ -728,7 +768,7 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
let mut config = ui.dirs.load_config(); let mut config = ui.dirs.load_config();
config.mountpoint = Some(path_str.clone()); config.mountpoint = Some(path_str.clone());
if let Err(e) = ui.dirs.save_config(&config) { if let Err(e) = ui.dirs.save_config(&config) {
toast_error(&ui, &gettext("Couldn't save mountpoint"), &e.to_string()); toast_error(&ui, &gettext("Couldn't save the folder"), &e.to_string());
return; return;
} }
ui.status.mountpoint_row.set_subtitle(&path_str); ui.status.mountpoint_row.set_subtitle(&path_str);
@@ -741,21 +781,22 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
// The daemon only reads the mountpoint at mount time, so offer a restart. // The daemon only reads the mountpoint at mount time, so offer a restart.
let confirm = adw::AlertDialog::builder() let confirm = adw::AlertDialog::builder()
.heading(gettext("Restart to apply")) .heading(gettext("Restart Proton Drive?"))
// Translators: {path} is the folder the drive is mounted at. // Translators: {path} is the folder the drive is mounted at.
.body(gettext_f( .body(gettext_f(
"The mountpoint is now “{path}”. Restart the Drive mount to use it?", "My files now appears in “{path}” after Proton Drive restarts.",
&[("path", &path_str)], &[("path", &path_str)],
)) ))
.build(); .build();
confirm.add_response("later", &gettext("Later")); confirm.add_response("later", &gettext("Later"));
confirm.add_response("restart", &gettext("Restart now")); confirm.add_response("restart", &gettext("Restart Now"));
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested); confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
confirm.set_default_response(Some("restart")); confirm.set_default_response(Some("restart"));
confirm.set_close_response("later"); confirm.set_close_response("later");
confirm.connect_response(None, |_, resp| { let ui_restart = ui.clone();
confirm.connect_response(None, move |_, resp| {
if resp == "restart" { if resp == "restart" {
service::restart(); restart_service_then(&ui_restart, |_| {});
} }
}); });
confirm.present(ui_window(&ui).as_ref()); confirm.present(ui_window(&ui).as_ref());
@@ -767,20 +808,20 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
/// reload the page. /// reload the page.
pub(crate) fn wire_retry(ui: &Rc<Ui>) { pub(crate) fn wire_retry(ui: &Rc<Ui>) {
let ui_browser = ui.clone(); let ui_browser = ui.clone();
ui.browser.retry.clone().connect_clicked(move |_| { ui.browser
service::restart(); .retry
load_browser(&ui_browser); .clone()
}); .connect_clicked(move |_| restart_service_then(&ui_browser, load_browser));
let ui_gallery = ui.clone(); let ui_gallery = ui.clone();
ui.gallery.retry.clone().connect_clicked(move |_| { ui.gallery
service::restart(); .retry
load_gallery(&ui_gallery, false); .clone()
}); .connect_clicked(move |_| restart_service_then(&ui_gallery, |ui| load_gallery(ui, false)));
let ui_trash = ui.clone(); let ui_trash = ui.clone();
ui.trash.retry.clone().connect_clicked(move |_| { ui.trash
service::restart(); .retry
load_trash(&ui_trash); .clone()
}); .connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
} }
/// Repaint the window from the cached login identity, then kick an async mount- /// Repaint the window from the cached login identity, then kick an async mount-
@@ -848,9 +889,8 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
/// active-page tick refetches it only this often rather than every 2s. /// active-page tick refetches it only this often rather than every 2s.
const QUOTA_TTL: Duration = Duration::from_secs(60); const QUOTA_TTL: Duration = Duration::from_secs(60);
/// Fetch the account quota (if the last reading is stale) and paint both the /// Fetch the account quota (if the last reading is stale) and paint the
/// sidebar footer and Files status bar. A failed fetch leaves the last /// sidebar footer. A failed fetch leaves the last good reading in place.
/// good reading in place.
pub(crate) fn refresh_quota(ui: &Rc<Ui>) { pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
if ui.status.quota_inflight.get() { if ui.status.quota_inflight.get() {
return; return;
@@ -874,10 +914,6 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
ui.status.quota_checked_at.set(Some(Instant::now())); ui.status.quota_checked_at.set(Some(Instant::now()));
paint_account_quota(&ui, max_space, used_space); paint_account_quota(&ui, max_space, used_space);
ui.status.quota_box.set_visible(true); ui.status.quota_box.set_visible(true);
} else if ui.status.quota_checked_at.get().is_none() {
// Match Dolphin: capacity information does not occupy the bar until
// the backing observer has real figures.
ui.browser.quota_box.set_visible(false);
} }
}); });
} }
@@ -886,15 +922,6 @@ fn paint_account_quota(ui: &Rc<Ui>, max_space: i64, used_space: i64) {
let (fraction, text) = quota_display(max_space, used_space); let (fraction, text) = quota_display(max_space, used_space);
ui.status.quota_bar.set_fraction(fraction); ui.status.quota_bar.set_fraction(fraction);
ui.status.quota_label.set_text(&text); ui.status.quota_label.set_text(&text);
if let Some((fraction, free_text, tooltip)) = quota_status_display(max_space, used_space) {
ui.browser.quota.set_fraction(fraction);
ui.browser.quota.set_tooltip_text(Some(&tooltip));
ui.browser.quota_text.set_label(&free_text);
ui.browser.quota_text.set_tooltip_text(Some(&tooltip));
ui.browser.quota_box.set_visible(true);
} else {
ui.browser.quota_box.set_visible(false);
}
} }
fn quota_display(max_space: i64, used_space: i64) -> (f64, String) { fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
@@ -924,34 +951,6 @@ fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
) )
} }
/// Dolphin's status bar shows a bare capacity bar followed by “X free”; the
/// full free/total/percentage sentence is a tooltip rather than inline bar text.
fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String, String)> {
if max_space <= 0 {
return None;
}
let total = max_space as u64;
let used = (used_space.max(0) as u64).min(total);
let free = total.saturating_sub(used);
let fraction = used as f64 / total as f64;
let pct = (fraction * 100.0).round() as u64;
Some((
fraction,
// Translators: {size} is an amount of storage, such as "1.2 GiB".
gettext_f("{size} free", &[("size", &human_bytes(free))]),
// Translators: account storage. {free} and {total} are sizes such as
// "1.2 GiB"; {percent} is a whole number, followed by the percent sign.
gettext_f(
"{free} free out of {total} ({percent}% used)",
&[
("free", &human_bytes(free)),
("total", &human_bytes(total)),
("percent", &pct.to_string()),
],
),
))
}
/// Record the mount state seen by the last status poll: gate every control that /// 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. /// needs a live daemon, and notify the desktop when the state actually flips.
/// ///
@@ -981,13 +980,13 @@ pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
notify( notify(
"mount-state", "mount-state",
&gettext("Proton Drive connected"), &gettext("Proton Drive connected"),
&gettext("Your Drive is mounted and available."), &gettext("Your files are available."),
); );
} else { } else {
notify( notify(
"mount-state", "mount-state",
&gettext("Proton Drive disconnected"), &gettext("Proton Drive disconnected"),
&gettext("The mount service stopped. Files aren't available until it restarts."), &gettext("The Proton Drive service stopped. Files aren't available until it restarts."),
); );
} }
} }
@@ -1036,17 +1035,35 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
takeout_progress(ui, jobs); takeout_progress(ui, jobs);
let previous = ui.status.active_transfers.replace(items.len()); 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 { if items.is_empty() && previous > 0 {
notify( // Nobody needs a notification for what they are watching happen.
"sync-complete", let watching = ui_window(ui).is_some_and(|window| window.is_active());
&gettext("Sync complete"), if let Some(batch) = ui.status.transfer_batch.take()
&ngettext_f( && !watching
"{n} file finished transferring.", && (batch.names.len() >= SYNC_NOTIFY_MIN_FILES
"{n} files finished transferring.", || batch.started.elapsed() >= SYNC_NOTIFY_MIN_DURATION)
previous as u64, {
&[], 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 // A just-finished batch may have added files (bulk upload) the current
// listing doesn't show yet; refresh whichever listing is on screen. // listing doesn't show yet; refresh whichever listing is on screen.
reload_listing(ui); reload_listing(ui);
@@ -1228,9 +1245,20 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
// used" reads as a broken read-out, and there is no fraction to // used" reads as a broken read-out, and there is no fraction to
// draw against no limit. // draw against no limit.
ui.status.cache_label.set_text(&if budget == 0 { ui.status.cache_label.set_text(&if budget == 0 {
format!("{} cached — no limit set", human_bytes(used)) // Translators: {used} is a size such as "1.2 GiB".
gettext_f(
"{used} cached — no limit set",
&[("used", &human_bytes(used))],
)
} else { } else {
format!("{} of {} used", human_bytes(used), human_bytes(budget)) // 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); repaint_pins(&ui, &pins, true);
} }
@@ -1321,13 +1349,13 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
Some(queued.unwrap_or_else(|| gettext("Cached files only"))), Some(queued.unwrap_or_else(|| gettext("Cached files only"))),
), ),
SyncState::Syncing { queued } => ( SyncState::Syncing { queued } => (
"emblem-synchronizing-symbolic", "pdfs-sync-symbolic",
None, None,
pgettext("state", "Syncing"), pgettext("state", "Syncing"),
Some(queued), Some(queued),
), ),
SyncState::UpToDate { mountpoint } => ( SyncState::UpToDate { mountpoint } => (
"emblem-ok-symbolic", "pdfs-synced-symbolic",
Some("success"), Some("success"),
gettext("Up to date"), gettext("Up to date"),
Some(mountpoint), Some(mountpoint),
@@ -1341,6 +1369,9 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
}; };
let image = &ui.status.status_icon; let image = &ui.status.status_icon;
image.set_icon_name(Some(icon)); image.set_icon_name(Some(icon));
image.set_visible(true);
ui.status.status_spinner.set_visible(false);
ui.status.status_spinner.set_spinning(false);
for c in ["success", "warning", "error"] { for c in ["success", "warning", "error"] {
image.remove_css_class(c); image.remove_css_class(c);
} }
@@ -1474,7 +1505,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't make it online only"), &gettext("Couldn't make it online only"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
@@ -1529,7 +1560,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{quota_display, quota_status_display}; use super::quota_display;
#[test] #[test]
fn quota_display_reports_used_total_and_percentage() { fn quota_display_reports_used_total_and_percentage() {
@@ -1544,18 +1575,4 @@ mod tests {
assert_eq!(quota_display(0, -1), (0.0, "0 bytes used".to_string())); assert_eq!(quota_display(0, -1), (0.0, "0 bytes used".to_string()));
assert_eq!(quota_display(100, 150).0, 1.0); assert_eq!(quota_display(100, 150).0, 1.0);
} }
#[test]
fn quota_status_display_matches_dolphin_wording() {
let gib = 1024_i64.pow(3);
assert_eq!(
quota_status_display(4 * gib, gib),
Some((
0.25,
"3.0 GiB free".to_string(),
"3.0 GiB free out of 4.0 GiB (25% used)".to_string()
))
);
assert_eq!(quota_status_display(0, 0), None);
}
} }
+3 -3
View File
@@ -384,7 +384,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
let count = ui_import.takeout.archives.borrow().len(); let count = ui_import.takeout.archives.borrow().len();
let ui = ui_import.clone(); let ui = ui_import.clone();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Import this export?")) .heading(gettext("Import This Export?"))
.body(ngettext_f( .body(ngettext_f(
"{n} archive will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.", "{n} archive will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
"{n} archives will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.", "{n} archives will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
@@ -421,7 +421,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't stop the import"), &gettext("Couldn't stop the import"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
@@ -613,7 +613,7 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
_ => toast_error( _ => toast_error(
&ui, &ui,
&gettext("Couldn't start the import"), &gettext("Couldn't start the import"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
+10 -29
View File
@@ -49,7 +49,7 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
let empty = gtk4::Button::builder() let empty = gtk4::Button::builder()
.label(gettext("Empty Trash…")) .label(gettext("Empty Trash…"))
.tooltip_text(gettext("Permanently delete everything in the Trash")) .tooltip_text(gettext("Permanently delete everything in the Trash"))
.sensitive(false) .visible(false)
.build(); .build();
let refresh = refresh_button(); let refresh = refresh_button();
@@ -215,7 +215,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
} else { } else {
human_bytes(entry.size) human_bytes(entry.size)
}; };
let modified = format_modified(entry.modified); let modified = dates::short_date(entry.modified);
// Translators: {kind} is "Folder" or a file size; {modified} is a date. // Translators: {kind} is "Folder" or a file size; {modified} is a date.
let text = gettext_f( let text = gettext_f(
"{kind} · {modified}", "{kind} · {modified}",
@@ -317,7 +317,7 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
&ui, &ui,
"dialog-warning-symbolic", "dialog-warning-symbolic",
&gettext("Couldn't read the trash"), &gettext("Couldn't read the trash"),
&gettext("Unexpected reply from the mount service."), &gettext("Unexpected reply from the Proton Drive service."),
false, false,
), ),
Ok(Err(_)) | Err(_) => trash_unreachable(&ui), Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
@@ -328,29 +328,7 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
/// The daemon didn't answer. Same split as the Files page: still starting (poll /// The daemon didn't answer. Same split as the Files page: still starting (poll
/// again, no button) versus actually down (Retry, which restarts the service). /// again, no button) versus actually down (Retry, which restarts the service).
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) { pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() { service_unreachable(ui, "trash", trash_status, load_trash);
trash_status(
ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive mount service isn't running."),
true,
);
return;
}
trash_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("trash") {
load_trash(&ui);
}
});
} }
/// Show a status page in place of the trash list. /// Show a status page in place of the trash list.
@@ -368,7 +346,10 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) { pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
ui.trash.model.remove_all(); ui.trash.model.remove_all();
sync_trash_selection(ui); sync_trash_selection(ui);
ui.trash.empty.set_sensitive(!entries.is_empty()); // An empty Trash has nothing to empty, so the button goes away rather than
// sitting there greyed out.
ui.trash.empty.set_sensitive(true);
ui.trash.empty.set_visible(!entries.is_empty());
if entries.is_empty() { if entries.is_empty() {
trash_status( trash_status(
ui, ui,
@@ -451,7 +432,7 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
), ),
}; };
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Delete Permanently")) .heading(gettext("Delete Permanently?"))
.body(body) .body(body)
.build(); .build();
dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("cancel", &gettext("Cancel"));
@@ -479,7 +460,7 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
let win = ui_window(ui); let win = ui_window(ui);
let count = ui.trash.model.n_items(); let count = ui.trash.model.n_items();
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Empty Trash")) .heading(gettext("Empty Trash?"))
.body(ngettext_f( .body(ngettext_f(
"Permanently delete all {n} item in the trash? This cannot be undone.", "Permanently delete all {n} item in the trash? This cannot be undone.",
"Permanently delete all {n} items in the trash? This cannot be undone.", "Permanently delete all {n} items in the trash? This cannot be undone.",
+5 -5
View File
@@ -49,7 +49,7 @@ pub(crate) struct DetailsWidgets {
/// [`DirEntry`] and [`wire_details`] connects the buttons. /// [`DirEntry`] and [`wire_details`] connects the buttons.
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let toggle = gtk4::ToggleButton::builder() let toggle = gtk4::ToggleButton::builder()
.icon_name("sidebar-show-right-symbolic") .icon_name("pdfs-details-pane-symbolic")
.tooltip_text(gettext("Details (Alt+Enter)")) .tooltip_text(gettext("Details (Alt+Enter)"))
.build(); .build();
@@ -202,7 +202,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let toggle_off = toggle.clone(); let toggle_off = toggle.clone();
empty_close.connect_clicked(move |_| toggle_off.set_active(false)); empty_close.connect_clicked(move |_| toggle_off.set_active(false));
let empty_status = adw::StatusPage::builder() let empty_status = adw::StatusPage::builder()
.icon_name("sidebar-show-right-symbolic") .icon_name("pdfs-details-pane-symbolic")
.title(gettext("No item selected")) .title(gettext("No item selected"))
.description(gettext("Select a file or folder to see its details.")) .description(gettext("Select a file or folder to see its details."))
.vexpand(true) .vexpand(true)
@@ -373,7 +373,7 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
}); });
d.size_row.set_visible(!entry.is_dir); d.size_row.set_visible(!entry.is_dir);
d.modified_row d.modified_row
.set_subtitle(&format_modified(entry.modified)); .set_subtitle(&dates::short_date(entry.modified));
let rel = entry_rel(ui, entry); let rel = entry_rel(ui, entry);
let parent = match rel.rfind('/') { let parent = match rel.rfind('/') {
Some(i) => &rel[..i], Some(i) => &rel[..i],
@@ -494,13 +494,13 @@ pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
match (items.len(), latest) { match (items.len(), latest) {
(0, _) | (_, None) => gettext("No earlier versions"), (0, _) | (_, None) => gettext("No earlier versions"),
// Translators: {date} is the date of the only version. // Translators: {date} is the date of the only version.
(1, Some(t)) => gettext_f("1 version · {date}", &[("date", &format_modified(t))]), (1, Some(t)) => gettext_f("1 version · {date}", &[("date", &dates::short_date(t))]),
// Translators: {date} is the date of the newest version. // Translators: {date} is the date of the newest version.
(n, Some(t)) => ngettext_f( (n, Some(t)) => ngettext_f(
"{n} version · latest {date}", "{n} version · latest {date}",
"{n} versions · latest {date}", "{n} versions · latest {date}",
n as u64, n as u64,
&[("date", &format_modified(t))], &[("date", &dates::short_date(t))],
), ),
} }
} }
@@ -427,7 +427,7 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
_ => toast_error( _ => toast_error(
&state.ui, &state.ui,
&gettext("Couldn't load sharing"), &gettext("Couldn't load sharing"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
@@ -582,6 +582,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
let url_copy = url.clone(); let url_copy = url.clone();
copy.connect_clicked(move |btn| { copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url_copy); btn.clipboard().set_text(&url_copy);
flash_copied(btn, "edit-copy-symbolic");
toast(&state_copy.ui, &gettext("Link copied")); toast(&state_copy.ui, &gettext("Link copied"));
}); });
row.add_suffix(&copy); row.add_suffix(&copy);
@@ -718,7 +719,7 @@ pub(crate) fn share_dialog_create_link(
_ => toast_error( _ => toast_error(
&state.ui, &state.ui,
&gettext("Couldn't create the link"), &gettext("Couldn't create the link"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
@@ -756,7 +757,7 @@ pub(crate) fn share_dialog_op(
toast_error( toast_error(
&state.ui, &state.ui,
&failed, &failed,
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
); );
share_dialog_reload(&state); share_dialog_reload(&state);
} }
@@ -167,7 +167,7 @@ pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
_ => toast_error( _ => toast_error(
&state.ui, &state.ui,
&gettext("Couldn't load versions"), &gettext("Couldn't load versions"),
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
@@ -204,13 +204,13 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
gettext_f( gettext_f(
"{time} · {size}", "{time} · {size}",
&[ &[
("time", &activity_time(item.created)), ("time", &dates::relative(item.created)),
("size", &human_bytes(size)), ("size", &human_bytes(size)),
], ],
), ),
) )
} else { } else {
(activity_time(item.created), human_bytes(size)) (dates::relative(item.created), human_bytes(size))
}; };
if let Some(email) = &item.signed_by { if let Some(email) = &item.signed_by {
subtitle.push_str(" · "); subtitle.push_str(" · ");
@@ -266,7 +266,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
/// every device on the account, not only this one. /// every device on the account, not only this one.
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) { fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Restore version")) .heading(gettext("Restore Version?"))
// Translators: {name} is a file name. // Translators: {name} is a file name.
.body(gettext_f( .body(gettext_f(
"Make this version the current content of “{name}”? The version it replaces stays in the history.", "Make this version the current content of “{name}”? The version it replaces stays in the history.",
@@ -301,7 +301,7 @@ fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
/// Confirm, then permanently delete one revision. /// Confirm, then permanently delete one revision.
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) { fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(gettext("Delete version")) .heading(gettext("Delete Version?"))
.body(gettext( .body(gettext(
"Delete this version permanently? Its content can't be recovered.", "Delete this version permanently? Its content can't be recovered.",
)) ))
@@ -377,7 +377,7 @@ fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str,
_ => toast_error( _ => toast_error(
&state.ui, &state.ui,
&failed, &failed,
&gettext("The mount service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), ),
} }
}); });
+12
View File
@@ -198,6 +198,18 @@ pub(crate) fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
} }
} }
/// 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.
pub(crate) fn human_bytes(bytes: u64) -> String {
gtk4::glib::format_size_full(bytes, gtk4::glib::FormatSizeFlags::IEC_UNITS)
.replace('\u{a0}', " ")
}
/// gettext takes the count as `u32`. Counts past that pick the same form as /// gettext takes the count as `u32`. Counts past that pick the same form as
/// the largest one, which is the plural in every shipped language. /// the largest one, which is the plural in every shipped language.
fn plural_count(n: u64) -> u32 { fn plural_count(n: u64) -> u32 {
+5 -5
View File
@@ -53,7 +53,7 @@ impl Phase {
fn icon(self) -> &'static str { fn icon(self) -> &'static str {
match self { match self {
Phase::Synced => "folder-remote-symbolic", Phase::Synced => "folder-remote-symbolic",
Phase::Syncing => "emblem-synchronizing-symbolic", Phase::Syncing => "view-refresh-symbolic",
Phase::Paused => "media-playback-pause-symbolic", Phase::Paused => "media-playback-pause-symbolic",
Phase::Offline => "network-offline-symbolic", Phase::Offline => "network-offline-symbolic",
Phase::Attention => "dialog-warning-symbolic", Phase::Attention => "dialog-warning-symbolic",
@@ -172,8 +172,8 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
), ),
// Translators: {mountpoint} is a folder path; {n} is the number of pinned items. // Translators: {mountpoint} is a folder path; {n} is the number of pinned items.
(true, None) => ngettext_f( (true, None) => ngettext_f(
"Mounted at {mountpoint} ({n} pinned)", "Up to date in {mountpoint} ({n} pinned)",
"Mounted at {mountpoint} ({n} pinned)", "Up to date in {mountpoint} ({n} pinned)",
pinned as u64, pinned as u64,
&[("mountpoint", &mountpoint)], &[("mountpoint", &mountpoint)],
), ),
@@ -204,7 +204,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
} }
// Socket answered but with something unexpected — treat as up but odd. // Socket answered but with something unexpected — treat as up but odd.
Ok(_) => DriveState { Ok(_) => DriveState {
line: gettext("Mount: unexpected daemon response"), line: gettext("Proton Drive sent an unexpected reply"),
phase: Phase::Attention, phase: Phase::Attention,
mounted: true, mounted: true,
mountpoint: default_mountpoint.to_path_buf(), mountpoint: default_mountpoint.to_path_buf(),
@@ -218,7 +218,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
Ok(s) => ( Ok(s) => (
// Translators: {username} is the Proton account name. // Translators: {username} is the Proton account name.
gettext_f( gettext_f(
"Logged in as {username} — not mounted", "Signed in as {username} — not connected",
&[("username", &s.username)], &[("username", &s.username)],
), ),
Phase::Disconnected, Phase::Disconnected,
+6 -25
View File
@@ -24,7 +24,7 @@ use pdfs_core::opener::{self, OpenWith};
mod activation; mod activation;
mod i18n; mod i18n;
use i18n::{gettext, gettext_f, gettext_noop, ngettext_f, pgettext}; use i18n::{gettext, gettext_f, gettext_noop, human_bytes, ngettext_f, pgettext};
mod dmenu; mod dmenu;
mod fzf; mod fzf;
mod query; mod query;
@@ -586,7 +586,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let offline = adw::StatusPage::builder() let offline = adw::StatusPage::builder()
.icon_name("network-offline-symbolic") .icon_name("network-offline-symbolic")
.title(gettext("Proton Drive is not running")) .title(gettext("Proton Drive is not running"))
.description(gettext("Start the mount daemon to search your Drive.")) .description(gettext("Start Proton Drive to search your files."))
.build(); .build();
offline.add_css_class("compact"); offline.add_css_class("compact");
let retry = gtk4::Button::builder() let retry = gtk4::Button::builder()
@@ -876,7 +876,7 @@ impl Ui {
} }
_ => { _ => {
ui.entry.set_sensitive(false); ui.entry.set_sensitive(false);
ui.hint.set_label(&gettext("Daemon offline")); ui.hint.set_label(&gettext("Proton Drive isn't running"));
ui.stack.set_visible_child_name("offline"); ui.stack.set_visible_child_name("offline");
} }
} }
@@ -890,7 +890,7 @@ impl Ui {
self.spinner.stop(); self.spinner.stop();
self.spinner.set_visible(false); self.spinner.set_visible(false);
self.entry.set_sensitive(false); self.entry.set_sensitive(false);
self.hint.set_label(&gettext("Daemon offline")); self.hint.set_label(&gettext("Proton Drive isn't running"));
self.stack.set_visible_child_name("offline"); self.stack.set_visible_child_name("offline");
} }
@@ -1322,7 +1322,7 @@ impl Ui {
&[("message", &message)], &[("message", &message)],
)); ));
} }
_ => ui.hint.set_label(&gettext("Could not reach the daemon")), _ => ui.hint.set_label(&gettext("Couldn't reach Proton Drive")),
} }
}); });
} }
@@ -1370,7 +1370,7 @@ fn build_row(hit: &Hit) -> gtk4::ListBoxRow {
meta.append(&pin); meta.append(&pin);
} }
if !hit.is_dir() && hit.size() > 0 { if !hit.is_dir() && hit.size() > 0 {
let size = gtk4::Label::new(Some(&format_size(hit.size()))); let size = gtk4::Label::new(Some(&human_bytes(hit.size())));
size.add_css_class("result-meta"); size.add_css_class("result-meta");
meta.append(&size); meta.append(&size);
} }
@@ -1445,25 +1445,6 @@ fn parent_of(path: &str) -> String {
.unwrap_or_default() .unwrap_or_default()
} }
fn format_size(bytes: u64) -> String {
const UNITS: [(u64, &str); 3] = [
// Translators: a file size in gigabytes; {size} is a number such as "4.2".
(1024 * 1024 * 1024, gettext_noop("{size} GB")),
// Translators: a file size in megabytes; {size} is a number such as "4.2".
(1024 * 1024, gettext_noop("{size} MB")),
// Translators: a file size in kilobytes; {size} is a number such as "4.2".
(1024, gettext_noop("{size} KB")),
];
for (scale, unit) in UNITS {
if bytes >= scale {
let size = format!("{:.1}", bytes as f64 / scale as f64);
return gettext_f(unit, &[("size", &size)]);
}
}
// Translators: a file size in bytes; {size} is a whole number.
gettext_f("{size} B", &[("size", &bytes.to_string())])
}
/// Coarse relative age, in the granularity a launcher row has room for. /// Coarse relative age, in the granularity a launcher row has room for.
fn format_age(epoch_secs: i64) -> String { fn format_age(epoch_secs: i64) -> String {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
+160
View File
@@ -0,0 +1,160 @@
//! Every symbolic icon the app window and the search prompt ask for must
//! exist on a stock GNOME desktop.
//!
//! An icon name that no theme ships shows up as a missing-image glyph, and
//! nothing fails until someone looks. So each `"…-symbolic"` literal in the
//! sources has to be either one of the `pdfs-*` icons bundled in
//! `resources/icons` (and listed in the gresource manifest), or on
//! [`STOCK`], a list of names checked against Adwaita 50, libadwaita and
//! GTK's own built-in icons.
//!
//! The tray is left out: its icons are drawn by the panel, from the panel's
//! theme, not from our resources.
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
/// Stock symbolic icons the app may use. Before adding a name, check that
/// Adwaita has it outside its `legacy` folder, or that libadwaita or GTK
/// ship it themselves.
const STOCK: &[&str] = &[
// libadwaita
"adw-external-link-symbolic",
// GTK's built-in icons
"info-outline-symbolic",
// Adwaita
"checkbox-checked-symbolic",
"checkbox-symbolic",
"computer-symbolic",
"dialog-error-symbolic",
"dialog-warning-symbolic",
"document-edit-symbolic",
"document-open-recent-symbolic",
"document-open-symbolic",
"document-save-symbolic",
"drive-harddisk-symbolic",
"edit-copy-symbolic",
"edit-delete-symbolic",
"edit-find-symbolic",
"edit-undo-symbolic",
"folder-download-symbolic",
"folder-new-symbolic",
"folder-open-symbolic",
"folder-remote-symbolic",
"folder-symbolic",
"go-down-symbolic",
"go-jump-symbolic",
"go-next-symbolic",
"go-previous-symbolic",
"go-up-symbolic",
"image-missing-symbolic",
"image-x-generic-symbolic",
"list-add-symbolic",
"mark-location-symbolic",
"media-playback-pause-symbolic",
"media-playback-start-symbolic",
"network-idle-symbolic",
"network-offline-symbolic",
"non-starred-symbolic",
"object-select-symbolic",
"preferences-system-symbolic",
"process-stop-symbolic",
"starred-symbolic",
"system-search-symbolic",
"text-x-generic-symbolic",
"user-trash-symbolic",
"view-grid-symbolic",
"view-list-symbolic",
"view-more-symbolic",
"view-paged-symbolic",
"view-pin-symbolic",
"view-refresh-symbolic",
"window-close-symbolic",
"zoom-fit-best-symbolic",
"zoom-in-symbolic",
"zoom-out-symbolic",
];
fn crate_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
/// Every `"name-symbolic"` string literal in `source`. Found by suffix rather
/// than by pairing quotes, so an escaped quote or a `'"'` elsewhere in the
/// file can't hide one.
fn symbolic_literals(source: &str) -> Vec<String> {
const SUFFIX: &str = "-symbolic\"";
let is_name = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-';
source
.match_indices(SUFFIX)
.filter_map(|(at, _)| {
let head = &source[..at];
let start = head.trim_end_matches(is_name).len();
(start > 0 && head[..start].ends_with('"'))
.then(|| format!("{}-symbolic", &head[start..]))
})
.collect()
}
/// The `pdfs-*` icons in `resources/icons`, by name.
fn bundled() -> BTreeSet<String> {
let mut names = BTreeSet::new();
let root = crate_dir().join("resources/icons/scalable");
for context in fs::read_dir(root).unwrap() {
for icon in fs::read_dir(context.unwrap().path()).unwrap() {
let path = icon.unwrap().path();
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
names.insert(name.to_string());
}
}
}
names
}
#[test]
fn every_symbolic_icon_exists_on_a_stock_desktop() {
let mut files = vec![crate_dir().join("src/prompt.rs")];
rust_files(&crate_dir().join("src/app"), &mut files);
let bundled = bundled();
let mut unknown = BTreeSet::new();
for file in files {
let source = fs::read_to_string(&file).unwrap();
for name in symbolic_literals(&source) {
if !bundled.contains(&name) && !STOCK.contains(&name.as_str()) {
let file = file
.strip_prefix(crate_dir())
.unwrap()
.display()
.to_string();
unknown.insert(format!("{name} ({file})"));
}
}
}
assert!(
unknown.is_empty(),
"icons that are neither bundled nor on the stock list:\n {}",
unknown.into_iter().collect::<Vec<_>>().join("\n ")
);
}
#[test]
fn every_bundled_icon_is_in_the_resource_manifest() {
let manifest = fs::read_to_string(crate_dir().join("resources/pdfs.gresource.xml")).unwrap();
let missing: Vec<String> = bundled()
.into_iter()
.filter(|name| name.starts_with("pdfs-") && !manifest.contains(&format!("/{name}.svg<")))
.collect();
assert!(missing.is_empty(), "not in pdfs.gresource.xml: {missing:?}");
}
+13
View File
@@ -63,6 +63,19 @@ stored as `language` in `config.json` and takes effect the next time they start.
- Keep product names as they are: Proton Drive, Proton, Google Takeout. - Keep product names as they are: Proton Drive, Proton, Google Takeout.
- Use the same term for the same thing across the catalog, and follow the terminology of your - Use the same term for the same thing across the catalog, and follow the terminology of your
desktop (GNOME or KDE) for common actions such as Trash, Preferences and Cancel. desktop (GNOME or KDE) for common actions such as Trash, Preferences and Cancel.
- The English UI uses one term per concept. Pick one translation for each and keep it:
| Concept | English term |
|---|---|
| The root of the user's Drive | My files |
| The background service | Proton Drive service (never "daemon" or "mount") |
| A folder with a full copy on this computer | Synced folder |
| A folder whose files download when opened | Online-only folder |
| A single file kept on this computer | Available offline |
| A single file that is not on this computer | Online only |
| Where My files appears on disk | Proton Drive folder |
| Stop syncing a folder | Stop syncing |
| Two versions of one file | Conflict |
## Rules for developers ## Rules for developers
+2 -1
View File
@@ -283,7 +283,8 @@ Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [
| Cloud-only file | Online only | none / `cloud-outline` | | Cloud-only file | Online only | none / `cloud-outline` |
| Pinned file | Available offline | pin/download-filled custom icon, not stars | | Pinned file | Available offline | pin/download-filled custom icon, not stars |
| Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair | | Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair |
| Folder sync mode | Synced / On-demand | — | | Folder sync mode | Synced / Online only | — |
| Where My files appears on disk | Proton Drive folder — never "mountpoint" in UI | — |
| Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash | | Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash |
| Remove a computer | Remove computer… | destructive, in menu | | Remove a computer | Remove computer… | destructive, in menu |
| Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour | | Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour |
+1
View File
@@ -1,4 +1,5 @@
crates/pdfs-gui/src/activation.rs crates/pdfs-gui/src/activation.rs
crates/pdfs-gui/src/app/dates.rs
crates/pdfs-gui/src/app/main.rs crates/pdfs-gui/src/app/main.rs
crates/pdfs-gui/src/app/pages/activity.rs crates/pdfs-gui/src/app/pages/activity.rs
crates/pdfs-gui/src/app/pages/albums.rs crates/pdfs-gui/src/app/pages/albums.rs
+1292 -1137
View File
File diff suppressed because it is too large. Load diff
+1307 -1165
View File
File diff suppressed because it is too large. Load diff
+1280 -1132
View File
File diff suppressed because it is too large. Load diff
+1289 -1136
View File
File diff suppressed because it is too large. Load diff
+1285 -1136
View File
File diff suppressed because it is too large. Load diff
+1285 -1135
View File
File diff suppressed because it is too large. Load diff
+1088 -1093
View File
File diff suppressed because it is too large. Load diff
+1283 -1134
View File
File diff suppressed because it is too large. Load diff
+1281 -1131
View File
File diff suppressed because it is too large. Load diff