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-offline-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
can't be recoloured by CSS. -->
<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-account { padding: 4px 4px 4px 8px; }
/* Sync page status card: the state is the headline, the detail explains it */
.sync-card .title { font-size: 1.15rem; font-weight: 700; }
.sync-card-icon { padding: 6px 8px; }
.sync-card-icon.success { color: var(--success-color); }
.sync-card-icon.warning { color: var(--warning-color); }
.sync-card-icon.error { color: var(--error-color); }
/* File browser */
.file-grid { padding: 6px; }
.file-tile { padding: 8px; border-radius: 10px; }
@@ -27,13 +34,11 @@
.file-tile:selected, .file-tile:hover:selected { background: color-mix(in srgb, var(--accent-bg-color) 20%, transparent); }
.file-thumbnail { border-radius: 7px; }
.file-badge { -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }
.badge-pinned { color: var(--yellow-3); }
.badge-pinned { color: var(--success-color); }
.badge-cached { color: var(--success-color); }
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
scale.browser-status-meter trough, progressbar.browser-status-meter trough { min-width: 104px; }
scale.browser-status-meter trough { min-height: 6px; }
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress { min-height: 6px; }
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
+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"]
pub(crate) mod activation;
pub(crate) mod dates;
#[path = "../i18n.rs"]
pub(crate) mod i18n;
pub(crate) mod pages;
@@ -28,7 +29,9 @@ use widgets::share_dialog::*;
use widgets::thumbnails::*;
use widgets::versions_dialog::*;
pub(crate) use i18n::{country_name, gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
pub(crate) use i18n::{
country_name, gettext, gettext_f, gettext_noop, human_bytes, ngettext_f, pgettext,
};
use std::cell::{Cell, RefCell};
@@ -72,6 +75,11 @@ const APP_ID: &str = "io.narl.proton-drive-linux";
/// How often the window re-reads mount status, cache usage and the pin list.
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
/// While the window is open but not focused, only every this-many ticks
/// refreshes (every 10 s). A minimised or hidden window skips them all, and
/// focusing the window refreshes at once.
const BACKGROUND_REFRESH_EVERY: u32 = 5;
/// Backoff between auto-retries of a Files/Photos load while the mount service
/// is still coming up (see [`load_browser`] / [`load_gallery`]).
const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2);
@@ -520,6 +528,7 @@ fn build_window(app: &adw::Application) {
account_name: main_widgets.account_name.clone(),
avatar: main_widgets.avatar.clone(),
status_icon: main_widgets.status_icon.clone(),
status_spinner: main_widgets.status_spinner.clone(),
status_title: main_widgets.status_title.clone(),
status_detail: main_widgets.status_detail.clone(),
transfer_rows: RefCell::new(Vec::new()),
@@ -549,6 +558,7 @@ fn build_window(app: &adw::Application) {
pins_state: RefCell::new(None),
notified_mounted: Cell::new(None),
active_transfers: Cell::new(0),
transfer_batch: RefCell::new(None),
},
browser: BrowserState {
model: browser_widgets.model.clone(),
@@ -588,9 +598,6 @@ fn build_window(app: &adw::Application) {
zoom: browser_widgets.zoom.clone(),
grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT),
grid_tiles: RefCell::new(Vec::new()),
quota_box: browser_widgets.quota_box.clone(),
quota: browser_widgets.quota.clone(),
quota_text: browser_widgets.quota_text.clone(),
},
details: DetailsState {
details: browser_widgets.details,
@@ -627,7 +634,6 @@ fn build_window(app: &adw::Application) {
burst: Cell::new(false),
import_banner: gallery_widgets.import_banner.clone(),
upload: gallery_widgets.upload.clone(),
import: gallery_widgets.import.clone(),
empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(),
albums: gallery_widgets.albums.clone(),
@@ -830,10 +836,6 @@ fn build_window(app: &adw::Application) {
&gallery_widgets.empty_upload,
&gallery_widgets.empty_import,
);
let ui_duplicates = ui.clone();
gallery_widgets
.duplicates
.connect_clicked(move |_| show_duplicates(&ui_duplicates));
wire_albums(&ui);
wire_places(&ui);
wire_trash(&ui, &trash_widgets);
@@ -919,12 +921,33 @@ fn build_window(app: &adw::Application) {
refresh(&ui);
// Periodic refresh while the window lives. The closure holds a strong `Rc`;
// it is dropped when the source is removed on window close.
// it is dropped when the source is removed on window close. A window in the
// background refreshes less often, and one nobody can see not at all.
let ui_tick = ui.clone();
let window_tick = window.downgrade();
let skipped = Cell::new(0u32);
let source = glib::timeout_add_local(REFRESH_INTERVAL, move || {
let Some(window) = window_tick.upgrade() else {
return glib::ControlFlow::Continue;
};
if window_hidden(&window) {
return glib::ControlFlow::Continue;
}
if !window.is_active() && skipped.get() + 1 < BACKGROUND_REFRESH_EVERY {
skipped.set(skipped.get() + 1);
return glib::ControlFlow::Continue;
}
skipped.set(0);
refresh(&ui_tick);
glib::ControlFlow::Continue
});
// Catch up as soon as the user comes back to the window.
let ui_focus = ui.clone();
window.connect_is_active_notify(move |window| {
if window.is_active() {
refresh(&ui_focus);
}
});
let cell = RefCell::new(Some(source));
window.connect_close_request(move |_| {
if let Some(id) = cell.borrow_mut().take() {
@@ -936,6 +959,22 @@ fn build_window(app: &adw::Application) {
window.present();
}
/// Whether `window` is minimised or otherwise out of sight (another workspace,
/// fully covered), as far as the compositor tells us.
fn window_hidden(window: &adw::ApplicationWindow) -> bool {
if !window.is_visible() {
return true;
}
window
.surface()
.and_downcast::<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,
/// and each entry is `(stack page name, label, icon)`. The places in the account
/// come first; the rows from [`SIDEBAR_SYNC_SECTION`] on are about this computer's
@@ -950,21 +989,17 @@ const DESTINATIONS: [(&str, &str, &str); 8] = [
(
"shared",
gettext_noop("Shared with me"),
"system-users-symbolic",
"pdfs-people-symbolic",
),
(
"sharedbyme",
gettext_noop("Shared by me"),
"emblem-shared-symbolic",
"pdfs-share-symbolic",
),
("devices", gettext_noop("Computers"), "computer-symbolic"),
("trash", gettext_noop("Trash"), "user-trash-symbolic"),
// Translators: sidebar entry for the page listing this computer's synced folders.
(
"locations",
gettext_noop("Sync"),
"emblem-synchronizing-symbolic",
),
("locations", gettext_noop("Sync"), "pdfs-sync-symbolic"),
(
"activity",
gettext_noop("Activity"),
@@ -1358,7 +1393,7 @@ fn build_primary_menu() -> gtk4::MenuButton {
Some("win.about"),
);
gtk4::MenuButton::builder()
.icon_name("open-menu-symbolic")
.icon_name("pdfs-menu-symbolic")
.tooltip_text(gettext("Main menu"))
.primary(true)
.menu_model(&menu)
@@ -1383,12 +1418,22 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
sign_out_action.connect_activate(move |_, _| sign_out(&ui_out));
window.add_action(&sign_out_action);
// The Photos page's import banner leads here.
// The Photos page's import banner and page menu lead here. The import is a
// staged, hours-long migration, so it gets its own page rather than a file
// chooser: the archives need reviewing before anything is sent, and the run
// needs somewhere to report.
let show_import = gio::SimpleAction::new("show-import", None);
let ui_import = ui.clone();
show_import.connect_activate(move |_, _| ui_import.stack.set_visible_child_name("takeout"));
window.add_action(&show_import);
// The Photos page menu's Find Duplicates. The finder opens over the page:
// a review, not a view of it.
let find_duplicates = gio::SimpleAction::new("find-duplicates", None);
let ui_duplicates = ui.clone();
find_duplicates.connect_activate(move |_, _| show_duplicates(&ui_duplicates));
window.add_action(&find_duplicates);
// The Albums view's New Album button.
let new_album = gio::SimpleAction::new("new-album", None);
let ui_album = ui.clone();
@@ -1502,7 +1547,7 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
confirm_stop.connect_activate(move |_, _| {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Stop Proton Drive?"))
.body(gettext("The drive unmounts and nothing syncs until you connect again. Proton Drive starts again at your next login."))
.body(gettext("My files goes offline and nothing syncs until you connect again. Proton Drive starts again at your next login."))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("stop", &pgettext("verb", "Stop"));
@@ -1523,7 +1568,7 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
toast_error(
&ui,
&gettext("Couldn't stop Proton Drive"),
&gettext("The mount service did not stop."),
&gettext("The Proton Drive service did not stop."),
);
}
});
@@ -1810,6 +1855,77 @@ fn capitalize(s: &str) -> String {
}
}
/// Whether the Proton Drive service is down (failed, or not running at all).
/// Asks systemd on a worker thread: `systemctl` can take seconds to answer,
/// and the window must not freeze while it does.
pub(crate) async fn service_down() -> bool {
gio::spawn_blocking(|| service::is_failed() || !service::is_active())
.await
.unwrap_or(true)
}
/// Restart the Proton Drive service off the main thread, then run `then`.
pub(crate) fn restart_service_then(ui: &Rc<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_with` in `config.json` overrides it for this kind of file.
fn open_path(path: &str) {
@@ -1824,17 +1940,6 @@ pub(crate) fn open_named_path(path: &str, name: &str) {
pdfs_core::opener::open_default_named(Path::new(path), name, false);
}
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`, or
/// `512 bytes` below one KiB).
///
/// GLib does the formatting, so the units, the plural of "bytes" and the
/// decimal separator follow the user's language from GLib's own catalog. GLib
/// separates number and unit with a no-break space; it is turned back into a
/// plain space so the text matches what the rest of the UI builds around it.
fn human_bytes(bytes: u64) -> String {
glib::format_size_full(bytes, glib::FormatSizeFlags::IEC_UNITS).replace('\u{a0}', " ")
}
#[cfg(test)]
mod tests {
use super::*;
+9 -45
View File
@@ -174,10 +174,7 @@ pub(crate) fn wire_activity(
filters: &[(ActivityFilter, gtk4::ToggleButton)],
) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_activity(&ui_retry);
});
retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_activity));
for (filter, button) in filters {
let ui = ui.clone();
let filter = *filter;
@@ -272,7 +269,7 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
&ui,
"dialog-warning-symbolic",
&gettext("Couldn't read activity"),
&gettext("Unexpected reply from the mount service."),
&gettext("Unexpected reply from the Proton Drive service."),
false,
),
Ok(Err(_)) | Err(_) => activity_unreachable(&ui),
@@ -282,29 +279,7 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
/// The daemon didn't answer the Activity page.
pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
activity_status(
ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive mount service isn't running."),
true,
);
return;
}
activity_status(
ui,
"folder-remote-symbolic",
&gettext("Connecting…"),
&gettext("Waiting for the Proton Drive mount service to come up."),
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("activity") {
load_activity(&ui);
}
});
service_unreachable(ui, "activity", activity_status, load_activity);
}
/// Poll the conflicts still waiting for a decision while the Activity page is
@@ -538,8 +513,8 @@ fn feed_row(
subtitle.push(capitalize(&a.detail));
}
if row.count > 1 {
let since = activity_time(row.first);
// Translators: {time} is a date and time such as "Sep 21, 14:05".
let since = dates::relative(row.first);
// Translators: {time} is a date or time such as "Sep 21" or "5 min ago".
subtitle.push(ngettext_f(
"{n} time since {time}",
"{n} times since {time}",
@@ -585,9 +560,7 @@ fn feed_row(
));
time.add_css_class("dim-label");
time.add_css_class("numeric");
if let Some(at) = at.and_then(|at| at.format("%c").ok()) {
time.set_tooltip_text(Some(&at));
}
time.set_tooltip_text(Some(&dates::full(a.time)));
widget.add_suffix(&time);
if let Some(conflict) = pending {
@@ -645,28 +618,19 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
match kind {
ActivityKind::Upload => "pdfs-upload-symbolic",
ActivityKind::Download => "document-save-symbolic",
ActivityKind::Sync => "emblem-synchronizing-symbolic",
ActivityKind::Sync => "pdfs-sync-symbolic",
ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic",
ActivityKind::Trash => "user-trash-symbolic",
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
ActivityKind::Restore => "edit-undo-symbolic",
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
ActivityKind::Unshare => "action-unavailable-symbolic",
ActivityKind::Share | ActivityKind::PublicLink => "pdfs-share-symbolic",
ActivityKind::Unshare => "pdfs-unshare-symbolic",
ActivityKind::Conflict => "dialog-warning-symbolic",
}
}
/// Format an epoch-seconds timestamp for the Activity feed, in local time.
pub(crate) fn activity_time(secs: i64) -> String {
glib::DateTime::from_unix_local(secs)
// Translators: strftime format for a date and time in the activity feed, such as "Sep 23, 14:05".
.and_then(|dt| dt.format(&gettext("%b %-d, %H:%M")))
.map(|s| s.to_string())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
+10 -6
View File
@@ -83,7 +83,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
&ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -286,7 +286,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
_ => toast_error(
&ui,
&gettext("Couldn't create the album"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -351,7 +351,11 @@ fn run_album_request(ui: &Rc<Ui>, request: Request, done: String, failed: String
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
}
@@ -380,7 +384,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
toast_error(
&ui,
&gettext("Couldn't load albums"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
);
return;
}
@@ -487,7 +491,7 @@ fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
_ => toast_error(
&ui,
&gettext("Couldn't add to the album"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -559,7 +563,7 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
toast_error(
&ui,
&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,
/// 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) quota_box: gtk4::Box,
pub(crate) quota: gtk4::ProgressBar,
pub(crate) quota_text: gtk4::Label,
}
/// Idle pause after the last keystroke before a search query is sent, so typing
@@ -136,9 +133,6 @@ pub(crate) struct BrowserWidgets {
pub(crate) thumbnail_status: gtk4::Label,
pub(crate) summary: gtk4::Label,
pub(crate) zoom: gtk4::Scale,
pub(crate) quota_box: gtk4::Box,
pub(crate) quota: gtk4::ProgressBar,
pub(crate) quota_text: gtk4::Label,
pub(crate) refresh: gtk4::Button,
/// Wraps the views + the details pane; the pane shows the selection while its header toggle is on.
pub(crate) split: adw::OverlaySplitView,
@@ -344,7 +338,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
let grid = gtk4::GridView::builder()
.model(&selection)
.min_columns(2)
.max_columns(10)
.max_columns(32)
.enable_rubberband(true)
.build();
grid.add_css_class("file-grid");
@@ -436,9 +430,8 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.child(&bulk_box)
.build();
// Mirror DolphinStatusBar's full-width order: contextual text (stretch 1),
// "Zoom:", its slider, then capacity information. Both visible troughs use
// the same CSS dimensions.
// The status bar: contextual text (stretch 1), then the icon-size slider.
// Account storage lives in the sidebar, so it isn't repeated here.
let summary = gtk4::Label::builder()
.label(gettext("Loading…"))
.halign(gtk4::Align::Start)
@@ -446,9 +439,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.ellipsize(gtk4::pango::EllipsizeMode::End)
.hexpand(true)
.build();
// Translators: label in front of the icon-size slider in the status bar.
let zoom_label = gtk4::Label::new(Some(&gettext("Zoom:")));
zoom_label.set_valign(gtk4::Align::Center);
let zoom = gtk4::Scale::with_range(
gtk4::Orientation::Horizontal,
f64::from(GRID_THUMB_MIN),
@@ -465,50 +455,34 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
&[],
)));
zoom.add_css_class("browser-status-meter");
let quota = gtk4::ProgressBar::new();
quota.set_hexpand(false);
quota.set_valign(gtk4::Align::Center);
quota.set_tooltip_text(Some(&gettext("Proton account storage")));
quota.add_css_class("browser-status-meter");
let quota_text = gtk4::Label::builder()
.label(gettext("Loading…"))
.halign(gtk4::Align::Start)
.valign(gtk4::Align::Center)
.margin_end(6)
.tooltip_text(gettext("Proton account storage"))
.build();
let quota_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
quota_box.set_valign(gtk4::Align::Center);
quota_box.append(&quota);
quota_box.append(&quota_text);
quota_box.set_visible(false);
zoom.update_property(&[gtk4::accessible::Property::Label(&gettext("Icon size"))]);
// Small and large icon glyphs at the ends of the slider say what it does
// without a text label.
let zoom_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 2);
zoom_box.set_valign(gtk4::Align::Center);
zoom_box.append(&gtk4::Image::from_icon_name("zoom-out-symbolic"));
zoom_box.append(&zoom);
zoom_box.append(&gtk4::Image::from_icon_name("zoom-in-symbolic"));
let status_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
status_bar.add_css_class("browser-statusbar");
status_bar.set_margin_start(6);
status_bar.set_margin_end(2);
status_bar.append(&summary);
status_bar.append(&zoom_label);
status_bar.append(&zoom);
status_bar.append(&quota_box);
status_bar.append(&zoom_box);
let zoom_label_for_view = zoom_label.clone();
let zoom_for_view = zoom.clone();
let zoom_for_view = zoom_box.clone();
let content_for_view = content.clone();
view_stack.connect_visible_child_name_notify(move |stack| {
let visible = content_for_view.visible_child_name().as_deref() == Some("views")
&& stack.visible_child_name().as_deref() == Some("grid");
zoom_label_for_view.set_visible(visible);
zoom_for_view.set_visible(visible);
});
let zoom_label_for_content = zoom_label.clone();
let zoom_for_content = zoom.clone();
let zoom_for_content = zoom_box.clone();
let view_for_content = view_stack.clone();
content.connect_visible_child_name_notify(move |stack| {
let visible = stack.visible_child_name().as_deref() == Some("views")
&& view_for_content.visible_child_name().as_deref() == Some("grid");
zoom_label_for_content.set_visible(visible);
zoom_for_content.set_visible(visible);
});
@@ -528,7 +502,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
page.append(&gtk4::Separator::new(gtk4::Orientation::Horizontal));
page.append(&status_bar);
let (frame, header, _) = page_frame(&gettext("My Files"), &page);
let (frame, header, _) = page_frame(&gettext("My files"), &page);
header.pack_start(&new_button);
header.pack_end(&folder_button);
header.pack_end(&refresh);
@@ -559,9 +533,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
thumbnail_status,
summary,
zoom,
quota_box,
quota,
quota_text,
refresh,
split,
details,
@@ -699,7 +670,8 @@ pub(crate) fn run_bulk_delete(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
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_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| {
format_modified(e.modified)
dates::short_date(e.modified)
}));
// Search hits come from anywhere in the Drive; this says where.
let location = text_column(&gettext(LOCATION_COLUMN), |e| hit_location(&e.path));
@@ -1087,7 +1060,7 @@ const LOCATION_COLUMN: &str = gettext_noop("Location");
fn hit_location(path: &str) -> String {
match path.rsplit_once('/') {
Some((parent, _)) => parent.to_string(),
None => gettext("My Files"),
None => gettext("My files"),
}
}
@@ -1376,7 +1349,7 @@ pub(crate) fn open_entry_with(ui: &Rc<Ui>, entry: &DirEntry) {
toast_error(
ui,
&gettext("Couldn't open file"),
&gettext("Open With needs the Proton Drive folder to be mounted."),
&gettext("Open With needs the Proton Drive service to be running."),
);
return;
};
@@ -1427,7 +1400,7 @@ pub(crate) fn copy_entry_link(ui: &Rc<Ui>, entry: &DirEntry) {
_ => toast_error(
&ui,
&gettext("Couldn't copy link"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -1535,7 +1508,7 @@ pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
_ => toast_error(
&ui,
&gettext("Couldn't open file"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -1581,7 +1554,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
toast_error(
&ui,
&gettext("Couldn't change offline state"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
);
load_browser(&ui);
}
@@ -1618,9 +1591,12 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
} else {
path.split('/').collect()
};
ui.browser
.crumb
.append(&crumb_node(ui, "Proton Drive", "", segments.is_empty()));
ui.browser.crumb.append(&crumb_node(
ui,
&gettext("My files"),
"",
segments.is_empty(),
));
let mut acc = String::new();
for (i, seg) in segments.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
@@ -2003,7 +1979,7 @@ fn start_thumbnail_build(ui: &Rc<Ui>) {
toast_failure(&ui, &gettext("Couldn't build thumbnails"), &message, kind);
}
_ => {
let message = gettext("The mount service didn't respond.");
let message = gettext("The Proton Drive service didn't respond.");
thumbnail_build_failed(&ui);
toast_error(&ui, &gettext("Couldn't build thumbnails"), &message);
}
@@ -2041,7 +2017,7 @@ fn cancel_thumbnail_build(ui: &Rc<Ui>) {
toast_error(
&ui,
&gettext("Couldn't cancel thumbnail build"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
);
}
}
@@ -2078,7 +2054,7 @@ fn schedule_thumbnail_build_poll(ui: &Rc<Ui>) {
toast_error(
&ui_result,
&gettext("Thumbnail build stopped"),
&gettext("The mount service stopped reporting thumbnail progress."),
&gettext("The Proton Drive service stopped reporting thumbnail progress."),
);
}
}
@@ -2227,7 +2203,11 @@ pub(crate) fn run_mutation(ui: &Rc<Ui>, req: Request, done: String, failed: &'st
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
}
@@ -2527,7 +2507,7 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
_ => {
failure.get_or_insert_with(|| {
(
gettext("The mount service didn't respond."),
gettext("The Proton Drive service didn't respond."),
ErrorKind::Internal,
)
});
@@ -2583,7 +2563,7 @@ pub(crate) fn prompt_new_folder(ui: &Rc<Ui>) {
let win = ui_window(ui);
let parent = ui.browser.path.borrow().clone();
let dialog = adw::AlertDialog::builder()
.heading(gettext("New folder"))
.heading(gettext("New Folder"))
.body(gettext("Create a folder in the current directory."))
.build();
let group = adw::PreferencesGroup::new();
@@ -2703,7 +2683,7 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
_ => toast_error(
&ui,
&gettext("Couldn't upload"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -2719,7 +2699,7 @@ pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)
Some(if entry.pinned {
("pdfs-offline-symbolic", "badge-pinned")
} else if entry.cached {
("emblem-ok-symbolic", "badge-cached")
("pdfs-cached-symbolic", "badge-cached")
} else {
("pdfs-online-only-symbolic", "badge-cloud")
})
@@ -2832,18 +2812,6 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
}
}
/// Format an epoch-seconds modification time as a short local date.
pub(crate) fn format_modified(secs: i64) -> String {
match glib::DateTime::from_unix_local(secs) {
Ok(dt) => dt
// Translators: strftime format for a modification date, such as "23 Sep 2026".
.format(&gettext("%-d %b %Y"))
.map(|s| s.to_string())
.unwrap_or_default(),
Err(_) => String::new(),
}
}
/// Request the current browser directory from the daemon and repaint both views.
pub(crate) fn load_browser(ui: &Rc<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(_)) => browser_failed(
&ui,
&gettext("Unexpected reply from the mount service."),
&gettext("Unexpected reply from the Proton Drive service."),
ErrorKind::Internal,
),
Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
@@ -2915,32 +2883,15 @@ pub(crate) fn browser_failed(ui: &Rc<Ui>, message: &str, kind: ErrorKind) {
/// button) from *down* (actionable error + Retry), so a cold start self-heals
/// once the systemd mount comes up but a real failure stays visible.
pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
ui.browser.summary.set_label(&gettext("Not connected"));
browser_status(
ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive mount service isn't running."),
true,
);
return;
}
ui.browser.summary.set_label(&gettext("Connecting…"));
browser_status(
service_unreachable(
ui,
"folder-remote-symbolic",
&gettext("Connecting…"),
&gettext("Waiting for the Proton Drive mount service to come up."),
false,
"browser",
|ui, icon, title, description, retry| {
ui.browser.summary.set_label(title);
browser_status(ui, icon, title, description, retry);
},
load_browser,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
// Only keep polling while the Files page is the one on screen.
if ui.stack.visible_child_name().as_deref() == Some("browser") {
load_browser(&ui);
}
});
}
/// Repopulate the shared model — folders first, then case-insensitive by name —
@@ -3072,7 +3023,7 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind),
Ok(Ok(_)) => browser_failed(
&ui,
&gettext("Unexpected reply from the mount service."),
&gettext("Unexpected reply from the Proton Drive service."),
ErrorKind::Internal,
),
Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
@@ -3204,7 +3155,7 @@ mod tests {
#[test]
fn a_search_hit_location_is_its_parent_folder() {
assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old");
assert_eq!(hit_location("a.txt"), "My Files");
assert_eq!(hit_location("a.txt"), "My files");
}
#[test]
+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.
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_devices(&ui_retry);
});
retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_devices));
let ui_ren = ui.clone();
ui.devices.rename_this.connect_clicked(move |_| {
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
@@ -202,29 +199,7 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
/// The daemon didn't answer the Devices page.
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
devices_status(
ui,
"network-offline-symbolic",
&gettext("Not connected"),
&gettext("The Proton Drive mount service isn't running."),
true,
);
return;
}
devices_status(
ui,
"folder-remote-symbolic",
&gettext("Connecting…"),
&gettext("Waiting for the Proton Drive mount service to come up."),
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("devices") {
load_devices(&ui);
}
});
service_unreachable(ui, "devices", devices_status, load_devices);
}
/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal
@@ -330,7 +305,7 @@ pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
"{platform} · last synced {time}",
&[
("platform", &dev.device_type),
("time", &activity_time(secs)),
("time", &dates::relative(secs)),
],
),
// Translators: {platform} is the device's platform, such as "Linux".
@@ -358,7 +333,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let manage = gtk4::Button::builder()
.label(gettext("Open Sync"))
.tooltip_text(gettext("Manage this computer's folders and mountpoint"))
.tooltip_text(gettext("Manage this computer's synced folders"))
.valign(gtk4::Align::Center)
.build();
manage.add_css_class("flat");
@@ -489,7 +464,7 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
_ => toast_error(
&ui,
&gettext("Couldn't add folder"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -529,7 +504,7 @@ pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>, device: Option<(String, String
_ => toast_error(
&ui,
&gettext("Couldn't list folders to restore"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
),
}
});
@@ -577,7 +552,7 @@ fn show_restore_picker(
.child(&group)
.build();
let dialog = adw::AlertDialog::builder()
.heading(gettext("Restore folders"))
.heading(gettext("Restore Folders"))
.body(restore_picker_body(
device.as_ref().map(|(_, n)| n.as_str()),
))
@@ -668,7 +643,7 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
Ok(Ok(Response::Ok { .. })) => toast(
&ui,
&if mode == "ondemand" {
gettext("Folder will switch to on-demand")
gettext("Folder will switch to online only")
} else {
gettext("Folder will download and stay synced")
},
@@ -679,7 +654,7 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
_ => toast_error(
&ui,
&gettext("Couldn't change mode"),
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
),
}
reload_sync_pages(&ui);
@@ -710,7 +685,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
let body = if ondemand {
// Translators: {path} is a local folder path.
gettext_f(
"Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton Drive, not on this disk, so the folder will be empty once it is unmounted. To keep a local copy, cancel, turn off On-demand, and wait for the download to finish before removing it.",
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
&[("path", path)],
)
} else {
@@ -721,7 +696,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
)
};
let dialog = adw::AlertDialog::builder()
.heading(gettext("Stop syncing folder"))
.heading(gettext("Stop Syncing Folder?"))
.body(body)
.build();
let group = adw::PreferencesGroup::new();
@@ -818,7 +793,7 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let group = adw::PreferencesGroup::new();
group.add(&confirm);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Remove Computer"))
.heading(gettext("Remove Computer?"))
// Translators: {name} is the name of the computer being removed.
.body(gettext_f(
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is deleted along with it. The files on that computer itself are not touched — but this cannot be undone from here.",
@@ -862,7 +837,7 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Use this computer's identity"))
.heading(gettext("Use This Computer's Identity?"))
// Translators: {name} is the name of the other computer whose identity is adopted.
.body(gettext_f(
"Treat this machine as “{name}”?\n\nNew synced folders are created under that computer in Proton Drive, and this machine keeps that identity even if its hostname changes. Folders already synced here are not moved.\n\nUse “Restore folders” afterwards to bring back what “{name}” was syncing.",
@@ -870,7 +845,7 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("adopt", &gettext("Use identity"));
dialog.add_response("adopt", &gettext("Use Identity"));
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
@@ -909,7 +884,11 @@ pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
}
+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_title(&failed);
view.status
.set_description(Some(&gettext("The mount service didn't respond.")));
.set_description(Some(&gettext("The Proton Drive service didn't respond.")));
view.progress.set_visible(false);
return;
}
};
view.progress.set_visible(false);
if sets.is_empty() {
view.status.set_icon_name(Some("emblem-ok-symbolic"));
view.status.set_icon_name(Some("pdfs-synced-symbolic"));
match finder {
Finder::Exact => {
view.status.set_title(&gettext("No duplicates"));
+391 -217
View File
@@ -19,7 +19,7 @@ pub(crate) struct LocationsState {
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) rows: RefCell<Vec<LocationRow>>,
pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>,
pub(crate) card: SyncCard,
@@ -36,6 +36,28 @@ pub(crate) struct SyncCard {
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.
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool);
@@ -106,24 +128,24 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
.visible(false)
.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()
.title(gettext("On this computer"))
.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."))
.title(gettext("Folders"))
.description(gettext("Synced folders keep a full copy on this computer. Online-only folders fetch each file when you open it."))
.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(&conflicts_group);
groups.append(&queue_group);
groups.append(&transfers_group);
groups.append(&group);
groups.append(&transfers_group);
groups.append(&queue_group);
let clamp = adw::Clamp::builder().child(&groups).build();
// Never scroll sideways: a location's title is a full path, and letting the
// row grow to fit one pushes the controls at its end off screen. Constrained,
// the path ellipsizes and the switch/remove/open stay reachable.
// Never scroll sideways: a location's subtitle holds a full path, and letting
// the row grow to fit one pushes the controls at its end off screen.
// Constrained, the path ellipsizes and the controls stay reachable.
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
@@ -209,12 +231,12 @@ fn build_sync_card() -> (
adw::SplitButton,
) {
let icon = gtk4::Image::builder()
.icon_name("emblem-synchronizing-symbolic")
.icon_name("pdfs-sync-symbolic")
.pixel_size(32)
.margin_top(6)
.margin_bottom(6)
.build();
icon.add_css_class("sidebar-status");
icon.add_css_class("sync-card-icon");
let menu = gio::Menu::new();
menu.append(
Some(&gettext("Pause for 1 Hour")),
@@ -242,7 +264,9 @@ fn build_sync_card() -> (
.title_lines(1)
.subtitle_lines(2)
.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_suffix(&pause);
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),
_ => 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);
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")),
"trash" => ("user-trash-symbolic", gettext("Move to trash")),
// 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) {
(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);
if waiting {
let retry = gtk4::Button::builder()
.icon_name("view-refresh-symbolic")
.label(gettext("Retry"))
.tooltip_text(gettext("Retry now"))
.valign(gtk4::Align::Center)
.build();
@@ -513,7 +541,7 @@ fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
_ => toast_error(
&ui,
&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.
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(
"{size}, changed {time}",
&[
("size", &glib::format_size(size)),
("time", &clock_time(modified)),
("size", &human_bytes(size)),
("time", &dates::relative(modified)),
],
)
}
@@ -657,7 +685,7 @@ pub(crate) fn prompt_resolve_conflict(ui: &Rc<Ui>, conflict: &ConflictInfo) {
}
};
let dialog = adw::AlertDialog::builder()
.heading(gettext("Resolve conflict"))
.heading(gettext("Resolve Conflict"))
.body(body)
.build();
let group = adw::PreferencesGroup::new();
@@ -736,7 +764,7 @@ fn resolve_conflict(ui: &Rc<Ui>, path: String, keep: ConflictKeep) {
_ => toast_error(
&ui,
&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.
pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
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);
}
});
service_unreachable(ui, "locations", locations_status, load_locations);
}
/// 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]) {
for row in ui.locations.rows.borrow_mut().drain(..) {
ui.locations.group.remove(&row);
let keys: Vec<Option<LocationKey>> = if items.is_empty() {
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() {
let row = adw::ActionRow::builder()
.title(gettext("No locations"))
.title(gettext("No folders yet"))
.subtitle(gettext(
"The mount service hasn't reported a mountpoint yet.",
"The Proton Drive service hasn't reported its folder yet.",
))
.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.rows.borrow_mut() = vec![row.upcast()];
return;
rows.push(LocationRow {
key: None,
row,
progress: gtk4::ProgressBar::new(),
});
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for spec in items {
let row = adw::ActionRow::builder()
.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());
for (spec, key) in items.iter().zip(keys) {
let row = location_row(ui, spec);
ui.locations.group.add(&row.row);
rows.push(LocationRow { key, ..row });
}
*ui.locations.rows.borrow_mut() = rows;
}
/// The primary mount's only control: where it lives. Changing it rewrites config
/// and offers a service restart, which is why it goes through the shared
/// [`prompt_mountpoint`] dialog.
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
let change = gtk4::Button::builder()
.label(pgettext("verb", "Change"))
.tooltip_text(gettext(
"Choose a different folder for the Proton Drive mount",
))
.valign(gtk4::Align::Center)
.build();
change.add_css_class("flat");
let ui_mp = ui.clone();
change.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
row.add_suffix(&change);
/// The parts of a folder row that move while a pass runs.
fn update_location_row(row: &LocationRow, spec: &MountSpec) {
// Translators: a synced folder's location and state, such as "~/Documents · Synced · up to date".
let subtitle = gettext_f(
"{path} · {state}",
&[
("path", &tilde_path(&spec.local_path)),
("state", &location_subtitle(spec)),
],
);
let subtitle = glib::markup_escape_text(&subtitle);
if row.row.subtitle().as_deref() != Some(subtitle.as_str()) {
row.row.set_subtitle(&subtitle);
}
// 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
/// syncing for one synced folder.
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
if spec.mode != MountMode::OnDemand {
let (icon, tooltip) = match spec.paused {
true => (
"media-playback-start-symbolic",
gettext("Resume syncing this folder"),
),
false => (
"media-playback-pause-symbolic",
gettext("Pause syncing this folder"),
),
};
let pause = gtk4::Button::builder()
.icon_name(icon)
.tooltip_text(tooltip)
.valign(gtk4::Align::Center)
.build();
pause.add_css_class("flat");
let ui_pause = ui.clone();
let paused = spec.paused;
pause.connect_clicked(move |_| set_folder_paused(&ui_pause, id, !paused));
row.add_suffix(&pause);
}
// A paused folder skips its passes, so Sync now would do nothing.
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 folder row: its name, where it is and what it is doing, the folder's mode
/// as a button that names it, and one menu for everything else. Activating the
/// row opens the folder.
fn location_row(ui: &Rc<Ui>, spec: &MountSpec) -> LocationRow {
let row = adw::ActionRow::builder()
.title(glib::markup_escape_text(&location_name(spec)).as_str())
.tooltip_text(&spec.local_path)
// 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)
.activatable(true)
.build();
row.add_prefix(&gtk4::Image::from_icon_name(location_icon(&spec.kind)));
let path = spec.local_path.clone();
row.connect_activated(move |_| open_path(&path));
// 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);
}
// A queued switch paints as already flipped: the daemon accepted it and will
// 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."))
let progress = gtk4::ProgressBar::builder()
.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.
.sensitive(!spec.paused)
.build();
let ui_mode = ui.clone();
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);
button.add_css_class("flat");
let remove = gtk4::Button::builder()
.icon_name("media-playback-stop-symbolic")
.tooltip_text(gettext("Stop syncing this folder"))
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let ui_rm = ui.clone();
let options = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
let popover = gtk4::Popover::builder().child(&options).build();
popover.add_css_class("menu");
for (choice, title, description) in [
(
false,
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();
// The folder's *current* mode, not a queued one: a switch that hasn't landed
// yet has not moved the files anywhere.
let is_ondemand = spec.mode == MountMode::OnDemand;
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, is_ondemand));
row.add_suffix(&remove);
menu.item(&gettext("Stop Syncing…"), move || {
prompt_remove_sync_folder(&ui, id, &path, is_ondemand)
});
}
/// Ask before switching a synced folder to on-demand: the switch frees disk
/// space by removing the local copies, which is not something to do by accident.
fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) {
/// Ask before making a synced folder online only: the switch frees disk space
/// by removing the local copies, which is not something to do by accident.
fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Make Folder On-Demand?"))
.heading(gettext("Make Folder Online Only?"))
// 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)]))
.build();
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_default_response(Some("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| {
if response == "ondemand" {
set_sync_folder_mode(&ui, id, "ondemand");
} else {
refresh_locations(&ui);
}
});
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.
fn set_folder_paused(ui: &Rc<Ui>, id: i64, paused: bool) {
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 })) => {
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);
});
@@ -1106,7 +1281,7 @@ fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
_ => toast_error(
&ui,
&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 {
MountKind::MyFiles => "folder-remote-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
/// 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
/// path. A mirror folder is a plain directory with no FUSE session, so "not
/// mounted" is its normal state and saying it would be noise.
/// path. A mirror folder is a plain directory with no FUSE session, so having
/// none is its normal state and saying so would be noise.
pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
let mut parts: Vec<String> = Vec::new();
match &spec.kind {
MountKind::MyFiles => parts.push(gettext("My files")),
MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
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::Unknown) | None, MountMode::OnDemand) => {
// Translators: a folder mode: files are kept online and fetched when opened.
parts.push(pgettext("folder mode", "On-demand"))
parts.push(folder_mode_label(true))
}
// Translators: a folder mode: a full copy is kept on this computer.
(Some(MountMode::Unknown) | None, _) => parts.push(pgettext("folder mode", "Synced")),
(Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)),
},
}
if matches!(spec.kind, MountKind::Device { .. }) {
@@ -1156,7 +1329,8 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|| spec.mode == MountMode::OnDemand
|| spec.pending_mode == Some(MountMode::OnDemand);
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(" · ")
}
@@ -1215,7 +1389,7 @@ mod tests {
);
assert_eq!(
location_subtitle(&spec),
"On-demand · up to date · not mounted"
"Online only · up to date · not available"
);
}
@@ -1227,7 +1401,7 @@ mod tests {
true,
);
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.pending_mode = Some(MountMode::Mirror);
assert!(location_subtitle(&spec).starts_with("Switching to synced"));
@@ -1240,7 +1414,7 @@ mod tests {
let mut spec = spec(MountKind::MyFiles, MountMode::OnDemand, true);
assert_eq!(location_subtitle(&spec), "My files");
spec.mounted = false;
assert_eq!(location_subtitle(&spec), "My files · not mounted");
assert_eq!(location_subtitle(&spec), "My files · not available");
}
#[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.
*ui.session.borrow_mut() = auth::load().ok();
// Enable+start the mount service now that we have a session.
service::enable_start();
let _ = gio::spawn_blocking(service::enable_start).await;
refresh(&ui);
}
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.
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Two-factor authentication"))
.heading(gettext("Two-Factor Authentication"))
.body(gettext(
"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"),
move || {
service::disable_stop();
if let Err(e) = auth::logout() {
tracing::error!("logout failed: {e}");
}
*ui.session.borrow_mut() = None;
refresh(&ui);
// systemctl and the keyring both block, so run them on a worker.
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(|| {
service::disable_stop();
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(_)) => 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.")),
}
});
@@ -1136,7 +1136,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
.child(
&adw::ButtonContent::builder()
.label(gettext("Show on map"))
.icon_name("map-symbolic")
.icon_name("pdfs-map-symbolic")
.build(),
)
.halign(gtk4::Align::Start)
@@ -1426,7 +1426,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
None
}
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 {
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.
pub(crate) import_banner: adw::Banner,
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
/// 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.
@@ -401,12 +399,10 @@ pub(crate) struct GalleryWidgets {
pub(crate) scroll: gtk4::ScrolledWindow,
pub(crate) retry: gtk4::Button,
pub(crate) upload: gtk4::Button,
pub(crate) import: gtk4::Button,
pub(crate) empty_actions: gtk4::Box,
pub(crate) empty_upload: gtk4::Button,
pub(crate) empty_import: gtk4::Button,
pub(crate) refresh: gtk4::Button,
pub(crate) duplicates: gtk4::Button,
/// The Select toggle and the bar it reveals.
pub(crate) select_btn: gtk4::ToggleButton,
pub(crate) select_bar: gtk4::Revealer,
@@ -567,34 +563,36 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.build();
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();
// The duplicate finder opens over the page: a review, not a view of it.
let duplicates = gtk4::Button::builder()
.icon_name("edit-copy-symbolic")
.tooltip_text(gettext("Find duplicates"))
// Importing an export and hunting for duplicates are rarer, heavier jobs
// than adding one photo, so they wait in the page menu rather than taking
// a header button each.
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)
.menu_model(&page_menu)
.build();
duplicates.add_css_class("flat");
duplicates.add_css_class("circular");
more.add_css_class("flat");
// 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()
.icon_name("selection-mode-symbolic")
.label(pgettext("verb", "Select"))
.tooltip_text(gettext("Select photos"))
.valign(gtk4::Align::Center)
.build();
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
// 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_status, Some("status"));
let new_album = gtk4::Button::builder()
.icon_name("list-add-symbolic")
.label(gettext("New Album…"))
.child(
&adw::ButtonContent::builder()
.label(gettext("New Album…"))
.icon_name("list-add-symbolic")
.build(),
)
.halign(gtk4::Align::Start)
.action_name("win.new-album")
.build();
@@ -896,10 +898,9 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
let (frame, header, title) = page_frame(&gettext("Photos"), &body);
header.pack_start(&back);
header.pack_start(&upload);
header.pack_end(&more);
header.pack_end(&refresh);
header.pack_end(&select_btn);
header.pack_end(&import);
header.pack_end(&duplicates);
(
frame.upcast(),
@@ -916,12 +917,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
scroll,
retry,
upload,
import,
empty_actions,
empty_upload,
empty_import,
refresh,
duplicates,
select_btn,
select_bar,
select_label,
@@ -1408,14 +1407,6 @@ pub(crate) fn wire_gallery(
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();
ui.gallery.upload.connect_clicked(move |_| {
let dialog = gtk4::FileDialog::builder()
@@ -1493,7 +1484,7 @@ pub(crate) fn wire_gallery(
toast_error(
&ui_clone,
&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
// 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
// the rest (a live photo, a burst). Zoomed out to years the badge would
// cover most of a tile, so it is left off there.
if (tile.photo.has_raw || tile.photo.group_size > 1) && tile.grouping != Grouping::Year {
// the rest (a live photo, a burst). Only the Day zoom shows it: on the
// smaller Month and Year tiles a badge on every raw+JPEG shot turns the
// grid into noise.
if (tile.photo.has_raw || tile.photo.group_size > 1) && tile.grouping == Grouping::Day {
let badge = gtk4::Label::builder()
.label(if tile.photo.has_raw {
// 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()
.heading(gettext("Move to Trash"))
.heading(gettext("Move to Trash?"))
.body(body)
.build();
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(
&ui,
&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(
&ui,
&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,
"dialog-warning-symbolic",
&gettext("Couldn't load photos"),
&gettext("Unexpected reply from the mount service."),
&gettext("Unexpected reply from the Proton Drive service."),
false,
),
Ok(Err(_)) | Err(_) if append => toast_error(
&ui,
&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),
}
@@ -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
/// still starting, surface an actionable error + Retry once it's actually down.
pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
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);
}
});
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
}
/// 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,
"network-offline-symbolic",
&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,
/// 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) {
// A label, not a bookmark glyph: nothing about the icon says what a Drive
// bookmark is.
let add_bookmark = gtk4::Button::builder()
.icon_name("bookmark-new-symbolic")
.tooltip_text(gettext(
"Add Bookmark — save a public link to open it from here",
))
.label(gettext("Add Bookmark…"))
.tooltip_text(gettext("Save a public link to open it from here"))
.build();
add_bookmark.add_css_class("flat");
let refresh = refresh_button();
// 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.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("emblem-shared-symbolic")
.icon_name("pdfs-people-symbolic")
.vexpand(true)
.child(&retry)
.build();
@@ -111,7 +112,7 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
.tooltip_text(gettext("Back"))
.visible(false)
.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_end(&refresh);
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);
});
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_shared(&ui_retry);
});
retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared));
let ui_add = ui.clone();
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);
shared_status(
ui,
"emblem-shared-symbolic",
"pdfs-people-symbolic",
&gettext("Loading…"),
&gettext("Reading your shared items."),
false,
@@ -429,7 +427,7 @@ pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
if !entry.shared_by.is_empty() {
parts.push(entry.shared_by.clone());
if entry.shared_at > 0 {
parts.push(format_modified(entry.shared_at));
parts.push(dates::short_date(entry.shared_at));
}
}
if !entry.is_dir {
@@ -507,7 +505,7 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
_ => toast_error(
&ui,
&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
/// as the other pages.
pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
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);
}
});
service_unreachable(ui, "shared", shared_status, load_shared);
}
/// 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() {
shared_status(
ui,
"emblem-shared-symbolic",
"pdfs-people-symbolic",
&gettext("Nothing Shared with You"),
&gettext(
"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()));
}
// Invitations: inviter + item, Accept / Reject.
// Invitations: inviter + item, Accept / Decline.
for inv in invitations {
let item = inv.name.clone().unwrap_or_else(|| gettext("a shared item"));
let row = adw::ActionRow::builder()
@@ -606,12 +582,13 @@ pub(crate) fn repaint_shared(
} else {
"text-x-generic-symbolic"
}));
// Labelled: a close glyph here reads as "dismiss this row".
let reject = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text(gettext("Reject"))
.label(gettext("Decline"))
.valign(gtk4::Align::Center)
.build();
reject.add_css_class("flat");
reject.add_css_class("pill");
let accept = gtk4::Button::builder()
.label(gettext("Accept"))
.valign(gtk4::Align::Center)
@@ -631,13 +608,13 @@ pub(crate) fn repaint_shared(
let id = id_rej.clone();
confirm_destructive(
btn,
&gettext("Reject Invitation?"),
&gettext("Decline Invitation?"),
// Translators: {name} is the shared item's name, or "this item".
&gettext_f(
"You won't have access to {name} unless it is shared again.",
&[("name", &name_rej)],
),
&gettext("Reject"),
&gettext("Decline"),
move || respond_invitation(&ui, &id, false),
);
});
@@ -662,7 +639,7 @@ pub(crate) fn repaint_shared(
.build();
remove.add_css_class("flat");
let open = gtk4::Button::builder()
.icon_name("external-link-symbolic")
.icon_name("adw-external-link-symbolic")
.tooltip_text(gettext("Open in browser"))
.valign(gtk4::Align::Center)
.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) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Leave shared item"))
.heading(gettext("Leave Shared Item?"))
// Translators: {name} is the shared item's name.
.body(gettext_f(
"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) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Remove bookmark"))
.heading(gettext("Remove Bookmark?"))
// Translators: {name} is the bookmark's name.
.body(gettext_f(
"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>) {
let win = ui_window(ui);
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."))
.build();
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 })) => {
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.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("emblem-shared-symbolic")
.icon_name("pdfs-share-symbolic")
.vexpand(true)
.child(&retry)
.build();
@@ -60,7 +60,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
inner.set_margin_start(18);
inner.set_margin_end(18);
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);
(
@@ -78,10 +78,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
/// Install the Shared (by-me) page's retry button.
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_shared_by_me(&ui_retry);
});
retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared_by_me));
}
/// 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);
shared_by_me_status(
ui,
"emblem-shared-symbolic",
"pdfs-share-symbolic",
&gettext("Loading…"),
&gettext("Reading what you've shared."),
false,
@@ -136,7 +133,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
&ui,
"dialog-warning-symbolic",
&gettext("Couldn't read your shares"),
&gettext("Unexpected reply from the mount service."),
&gettext("Unexpected reply from the Proton Drive service."),
false,
),
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.
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
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);
}
});
service_unreachable(ui, "sharedbyme", shared_by_me_status, load_shared_by_me);
}
/// 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() {
shared_by_me_status(
ui,
"emblem-shared-symbolic",
"pdfs-share-symbolic",
&gettext("Nothing shared yet"),
&gettext("Items you share with people or by link show up here."),
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.
if let Some(url) = url.clone() {
let copy = gtk4::Button::builder()
.icon_name("edit-copy-symbolic")
.icon_name("pdfs-link-symbolic")
.tooltip_text(gettext("Copy link"))
.valign(gtk4::Align::Center)
.build();
@@ -219,6 +194,7 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
let ui_copy = ui.clone();
copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url);
flash_copied(btn, "pdfs-link-symbolic");
toast(&ui_copy, &gettext("Link copied"));
});
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.
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Stop sharing?"))
.heading(gettext("Stop Sharing?"))
// Translators: {name} is the shared item's name.
.body(gettext_f(
"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
/// systemd user service, not by the user — so this only reports.
pub(crate) status_icon: gtk4::Image,
/// Stands in for [`Self::status_icon`] until the first status arrives.
pub(crate) status_spinner: gtk4::Spinner,
pub(crate) status_title: gtk4::Label,
pub(crate) status_detail: gtk4::Label,
pub(crate) cache_bar: gtk4::ProgressBar,
@@ -82,8 +84,25 @@ pub(crate) struct StatusState {
/// How many transfers were in flight on the previous poll. A drop to zero is
/// what "sync complete" means; there's no completion event on the wire.
pub(crate) active_transfers: Cell<usize>,
/// The run of transfers since the count last left zero, for deciding
/// whether its end is worth a "Sync complete" notification.
pub(crate) transfer_batch: RefCell<Option<TransferBatch>>,
}
/// One run of transfers, from the first file to the count falling back to 0.
pub(crate) struct TransferBatch {
started: Instant,
/// Every file seen in flight during the run, so the notification can count
/// files rather than the handful still moving on the last poll.
names: HashSet<String>,
}
/// A run of transfers earns a "Sync complete" notification only when it moved
/// at least this many files or lasted at least [`SYNC_NOTIFY_MIN_DURATION`].
/// Saving one document at a time would otherwise notify on every save.
const SYNC_NOTIFY_MIN_FILES: usize = 5;
const SYNC_NOTIFY_MIN_DURATION: Duration = Duration::from_secs(30);
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
pub(crate) struct PinRow {
@@ -121,6 +140,7 @@ pub(crate) struct MainWidgets {
pub(crate) account_name: gtk4::Label,
pub(crate) avatar: adw::Avatar,
pub(crate) status_icon: gtk4::Image,
pub(crate) status_spinner: gtk4::Spinner,
pub(crate) status_title: gtk4::Label,
pub(crate) status_detail: gtk4::Label,
pub(crate) quota_box: gtk4::Box,
@@ -178,9 +198,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
mountpoint_row.add_css_class("property");
let mountpoint_button = gtk4::Button::builder()
.label(gettext("Change…"))
.tooltip_text(gettext(
"Choose a different folder for the Proton Drive mount",
))
.tooltip_text(gettext("Choose where My files appears on this computer"))
.valign(gtk4::Align::Center)
.build();
mountpoint_button.add_css_class("flat");
@@ -310,7 +328,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
prefs.add(&storage);
// ---- 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()
.label(gettext("Connecting…"))
.halign(gtk4::Align::Start)
@@ -329,6 +350,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
status_text.append(&status_title);
status_text.append(&status_detail);
let status_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
status_content.append(&status_spinner);
status_content.append(&status_icon);
status_content.append(&status_text);
let status_button = gtk4::Button::builder()
@@ -390,6 +412,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
account_name,
avatar,
status_icon,
status_spinner,
status_title,
status_detail,
quota_box,
@@ -491,7 +514,17 @@ pub(crate) fn wire_settings(
ui.status
.mountpoint_row
.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
.theme_row
.set_active(config.proton_theme.unwrap_or(false));
@@ -587,11 +620,14 @@ pub(crate) fn wire_settings(
if ui_auto.status.settings_suppress.get() {
return;
}
if row.is_active() {
service::enable();
} else {
service::disable();
}
let enable = row.is_active();
gio::spawn_blocking(move || {
if enable {
service::enable();
} else {
service::disable();
}
});
});
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 })) => {
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>) {
let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder()
.title(gettext("Choose mountpoint folder"))
.title(gettext("Choose Proton Drive Folder"))
.build();
let ui = ui.clone();
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();
config.mountpoint = Some(path_str.clone());
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;
}
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.
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.
.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)],
))
.build();
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_default_response(Some("restart"));
confirm.set_close_response("later");
confirm.connect_response(None, |_, resp| {
let ui_restart = ui.clone();
confirm.connect_response(None, move |_, resp| {
if resp == "restart" {
service::restart();
restart_service_then(&ui_restart, |_| {});
}
});
confirm.present(ui_window(&ui).as_ref());
@@ -767,20 +808,20 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
/// reload the page.
pub(crate) fn wire_retry(ui: &Rc<Ui>) {
let ui_browser = ui.clone();
ui.browser.retry.clone().connect_clicked(move |_| {
service::restart();
load_browser(&ui_browser);
});
ui.browser
.retry
.clone()
.connect_clicked(move |_| restart_service_then(&ui_browser, load_browser));
let ui_gallery = ui.clone();
ui.gallery.retry.clone().connect_clicked(move |_| {
service::restart();
load_gallery(&ui_gallery, false);
});
ui.gallery
.retry
.clone()
.connect_clicked(move |_| restart_service_then(&ui_gallery, |ui| load_gallery(ui, false)));
let ui_trash = ui.clone();
ui.trash.retry.clone().connect_clicked(move |_| {
service::restart();
load_trash(&ui_trash);
});
ui.trash
.retry
.clone()
.connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
}
/// Repaint the window from the cached login identity, then kick an async mount-
@@ -848,9 +889,8 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
/// active-page tick refetches it only this often rather than every 2s.
const QUOTA_TTL: Duration = Duration::from_secs(60);
/// Fetch the account quota (if the last reading is stale) and paint both the
/// sidebar footer and Files status bar. A failed fetch leaves the last
/// good reading in place.
/// Fetch the account quota (if the last reading is stale) and paint the
/// sidebar footer. A failed fetch leaves the last good reading in place.
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
if ui.status.quota_inflight.get() {
return;
@@ -874,10 +914,6 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
ui.status.quota_checked_at.set(Some(Instant::now()));
paint_account_quota(&ui, max_space, used_space);
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);
ui.status.quota_bar.set_fraction(fraction);
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) {
@@ -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
/// 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(
"mount-state",
&gettext("Proton Drive connected"),
&gettext("Your Drive is mounted and available."),
&gettext("Your files are available."),
);
} else {
notify(
"mount-state",
&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);
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 {
notify(
"sync-complete",
&gettext("Sync complete"),
&ngettext_f(
"{n} file finished transferring.",
"{n} files finished transferring.",
previous as u64,
&[],
),
);
// Nobody needs a notification for what they are watching happen.
let watching = ui_window(ui).is_some_and(|window| window.is_active());
if let Some(batch) = ui.status.transfer_batch.take()
&& !watching
&& (batch.names.len() >= SYNC_NOTIFY_MIN_FILES
|| batch.started.elapsed() >= SYNC_NOTIFY_MIN_DURATION)
{
notify(
"sync-complete",
&gettext("Sync complete"),
&ngettext_f(
"{n} file finished transferring.",
"{n} files finished transferring.",
batch.names.len() as u64,
&[],
),
);
}
// A just-finished batch may have added files (bulk upload) the current
// listing doesn't show yet; refresh whichever listing is on screen.
reload_listing(ui);
@@ -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
// draw against no limit.
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 {
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);
}
@@ -1321,13 +1349,13 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
Some(queued.unwrap_or_else(|| gettext("Cached files only"))),
),
SyncState::Syncing { queued } => (
"emblem-synchronizing-symbolic",
"pdfs-sync-symbolic",
None,
pgettext("state", "Syncing"),
Some(queued),
),
SyncState::UpToDate { mountpoint } => (
"emblem-ok-symbolic",
"pdfs-synced-symbolic",
Some("success"),
gettext("Up to date"),
Some(mountpoint),
@@ -1341,6 +1369,9 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
};
let image = &ui.status.status_icon;
image.set_icon_name(Some(icon));
image.set_visible(true);
ui.status.status_spinner.set_visible(false);
ui.status.status_spinner.set_spinning(false);
for c in ["success", "warning", "error"] {
image.remove_css_class(c);
}
@@ -1474,7 +1505,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
_ => toast_error(
&ui,
&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)]
mod tests {
use super::{quota_display, quota_status_display};
use super::quota_display;
#[test]
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(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 ui = ui_import.clone();
let dialog = adw::AlertDialog::builder()
.heading(gettext("Import this export?"))
.heading(gettext("Import This Export?"))
.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} 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(
&ui,
&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(
&ui,
&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()
.label(gettext("Empty Trash…"))
.tooltip_text(gettext("Permanently delete everything in the Trash"))
.sensitive(false)
.visible(false)
.build();
let refresh = refresh_button();
@@ -215,7 +215,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
} else {
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.
let text = gettext_f(
"{kind} · {modified}",
@@ -317,7 +317,7 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
&ui,
"dialog-warning-symbolic",
&gettext("Couldn't read the trash"),
&gettext("Unexpected reply from the mount service."),
&gettext("Unexpected reply from the Proton Drive service."),
false,
),
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
/// again, no button) versus actually down (Retry, which restarts the service).
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
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);
}
});
service_unreachable(ui, "trash", trash_status, load_trash);
}
/// 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]) {
ui.trash.model.remove_all();
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() {
trash_status(
ui,
@@ -451,7 +432,7 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
),
};
let dialog = adw::AlertDialog::builder()
.heading(gettext("Delete Permanently"))
.heading(gettext("Delete Permanently?"))
.body(body)
.build();
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 count = ui.trash.model.n_items();
let dialog = adw::AlertDialog::builder()
.heading(gettext("Empty Trash"))
.heading(gettext("Empty Trash?"))
.body(ngettext_f(
"Permanently delete all {n} item 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.
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let toggle = gtk4::ToggleButton::builder()
.icon_name("sidebar-show-right-symbolic")
.icon_name("pdfs-details-pane-symbolic")
.tooltip_text(gettext("Details (Alt+Enter)"))
.build();
@@ -202,7 +202,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let toggle_off = toggle.clone();
empty_close.connect_clicked(move |_| toggle_off.set_active(false));
let empty_status = adw::StatusPage::builder()
.icon_name("sidebar-show-right-symbolic")
.icon_name("pdfs-details-pane-symbolic")
.title(gettext("No item selected"))
.description(gettext("Select a file or folder to see its details."))
.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.modified_row
.set_subtitle(&format_modified(entry.modified));
.set_subtitle(&dates::short_date(entry.modified));
let rel = entry_rel(ui, entry);
let parent = match rel.rfind('/') {
Some(i) => &rel[..i],
@@ -494,13 +494,13 @@ pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
match (items.len(), latest) {
(0, _) | (_, None) => gettext("No earlier versions"),
// 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.
(n, Some(t)) => ngettext_f(
"{n} version · latest {date}",
"{n} versions · latest {date}",
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(
&state.ui,
&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();
copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url_copy);
flash_copied(btn, "edit-copy-symbolic");
toast(&state_copy.ui, &gettext("Link copied"));
});
row.add_suffix(&copy);
@@ -718,7 +719,7 @@ pub(crate) fn share_dialog_create_link(
_ => toast_error(
&state.ui,
&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(
&state.ui,
&failed,
&gettext("The mount service didn't respond."),
&gettext("The Proton Drive service didn't respond."),
);
share_dialog_reload(&state);
}
@@ -167,7 +167,7 @@ pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
_ => toast_error(
&state.ui,
&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(
"{time} · {size}",
&[
("time", &activity_time(item.created)),
("time", &dates::relative(item.created)),
("size", &human_bytes(size)),
],
),
)
} else {
(activity_time(item.created), human_bytes(size))
(dates::relative(item.created), human_bytes(size))
};
if let Some(email) = &item.signed_by {
subtitle.push_str(" · ");
@@ -266,7 +266,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
/// every device on the account, not only this one.
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Restore version"))
.heading(gettext("Restore Version?"))
// Translators: {name} is a file name.
.body(gettext_f(
"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.
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Delete version"))
.heading(gettext("Delete Version?"))
.body(gettext(
"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(
&state.ui,
&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
/// the largest one, which is the plural in every shipped language.
fn plural_count(n: u64) -> u32 {
+5 -5
View File
@@ -53,7 +53,7 @@ impl Phase {
fn icon(self) -> &'static str {
match self {
Phase::Synced => "folder-remote-symbolic",
Phase::Syncing => "emblem-synchronizing-symbolic",
Phase::Syncing => "view-refresh-symbolic",
Phase::Paused => "media-playback-pause-symbolic",
Phase::Offline => "network-offline-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.
(true, None) => ngettext_f(
"Mounted at {mountpoint} ({n} pinned)",
"Mounted at {mountpoint} ({n} pinned)",
"Up to date in {mountpoint} ({n} pinned)",
"Up to date in {mountpoint} ({n} pinned)",
pinned as u64,
&[("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.
Ok(_) => DriveState {
line: gettext("Mount: unexpected daemon response"),
line: gettext("Proton Drive sent an unexpected reply"),
phase: Phase::Attention,
mounted: true,
mountpoint: default_mountpoint.to_path_buf(),
@@ -218,7 +218,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
Ok(s) => (
// Translators: {username} is the Proton account name.
gettext_f(
"Logged in as {username} — not mounted",
"Signed in as {username} — not connected",
&[("username", &s.username)],
),
Phase::Disconnected,
+6 -25
View File
@@ -24,7 +24,7 @@ use pdfs_core::opener::{self, OpenWith};
mod activation;
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 fzf;
mod query;
@@ -586,7 +586,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let offline = adw::StatusPage::builder()
.icon_name("network-offline-symbolic")
.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();
offline.add_css_class("compact");
let retry = gtk4::Button::builder()
@@ -876,7 +876,7 @@ impl Ui {
}
_ => {
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");
}
}
@@ -890,7 +890,7 @@ impl Ui {
self.spinner.stop();
self.spinner.set_visible(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");
}
@@ -1322,7 +1322,7 @@ impl Ui {
&[("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);
}
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");
meta.append(&size);
}
@@ -1445,25 +1445,6 @@ fn parent_of(path: &str) -> String {
.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.
fn format_age(epoch_secs: i64) -> String {
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.
- 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.
- 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
+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` |
| Pinned file | Available offline | pin/download-filled custom icon, not stars |
| 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 |
| Remove a computer | Remove computer… | destructive, in menu |
| 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/app/dates.rs
crates/pdfs-gui/src/app/main.rs
crates/pdfs-gui/src/app/pages/activity.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