feat(core,gui): split Sync into views, tidy Activity, rework Computers

The Sync page gets Overview, Folders and History views under a switcher in
the header. Overview holds the status card, conflicts, transfers and the
queue; Folders holds the folder list, with Add Folder and a popover that
explains the sync modes; History is the former Activity page, which leaves
the sidebar. A banner in Folders and History points back to Overview when
changes fail or conflict.

Activity hides save-by-rename temporaries (pdfs-core gains
syncignore::is_scratch_name), with a toggle to show them again. Runs of
similar events fold into one expandable row, "Show Older" pages further back
through what the daemon keeps, and titles and details are translated
sentences built from the daemon's targets instead of its raw text.
Conflict copies say when they were made.

Computers lists every device in one group, this computer first with a
"This computer" pill; its row opens Sync's folder list. Another computer's
row browses its backup read-only in the shared file list, and its menu
offers Browse Files, Restore, Rename, "Continue This Backup Here…" (the old
identity adoption, now explained in its dialog) and Remove. Subtitles read
"last backup" or "no backups yet", and an empty list links to the download
page. DirEntry derives Default for the browse view's synthesized entries.

The sidebar shows badges for sync problems and waiting invitations, polled
every five minutes away from their pages. All catalogs are updated and
complete.
This commit is contained in:
nvrl committed 2026-09-28 15:14:59 +02:00
1 parent 127b71efd0
commit da7ea0ec56
22 files changed
+11984 -5767

No files matched your search

+1 -1
View File
@@ -1093,7 +1093,7 @@ pub struct TransferItem {
}
/// One entry in a [`Request::ListDir`] listing.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub struct DirEntry {
/// Decrypted node name.
pub name: String,
+39
View File
@@ -96,6 +96,26 @@ pub fn is_transient_name(name: &str) -> bool {
false
}
/// Whether `name` is scratch another program writes and throws away: the
/// [`is_transient_name`] set, plus the save-by-rename temporaries of GTK, Qt,
/// Chromium and Electron, generic `.tmp` files and Emacs lock files. None of
/// them is a file the user made, so a front-end can hide them from a history
/// of what happened to the user's files.
pub fn is_scratch_name(name: &str) -> bool {
if is_transient_name(name) {
return true;
}
let lower = name.to_ascii_lowercase();
lower.ends_with(".tmp")
|| lower.ends_with(".temp")
|| name.starts_with(".goutputstream-")
|| name.starts_with(".org.chromium.")
|| name.starts_with(".com.google.chrome.")
|| name.starts_with(".#")
|| (name.starts_with('#') && name.ends_with('#') && name.len() > 1)
|| (name.starts_with('.') && name.contains(".tmp."))
}
/// Filenames accepted as a folder's ignore file, in precedence order.
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
@@ -341,6 +361,25 @@ mod tests {
}
}
#[test]
fn save_by_rename_temporaries_are_scratch() {
for name in [
".goutputstream-4XK2B2",
".org.chromium.Chromium.aBc123",
"~$report.docx",
".~lock.budget.ods#",
".#notes.org",
"#notes.org#",
"cache.tmp",
"page.html.crdownload",
] {
assert!(is_scratch_name(name), "{name}");
}
for name in ["report.docx", "notes.org", "#1 hits.txt", ".bashrc"] {
assert!(!is_scratch_name(name), "{name}");
}
}
#[test]
fn finished_names_are_not_transient() {
for name in [
+11 -2
View File
@@ -19,10 +19,19 @@
.sidebar-status image.error { color: var(--error-color); }
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
.sidebar-account { padding: 4px 4px 4px 8px; }
/* A count of what waits on a page, at the end of its sidebar row */
.sidebar-badge {
min-width: 10px;
padding: 0 7px;
border-radius: 999px;
background: alpha(currentColor, 0.15);
font-size: 0.8em;
font-weight: 700;
}
/* 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 { padding: 18px 24px; }
.sync-card-icon { padding: 6px; }
.sync-card-icon.success { color: var(--success-color); }
.sync-card-icon.warning { color: var(--warning-color); }
.sync-card-icon.error { color: var(--error-color); }
+53 -38
View File
@@ -465,8 +465,8 @@ fn build_window(app: &adw::Application) {
let (shared_page, shared_widgets) = build_shared_page();
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
let (devices_page, devices_widgets) = build_devices_page();
let (locations_page, locations_widgets) = build_locations_page();
let (activity_page, activity_widgets) = build_activity_page();
let (locations_page, locations_widgets) = build_locations_page(&activity_page);
let (trash_page, trash_widgets) = build_trash_page();
let (takeout_page, takeout_widgets) = build_takeout_page();
// The login page has no title and no actions, but it still needs a header
@@ -485,7 +485,6 @@ fn build_window(app: &adw::Application) {
stack.add_named(&shared_page, Some("shared"));
stack.add_named(&devices_page, Some("devices"));
stack.add_named(&locations_page, Some("locations"));
stack.add_named(&activity_page, Some("activity"));
stack.add_named(&trash_page, Some("trash"));
stack.add_named(&takeout_page, Some("takeout"));
@@ -706,19 +705,7 @@ fn build_window(app: &adw::Application) {
},
shared: SharedState::new(&shared_widgets),
shared_by_me: SharedByMeState::new(&shared_by_me_widgets),
devices: DevicesState {
content: devices_widgets.content.clone(),
status: devices_widgets.status.clone(),
retry: devices_widgets.retry.clone(),
group: devices_widgets.group.clone(),
rows: RefCell::new(Vec::new()),
sync_group: devices_widgets.sync_group.clone(),
sync_rows: RefCell::new(Vec::new()),
rename_this: devices_widgets.rename_this.clone(),
this_device: RefCell::new(None),
loader: Loader::new(&devices_widgets.content),
loaded_at: Cell::new(None),
},
devices: DevicesState::new(&devices_widgets),
locations: LocationsState {
content: locations_widgets.content.clone(),
status: locations_widgets.status.clone(),
@@ -730,7 +717,8 @@ fn build_window(app: &adw::Application) {
loaded_at: Cell::new(None),
card: SyncCard {
icon: locations_widgets.card_icon.clone(),
row: locations_widgets.card_row.clone(),
title: locations_widgets.card_title.clone(),
detail: locations_widgets.card_detail.clone(),
pause: locations_widgets.pause.clone(),
paused: Cell::new(false),
},
@@ -748,6 +736,9 @@ fn build_window(app: &adw::Application) {
inflight: Cell::new(false),
fetched_at: Cell::new(None),
},
views: locations_widgets.views.clone(),
banner: locations_widgets.banner.clone(),
failing: Cell::new(0),
},
activity: ActivityState {
content: activity_widgets.content.clone(),
@@ -762,6 +753,8 @@ fn build_window(app: &adw::Application) {
conflicts: RefCell::new(None),
conflicts_inflight: Cell::new(false),
conflicts_at: Cell::new(None),
show_scratch: Cell::new(false),
limit: Cell::new(ACTIVITY_PAGE),
},
takeout: TakeoutState {
archives: RefCell::new(Vec::new()),
@@ -819,7 +812,7 @@ fn build_window(app: &adw::Application) {
wire_trash(&ui, &trash_widgets);
wire_shared(&ui, &shared_widgets);
wire_shared_by_me(&ui, &shared_by_me_widgets);
wire_devices(&ui, &devices_widgets.retry);
wire_devices(&ui, &devices_widgets);
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
wire_activity(&ui, &activity_widgets);
wire_takeout(&ui, &takeout_widgets);
@@ -833,7 +826,6 @@ fn build_window(app: &adw::Application) {
&shared_by_me_widgets.refresh,
&devices_widgets.refresh,
&locations_widgets.refresh,
&activity_widgets.refresh,
],
);
wire_retry(&ui);
@@ -859,16 +851,7 @@ fn build_window(app: &adw::Application) {
Some("shared") => load_shared(&ui_nav),
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
Some("devices") => load_devices(&ui_nav),
Some("locations") => {
refresh_conflicts(&ui_nav, true);
if !page_fresh(&ui_nav.locations.loaded_at) {
load_locations(&ui_nav);
}
}
// Activity is intentionally not TTL-cached: it changes out from under
// the page as background uploads and edits complete, so it reloads on
// every visit to stay live.
Some("activity") => load_activity(&ui_nav),
Some("locations") => load_sync_view(&ui_nav),
Some("trash") => load_trash(&ui_nav),
// The import runs in the daemon and outlives this page, so arriving
// here asks straight away whether one is in flight.
@@ -957,7 +940,7 @@ fn window_hidden(window: &adw::ApplicationWindow) -> bool {
/// 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
/// sync, set off by a separator.
const DESTINATIONS: [(&str, &str, &str); 8] = [
const DESTINATIONS: [(&str, &str, &str); 7] = [
("browser", gettext_noop("My files"), "folder-symbolic"),
(
"gallery",
@@ -978,11 +961,6 @@ const DESTINATIONS: [(&str, &str, &str); 8] = [
("trash", gettext_noop("Trash"), "user-trash-symbolic"),
// Translators: sidebar entry for the page listing this computer's synced folders.
("locations", gettext_noop("Sync"), "pdfs-sync-symbolic"),
(
"activity",
gettext_noop("Activity"),
"document-open-recent-symbolic",
),
];
/// Index of the first row in the sidebar's sync section.
@@ -998,7 +976,20 @@ fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
for (_, label, icon) in DESTINATIONS {
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
row_box.append(&gtk4::Image::from_icon_name(icon));
row_box.append(&gtk4::Label::new(Some(&gettext(label))));
let title = gtk4::Label::builder()
.label(gettext(label))
.xalign(0.0)
.hexpand(true)
.build();
row_box.append(&title);
// A count of what waits there; see `set_sidebar_badge`.
let badge = gtk4::Label::builder()
.visible(false)
.valign(gtk4::Align::Center)
.build();
badge.add_css_class("sidebar-badge");
badge.add_css_class("numeric");
row_box.append(&badge);
let row = gtk4::ListBoxRow::builder().child(&row_box).build();
list.append(&row);
}
@@ -1190,9 +1181,8 @@ fn reload_current_page(ui: &Rc<Ui>) {
}
Some("locations") => {
ui.locations.loaded_at.set(None);
load_locations(ui);
reload_sync_view(ui);
}
Some("activity") => load_activity(ui),
_ => {}
}
}
@@ -1276,6 +1266,24 @@ fn sync_sidebar(ui: &Rc<Ui>) {
}
}
/// Show `count` on the sidebar row of `page`, or nothing when it is 0.
pub(crate) fn set_sidebar_badge(ui: &Ui, page: &str, count: u64) {
let Some(index) = DESTINATIONS.iter().position(|(p, _, _)| *p == page) else {
return;
};
let Some(badge) = ui
.sidebar
.row_at_index(index as i32)
.and_then(|row| row.child())
.and_then(|row_box| row_box.last_child())
.and_downcast::<gtk4::Label>()
else {
return;
};
badge.set_visible(count > 0);
badge.set_label(&count.to_string());
}
/// Show a transient toast. Non-blocking by design: an action's outcome is
/// reported without stealing focus or forcing a click, so the user can keep
/// working while a slow upload lands.
@@ -1522,7 +1530,14 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
return;
};
// Signed out, the login page owns the window; unknown names are ignored.
if ui_page.session.borrow().is_some() && ui_page.stack.child_by_name(page).is_some() {
if ui_page.session.borrow().is_none() {
return;
}
// Activity lives on as the Sync page's History view.
if page == "activity" {
ui_page.locations.views.set_visible_child_name("history");
ui_page.stack.set_visible_child_name("locations");
} else if ui_page.stack.child_by_name(page).is_some() {
ui_page.stack.set_visible_child_name(page);
}
});
+476 -91
View File
@@ -1,4 +1,7 @@
use crate::*;
use pdfs_core::syncignore::is_scratch_name;
use super::activity_text::{Described, describe};
pub(crate) struct ActivityState {
// Activity page: a "Needs attention" section for live conflicts, then the
@@ -26,8 +29,26 @@ pub(crate) struct ActivityState {
pub(crate) conflicts: RefCell<Option<Vec<ConflictInfo>>>,
pub(crate) conflicts_inflight: Cell<bool>,
pub(crate) conflicts_at: Cell<Option<Instant>>,
/// Whether entries about editors' scratch files (`.goutputstream-*`,
/// `~$report.docx`, …) show. Off by default: one save can log several.
pub(crate) show_scratch: Cell<bool>,
/// How many entries to ask the daemon for; "Show Older" raises it.
pub(crate) limit: Cell<usize>,
}
/// How many entries the feed asks for at first, and how many more each
/// "Show Older" adds.
pub(crate) const ACTIVITY_PAGE: usize = 200;
/// The most the daemon keeps.
const ACTIVITY_KEPT: usize = 2000;
/// Entries of one kind this close together read as one burst.
const BURST_GAP: i64 = 5 * 60;
/// The fewest entries that make a burst.
const BURST_MIN: usize = 3;
/// Widgets the Activity page's load/repaint touch.
pub(crate) struct ActivityWidgets {
pub(crate) content: gtk4::Stack,
@@ -36,8 +57,9 @@ pub(crate) struct ActivityWidgets {
pub(crate) list: gtk4::ListView,
pub(crate) feed: gtk4::Stack,
pub(crate) filters: Vec<(ActivityFilter, gtk4::ToggleButton)>,
/// Holds the ⋮ menu, which needs the [`Ui`] and so is built when wiring.
pub(crate) options: gtk4::Box,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// Which slice of the feed the filter bar shows.
@@ -86,12 +108,11 @@ impl ActivityFilter {
}
}
/// The Activity page: a newest-first feed of the mutations and transfers the
/// daemon performed (uploads, deletes, shares, …), grouped by day, with the
/// conflicts that still need a decision on top.
/// The activity feed, shown as the Sync page's History view: a newest-first
/// feed of the mutations and transfers the daemon performed (uploads,
/// deletes, shares, …), grouped by day, with the conflicts that still need a
/// decision on top.
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
let refresh = refresh_button();
// Filter chips: a linked row of radio toggles. They sit above the list,
// not in it, so they stay in reach however far the feed is scrolled.
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
@@ -108,14 +129,19 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
bar.append(&button);
filters.push((filter, button));
}
bar.set_margin_top(12);
bar.set_margin_bottom(6);
bar.set_margin_start(12);
bar.set_margin_end(12);
bar.set_hexpand(true);
let options = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
top.append(&bar);
top.append(&options);
top.set_margin_top(12);
top.set_margin_bottom(6);
top.set_margin_start(12);
top.set_margin_end(12);
let bar_clamp = adw::Clamp::builder()
.maximum_size(900)
.tightening_threshold(600)
.child(&bar)
.child(&top)
.build();
// Rows are sectioned by the key each item carries: the conflicts first,
@@ -184,11 +210,8 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
content.add_named(&column, Some("list"));
content.add_named(&status, Some("status"));
let (frame, header, _) = page_frame(&gettext("Activity"), &content);
header.pack_end(&refresh);
(
frame.upcast(),
content.clone().upcast(),
ActivityWidgets {
content,
status,
@@ -196,8 +219,8 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
list,
feed,
filters,
options,
retry,
refresh,
},
)
}
@@ -242,7 +265,9 @@ fn feed_header_factory() -> gtk4::SignalListItemFactory {
&[],
)),
),
FeedItem::Logged { day_label, .. } => (day_label.clone(), None),
FeedItem::Logged { day_label, .. }
| FeedItem::Burst { day_label, .. }
| FeedItem::More { day_label, .. } => (day_label.clone(), None),
};
title.set_label(&heading);
description.set_visible(line.is_some());
@@ -267,6 +292,17 @@ pub(crate) fn wire_activity(ui: &Rc<Ui>, widgets: &ActivityWidgets) {
}
});
}
let mut menu = ActionMenu::new();
menu.toggle(&gettext("Show Temporary Files"), false, {
let ui = ui.clone();
move |on| {
ui.activity.show_scratch.set(on);
paint_activity(&ui);
}
});
let options = menu.button();
options.set_tooltip_text(Some(&gettext("View Options")));
widgets.options.append(&options);
let factory = gtk4::SignalListItemFactory::new();
factory.connect_bind({
@@ -279,18 +315,42 @@ pub(crate) fn wire_activity(ui: &Rc<Ui>, widgets: &ActivityWidgets) {
let row = match &*object.borrow::<FeedItem>() {
FeedItem::Attention { conflict, .. } => {
item.set_activatable(true);
conflict_row(&ui, conflict)
conflict_row(&ui, conflict).upcast::<gtk4::Widget>()
}
FeedItem::Logged {
entry,
count,
first,
when,
pending,
resolved,
..
} => {
item.set_activatable(false);
feed_row(&ui, entry, *count, *first, pending.as_ref(), *resolved)
feed_row(
&ui,
entry,
*count,
*first,
when,
pending.as_ref(),
*resolved,
)
.upcast()
}
FeedItem::Burst {
kind,
entries,
when,
..
} => {
item.set_activatable(false);
burst_row(*kind, entries, when).upcast()
}
FeedItem::More { .. } => {
item.set_activatable(false);
item.set_child(Some(&more_row(&ui)));
return;
}
};
row.add_css_class("card");
@@ -340,13 +400,20 @@ pub(crate) fn activity_status(
/// other than a good answer leaves the rows alone until the next tick.
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
refresh_activity_conflicts(ui, false);
fetch_activity(ui);
}
/// Ask for the feed and repaint with it if the feed is still on screen.
fn fetch_activity(ui: &Rc<Ui>) {
if ui.activity.inflight.get() {
return;
}
ui.activity.inflight.set(true);
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ListActivity { limit: 200 },
Request::ListActivity {
limit: ui.activity.limit.get(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
@@ -355,13 +422,20 @@ pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
// The page may have been navigated away from while the request was in
// flight.
if let Ok(Ok(Response::Activity { items })) = result
&& ui.stack.visible_child_name().as_deref() == Some("activity")
&& activity_visible(&ui)
{
repaint_activity(&ui, &items);
}
});
}
/// Whether the activity feed is on screen: the Sync page, on its History
/// view.
pub(crate) fn activity_visible(ui: &Ui) -> bool {
ui.stack.visible_child_name().as_deref() == Some("locations")
&& ui.locations.views.visible_child_name().as_deref() == Some("history")
}
pub(crate) fn load_activity(ui: &Rc<Ui>) {
refresh_activity_conflicts(ui, true);
if ui.activity.inflight.get() {
@@ -381,7 +455,9 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ListActivity { limit: 200 },
Request::ListActivity {
limit: ui.activity.limit.get(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
@@ -412,7 +488,11 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
/// The daemon didn't answer the Activity page.
pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
service_unreachable(ui, "activity", activity_status, load_activity);
service_unreachable(ui, "locations", activity_status, |ui| {
if activity_visible(ui) {
load_activity(ui);
}
});
}
/// Poll the conflicts still waiting for a decision while the Activity page is
@@ -445,7 +525,7 @@ pub(crate) fn refresh_activity_conflicts(ui: &Rc<Ui>, force: bool) {
_ => None,
};
*state.conflicts.borrow_mut() = items;
if ui.stack.visible_child_name().as_deref() == Some("activity") {
if activity_visible(&ui) {
paint_activity(&ui);
}
});
@@ -475,12 +555,28 @@ pub(crate) enum FeedItem {
/// section, and that day's heading.
day: i64,
day_label: String,
/// When it happened, as the row shows it.
when: String,
/// The open conflict a logged conflict names, which the row offers
/// to resolve.
pending: Option<ConflictInfo>,
/// A logged conflict that is no longer open.
resolved: bool,
},
/// The same action on several items within minutes, such as a folder's
/// files uploading, shown as one row that expands to list them.
Burst {
kind: ActivityKind,
/// Newest first.
entries: Vec<ActivityEntry>,
day: i64,
day_label: String,
/// When the newest of them happened, as the row shows it.
when: String,
},
/// The "Show Older" row at the end, while the daemon keeps older
/// entries than the feed asked for. In the last day's section.
More { day: i64, day_label: String },
}
impl FeedItem {
@@ -489,7 +585,9 @@ impl FeedItem {
fn section(&self) -> i64 {
match self {
FeedItem::Attention { .. } => i64::MAX,
FeedItem::Logged { day, .. } => *day,
FeedItem::Logged { day, .. }
| FeedItem::Burst { day, .. }
| FeedItem::More { day, .. } => *day,
}
}
}
@@ -559,14 +657,23 @@ pub(crate) fn day_label(at: &glib::DateTime, now: &glib::DateTime) -> String {
.unwrap_or_default()
}
/// What the feed shows besides the filter: whether scratch files count, and
/// whether a "Show Older" row ends it.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct FeedOptions {
pub(crate) show_scratch: bool,
pub(crate) more: bool,
}
/// The rows of the page: the open conflicts, then the feed entries the filter
/// lets through, each with its day. `conflicts` is `None` while the open
/// conflicts are not known yet.
/// lets through, each with its day, runs of one action folded into bursts.
/// `conflicts` is `None` while the open conflicts are not known yet.
pub(crate) fn feed_items(
items: &[ActivityEntry],
filter: ActivityFilter,
conflicts: Option<&[ConflictInfo]>,
now: Option<&glib::DateTime>,
options: FeedOptions,
) -> Vec<FeedItem> {
let open = conflicts.unwrap_or_default();
let mut rows: Vec<FeedItem> = open
@@ -576,7 +683,13 @@ pub(crate) fn feed_items(
open: open.len(),
})
.collect();
for row in collapse_feed(items) {
let kept: Vec<ActivityEntry> = items
.iter()
.filter(|entry| options.show_scratch || !is_scratch(entry))
.cloned()
.collect();
let mut logged = Vec::new();
for row in collapse_feed(&kept) {
if !filter.matches(row.entry) {
continue;
}
@@ -599,19 +712,148 @@ pub(crate) fn feed_items(
.flatten()
.cloned();
let resolved = a.kind == ActivityKind::Conflict && pending.is_none() && conflicts.is_some();
rows.push(FeedItem::Logged {
logged.push(FeedItem::Logged {
entry: a.clone(),
count: row.count,
first: row.first,
day,
day_label,
when: now.map(|now| feed_time(a.time, now)).unwrap_or_default(),
pending,
resolved,
});
}
let last = logged.last().map(|row| match row {
FeedItem::Logged { day, day_label, .. } => (*day, day_label.clone()),
_ => (0, String::new()),
});
rows.extend(fold_bursts(logged));
if options.more
&& let Some((day, day_label)) = last
{
rows.push(FeedItem::More { day, day_label });
}
rows
}
/// Whether an entry is about an editor's scratch file: the temporary a save
/// writes before renaming it over the real file, a lock file, a partial
/// download. A rename away from one is the save itself.
fn is_scratch(entry: &ActivityEntry) -> bool {
use ActivityKind::*;
if !matches!(
entry.kind,
Upload | Download | Rename | Move | Trash | Restore | DeleteForever | CreateFolder
) {
return false;
}
is_scratch_name(file_name(&entry.target))
|| (entry.kind == Rename
&& entry
.detail
.strip_prefix("was ")
.is_some_and(|old| is_scratch_name(file_name(old))))
}
/// Fold runs of the same successful action on single items, close together
/// on one day, into [`FeedItem::Burst`]s. `rows` are logged rows, newest
/// first.
fn fold_bursts(rows: Vec<FeedItem>) -> Vec<FeedItem> {
let burstable = |row: &FeedItem| match row {
FeedItem::Logged { entry, count, .. } => {
*count == 1
&& entry.ok
&& matches!(
entry.kind,
ActivityKind::Upload
| ActivityKind::Download
| ActivityKind::Trash
| ActivityKind::Restore
| ActivityKind::DeleteForever
| ActivityKind::Move
| ActivityKind::Rename
| ActivityKind::CreateFolder
)
}
_ => false,
};
let joins = |run: &[FeedItem], row: &FeedItem| {
let (
Some(FeedItem::Logged {
entry: prev,
day: prev_day,
..
}),
FeedItem::Logged { entry, day, .. },
) = (run.last(), row)
else {
return false;
};
prev.kind == entry.kind && prev_day == day && prev.time - entry.time <= BURST_GAP
};
let mut out = Vec::new();
let mut run: Vec<FeedItem> = Vec::new();
let flush = |run: &mut Vec<FeedItem>, out: &mut Vec<FeedItem>| {
if run.len() < BURST_MIN {
out.append(run);
return;
}
let mut entries = Vec::with_capacity(run.len());
let mut head = None;
for row in run.drain(..) {
if let FeedItem::Logged {
entry,
day,
day_label,
when,
..
} = row
{
head.get_or_insert((entry.kind, day, day_label, when));
entries.push(entry);
}
}
if let Some((kind, day, day_label, when)) = head {
out.push(FeedItem::Burst {
kind,
entries,
day,
day_label,
when,
});
}
};
for row in rows {
if burstable(&row) && (run.is_empty() || joins(&run, &row)) {
run.push(row);
continue;
}
flush(&mut run, &mut out);
if burstable(&row) {
run.push(row);
} else {
out.push(row);
}
}
flush(&mut run, &mut out);
out
}
/// When an entry happened, as its row shows it under its day's heading: how
/// long ago within the last hour, else the time of day.
pub(crate) fn feed_time(time: i64, now: &glib::DateTime) -> String {
if (0..3600).contains(&(now.to_unix() - time)) {
return dates::relative(time);
}
// Translators: strftime format for the time of day in the activity feed, such as "14:05".
let format = gettext("%H:%M");
glib::DateTime::from_unix_local(time)
.ok()
.and_then(|at| at.format(&format).ok())
.map(|s| s.to_string())
.unwrap_or_default()
}
/// Bring the page up to date with the kept feed and conflict list, changing
/// only the rows that differ.
pub(crate) fn paint_activity(ui: &Rc<Ui>) {
@@ -631,38 +873,49 @@ pub(crate) fn paint_activity(ui: &Rc<Ui>) {
return;
}
let now = glib::DateTime::now_local().ok();
let limit = state.limit.get();
let rows = feed_items(
&items,
state.filter.get(),
conflicts.as_deref(),
now.as_ref(),
FeedOptions {
show_scratch: state.show_scratch.get(),
more: items.len() >= limit && limit < ACTIVITY_KEPT,
},
);
replace_items(&state.model, &rows);
let shows_entries = rows.iter().any(|row| !matches!(row, FeedItem::More { .. }));
state
.feed
.set_visible_child_name(if rows.is_empty() { "empty" } else { "rows" });
.set_visible_child_name(if shows_entries { "rows" } else { "empty" });
state.content.set_visible_child_name("list");
}
/// A time label for the end of a row, with the whole moment in its tooltip.
fn time_label(when: &str, time: i64) -> gtk4::Label {
let label = gtk4::Label::new(Some(when));
label.add_css_class("dim-label");
label.add_css_class("numeric");
label.set_tooltip_text(Some(&dates::full(time)));
label
}
/// One logged row of the feed, with the fields of [`FeedItem::Logged`].
fn feed_row(
ui: &Rc<Ui>,
a: &ActivityEntry,
count: usize,
first: i64,
when: &str,
pending: Option<&ConflictInfo>,
resolved: bool,
) -> adw::ActionRow {
let title = activity_title(a.kind, &a.target);
let mut subtitle: Vec<String> = Vec::new();
if !a.detail.is_empty() {
subtitle.push(capitalize(&a.detail));
}
let Described { title, mut details } = describe(a);
if count > 1 {
let since = dates::relative(first);
// Translators: {time} is a date or time such as "Sep 21" or "5 min ago".
subtitle.push(ngettext_f(
details.push(ngettext_f(
"{n} time since {time}",
"{n} times since {time}",
count as u64,
@@ -670,11 +923,11 @@ fn feed_row(
));
}
if resolved {
subtitle.push(pgettext("state", "Resolved"));
details.push(pgettext("state", "Resolved"));
}
let widget = adw::ActionRow::builder()
.title(title)
.subtitle(subtitle.join(" · "))
.subtitle(details.join(" · "))
.use_markup(false)
.title_lines(1)
.subtitle_lines(2)
@@ -697,20 +950,7 @@ fn feed_row(
icon.add_css_class("error");
}
widget.add_prefix(&icon);
// Translators: strftime format for the time of day in the activity feed, such as "14:05".
let time_format = gettext("%H:%M");
let time = gtk4::Label::new(Some(
&glib::DateTime::from_unix_local(a.time)
.ok()
.and_then(|at| at.format(&time_format).ok())
.map(|s| s.to_string())
.unwrap_or_default(),
));
time.add_css_class("dim-label");
time.add_css_class("numeric");
time.set_tooltip_text(Some(&dates::full(a.time)));
widget.add_suffix(&time);
widget.add_suffix(&time_label(when, a.time));
if let Some(conflict) = pending {
let resolve = gtk4::Button::builder()
@@ -725,41 +965,83 @@ fn feed_row(
widget
}
/// The row title for an activity: what happened to `target`, as one sentence
/// per kind so translators can place the name where their language needs it.
pub(crate) fn activity_title(kind: ActivityKind, target: &str) -> String {
let args = [("name", target)];
match kind {
// Translators: {name} is a file or folder name.
ActivityKind::Upload => gettext_f("Uploaded {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::Download => gettext_f("Downloaded {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::Sync => gettext_f("Synced {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::Rename => gettext_f("Renamed {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::Move => gettext_f("Moved {name}", &args),
// Translators: {name} is a folder name.
ActivityKind::CreateFolder => gettext_f("Created folder {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::Trash => gettext_f("Trashed {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::Restore => gettext_f("Restored {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::DeleteForever => gettext_f("Deleted {name}", &args),
// The target of an empty is a count, not a name to lead with a verb.
// Translators: {count} is the number of items removed from the Trash.
ActivityKind::EmptyTrash => gettext_f("Emptied Trash ({count})", &[("count", target)]),
// Translators: {name} is a file or folder name.
ActivityKind::Share => gettext_f("Shared {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::PublicLink => gettext_f("Created a link to {name}", &args),
// Translators: {name} is a file or folder name.
ActivityKind::Unshare => gettext_f("Unshared {name}", &args),
// Translators: {name} is the name of the conflict copy.
ActivityKind::Conflict => gettext_f("Conflict {name}", &args),
/// A burst row: one line for the whole run, expanding to one row per item.
fn burst_row(kind: ActivityKind, entries: &[ActivityEntry], when: &str) -> adw::ExpanderRow {
let n = entries.len() as u64;
let title = match kind {
ActivityKind::Upload => ngettext_f("Uploaded {n} item", "Uploaded {n} items", n, &[]),
ActivityKind::Download => ngettext_f("Downloaded {n} item", "Downloaded {n} items", n, &[]),
ActivityKind::Trash => ngettext_f(
"Moved {n} item to the Trash",
"Moved {n} items to the Trash",
n,
&[],
),
ActivityKind::Restore => ngettext_f("Restored {n} item", "Restored {n} items", n, &[]),
ActivityKind::DeleteForever => ngettext_f(
"Deleted {n} item forever",
"Deleted {n} items forever",
n,
&[],
),
ActivityKind::Move => ngettext_f("Moved {n} item", "Moved {n} items", n, &[]),
ActivityKind::Rename => ngettext_f("Renamed {n} item", "Renamed {n} items", n, &[]),
ActivityKind::CreateFolder => {
ngettext_f("Created {n} folder", "Created {n} folders", n, &[])
}
_ => ngettext_f("{n} change", "{n} changes", n, &[]),
};
let names: Vec<&str> = entries.iter().map(|e| file_name(&e.target)).collect();
let row = adw::ExpanderRow::builder()
.title(title)
.subtitle(names.join(", "))
.use_markup(false)
.title_lines(1)
.subtitle_lines(1)
.build();
row.add_prefix(&gtk4::Image::from_icon_name(activity_icon(kind)));
if let Some(newest) = entries.first() {
row.add_suffix(&time_label(when, newest.time));
}
let now = glib::DateTime::now_local().ok();
for entry in entries {
let Described { details, .. } = describe(entry);
let child = adw::ActionRow::builder()
.title(file_name(&entry.target))
.subtitle(details.join(" · "))
.use_markup(false)
.title_lines(1)
.subtitle_lines(1)
.build();
let when = now
.as_ref()
.map(|now| feed_time(entry.time, now))
.unwrap_or_default();
child.add_suffix(&time_label(&when, entry.time));
row.add_row(&child);
}
row
}
/// The row at the end of the feed that asks the daemon for older entries.
fn more_row(ui: &Rc<Ui>) -> gtk4::Widget {
let button = gtk4::Button::builder()
.label(gettext("Show Older"))
.halign(gtk4::Align::Center)
.margin_top(6)
.margin_bottom(12)
.build();
button.add_css_class("pill");
let ui = ui.clone();
button.connect_clicked(move |button| {
button.set_sensitive(false);
let state = &ui.activity;
state
.limit
.set((state.limit.get() + ACTIVITY_PAGE).min(ACTIVITY_KEPT));
fetch_activity(&ui);
});
button.upcast()
}
/// A themed icon for an activity kind.
@@ -885,7 +1167,13 @@ mod tests {
entry(100, ActivityKind::Upload, "c.txt", "", true),
];
let open = [conflict("docs/a (sync-conflict).txt")];
let rows = feed_items(&items, ActivityFilter::All, Some(&open), None);
let rows = feed_items(
&items,
ActivityFilter::All,
Some(&open),
None,
FeedOptions::default(),
);
assert_eq!(rows.len(), 4);
assert!(matches!(&rows[0], FeedItem::Attention { open: 1, .. }));
assert!(rows[0].section() > rows[1].section());
@@ -895,7 +1183,7 @@ mod tests {
FeedItem::Logged {
pending, resolved, ..
} => (pending.is_some(), *resolved),
FeedItem::Attention { .. } => unreachable!(),
_ => unreachable!(),
})
.collect();
assert_eq!(states, vec![(true, false), (false, true), (false, false)]);
@@ -913,7 +1201,13 @@ mod tests {
),
entry(100, ActivityKind::Upload, "c.txt", "", true),
];
let rows = feed_items(&items, ActivityFilter::All, None, None);
let rows = feed_items(
&items,
ActivityFilter::All,
None,
None,
FeedOptions::default(),
);
assert!(matches!(
&rows[0],
FeedItem::Logged {
@@ -922,11 +1216,102 @@ mod tests {
}
));
let open = [conflict("docs/a (sync-conflict).txt")];
let rows = feed_items(&items, ActivityFilter::Sharing, Some(&open), None);
let rows = feed_items(
&items,
ActivityFilter::Sharing,
Some(&open),
None,
FeedOptions::default(),
);
assert_eq!(rows.len(), 1);
assert!(matches!(&rows[0], FeedItem::Attention { .. }));
}
#[test]
fn a_run_of_uploads_folds_into_one_burst() {
let items: Vec<ActivityEntry> = (0..4)
.map(|i| {
entry(
1000 - i * 60,
ActivityKind::Upload,
&format!("{i}.jpg"),
"",
true,
)
})
.chain([entry(100, ActivityKind::Upload, "late.jpg", "", true)])
.collect();
let rows = feed_items(
&items,
ActivityFilter::All,
Some(&[]),
None,
FeedOptions::default(),
);
assert_eq!(rows.len(), 2);
assert!(matches!(&rows[0], FeedItem::Burst { entries, .. } if entries.len() == 4));
assert!(matches!(&rows[1], FeedItem::Logged { .. }));
}
#[test]
fn two_in_a_row_stay_separate_and_failures_never_fold() {
let items = vec![
entry(300, ActivityKind::Upload, "a", "", true),
entry(290, ActivityKind::Upload, "b", "", true),
entry(280, ActivityKind::Upload, "c", "timeout", false),
entry(270, ActivityKind::Upload, "d", "", true),
];
let rows = feed_items(
&items,
ActivityFilter::All,
Some(&[]),
None,
FeedOptions::default(),
);
assert_eq!(rows.len(), 4);
}
#[test]
fn scratch_files_hide_unless_asked_for() {
let items = vec![
entry(300, ActivityKind::Upload, ".goutputstream-AB12", "", true),
entry(
290,
ActivityKind::Rename,
"notes.txt",
"was .goutputstream-AB12",
true,
),
entry(280, ActivityKind::Upload, "notes.txt", "", true),
];
let rows = feed_items(
&items,
ActivityFilter::All,
Some(&[]),
None,
FeedOptions::default(),
);
assert_eq!(rows.len(), 1);
let shown = FeedOptions {
show_scratch: true,
..FeedOptions::default()
};
let rows = feed_items(&items, ActivityFilter::All, Some(&[]), None, shown);
assert_eq!(rows.len(), 3);
}
#[test]
fn show_older_ends_the_last_section() {
let items = vec![entry(300, ActivityKind::Trash, "a", "", true)];
let more = FeedOptions {
more: true,
..FeedOptions::default()
};
let rows = feed_items(&items, ActivityFilter::All, Some(&[]), None, more);
assert!(matches!(rows.last(), Some(FeedItem::More { .. })));
assert_eq!(rows[0].section(), rows[1].section());
}
#[test]
fn days_read_today_yesterday_weekday_then_date() {
let tz = glib::TimeZone::utc();
@@ -0,0 +1,609 @@
//! Activity entries as the feed words them.
//!
//! The daemon logs each entry in plain English for its own log and the CLI:
//! a target ("report.pdf", "3 item(s)", "sync conflict in Docs") and a detail
//! ("to /Documents", "was notes.txt", "2 uploaded, 1 deleted"). This module
//! reads the shapes the daemon writes and says them again in the user's
//! language. A shape it does not know, which is mostly an error message,
//! shows as the daemon wrote it.
use crate::*;
/// What a feed row says about one entry.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Described {
pub(crate) title: String,
/// Subtitle parts, shown joined by " · ".
pub(crate) details: Vec<String>,
}
/// The title and subtitle parts for `entry`.
pub(crate) fn describe(entry: &ActivityEntry) -> Described {
let title = entry_title(entry);
let mut details = Vec::new();
if entry.kind == ActivityKind::Conflict
&& let Some(stamp) = conflict_stamp(&entry.target)
{
let when = dates::relative(stamp);
if !when.is_empty() {
// Translators: {time} is when a conflicting copy was made, such as
// "Sep 21" or "5 min ago".
details.push(gettext_f("Made {time}", &[("time", &when)]));
}
}
if let Some(detail) = detail_label(entry) {
details.insert(0, detail);
}
Described { title, details }
}
/// The row title: what happened to what, one sentence per case so a
/// translator can place the name where their language needs it.
fn entry_title(entry: &ActivityEntry) -> String {
use ActivityKind::*;
let target = entry.target.as_str();
let ok = entry.ok;
// Entries whose target is a count or a fixed phrase rather than a name.
match (entry.kind, target) {
(Upload, "Google Photos import") => {
return if ok {
gettext("Imported from Google Photos")
} else {
gettext("Couldn't import from Google Photos")
};
}
(Upload, "bulk upload") => return gettext("Couldn't upload files"),
(Upload, "add sync folder") => return gettext("Couldn't add a sync folder"),
(Download, "restore folders") => return gettext("Couldn't restore folders"),
(Unshare, "shared item") => return gettext("Left a shared item"),
_ => {}
}
if matches!(entry.kind, Restore | DeleteForever | EmptyTrash)
&& let Some(n) = leading_count(target, &["item(s)", "item", "items"])
{
return match entry.kind {
Restore => ngettext_f("Restored {n} item", "Restored {n} items", n, &[]),
DeleteForever => ngettext_f(
"Deleted {n} item forever",
"Deleted {n} items forever",
n,
&[],
),
_ => ngettext_f(
"Emptied the Trash ({n} item)",
"Emptied the Trash ({n} items)",
n,
&[],
),
};
}
if entry.kind == Download
&& let Some(rest) = target.strip_prefix("restored ")
&& let Some(n) = leading_count(rest, &["folder(s)"])
{
return ngettext_f("Restored {n} folder", "Restored {n} folders", n, &[]);
}
if entry.kind == Sync
&& let Some(title) = sync_title(target)
{
return title;
}
let Some(name) = target_name(target) else {
return match entry.kind {
Share => gettext("Shared an item"),
PublicLink => gettext("Created a link to an item"),
Unshare if entry.detail == "link removed" => gettext("Removed a link to an item"),
Unshare => gettext("Stopped sharing an item"),
_ => activity_title(entry.kind, ok, target),
};
};
if entry.kind == Unshare && entry.detail == "link removed" {
// Translators: {name} is a file or folder name.
return gettext_f("Removed the link to {name}", &[("name", &name)]);
}
if entry.kind == Conflict
&& let Some(base) = conflict_base(&name)
{
// Translators: {name} is the file the conflicting copy was made from.
return gettext_f("Conflicting copy of {name}", &[("name", &base)]);
}
activity_title(entry.kind, ok, &name)
}
/// The title of a sync pass or mode switch, whose target is a phrase.
fn sync_title(target: &str) -> Option<String> {
if let Some(name) = target.strip_prefix("sync conflict in ") {
// Translators: {name} is a synced folder's name.
return Some(gettext_f(
"Conflicting changes in {name}",
&[("name", name)],
));
}
if let Some(name) = target.strip_prefix("sync incomplete for ") {
// Translators: {name} is a synced folder's name.
return Some(gettext_f(
"Some items in {name} didn't sync",
&[("name", name)],
));
}
if let Some(name) = target.strip_prefix("sync failed for ") {
// Translators: {name} is a synced folder's name.
return Some(gettext_f("Couldn't sync {name}", &[("name", name)]));
}
if let Some(name) = target.strip_prefix("couldn't switch ") {
// Translators: {name} is a synced folder's name.
return Some(gettext_f(
"Couldn't change how {name} syncs",
&[("name", name)],
));
}
if let Some(path) = target.strip_suffix(" is now on-demand") {
let name = file_name(path);
// Translators: {name} is a folder's name.
return Some(gettext_f("{name} is now online only", &[("name", name)]));
}
if let Some(path) = target.strip_suffix(" is mirroring again; downloading") {
let name = file_name(path);
// Translators: {name} is a folder's name.
return Some(gettext_f("{name} is synced again", &[("name", name)]));
}
if target.starts_with("already ") {
return Some(gettext("Sync mode was already set"));
}
None
}
/// The row title for an activity on a named item.
pub(crate) fn activity_title(kind: ActivityKind, ok: bool, name: &str) -> String {
let args = [("name", name)];
match (kind, ok) {
// Translators: {name} is a file or folder name.
(ActivityKind::Upload, true) => gettext_f("Uploaded {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Upload, false) => gettext_f("Couldn't upload {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Download, true) => gettext_f("Downloaded {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Download, false) => gettext_f("Couldn't download {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Sync, true) => gettext_f("Synced {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Sync, false) => gettext_f("Couldn't sync {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Rename, true) => gettext_f("Renamed {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Rename, false) => gettext_f("Couldn't rename {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
// Translators: {name} is a folder name.
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
// Translators: {name} is a folder name.
(ActivityKind::CreateFolder, false) => gettext_f("Couldn't create folder {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Trash, true) => gettext_f("Moved {name} to the Trash", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Trash, false) => gettext_f("Couldn't move {name} to the Trash", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Restore, true) => gettext_f("Restored {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Restore, false) => gettext_f("Couldn't restore {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::DeleteForever, true) => gettext_f("Deleted {name} forever", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::DeleteForever, false) => gettext_f("Couldn't delete {name}", &args),
(ActivityKind::EmptyTrash, true) => gettext("Emptied the Trash"),
(ActivityKind::EmptyTrash, false) => gettext("Couldn't empty the Trash"),
// Translators: {name} is a file or folder name.
(ActivityKind::Share, true) => gettext_f("Shared {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Share, false) => gettext_f("Couldn't share {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::PublicLink, true) => gettext_f("Created a link to {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::PublicLink, false) => gettext_f("Couldn't create a link to {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Unshare, true) => gettext_f("Stopped sharing {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Unshare, false) => gettext_f("Couldn't stop sharing {name}", &args),
// Translators: {name} is the name of the conflicting copy.
(ActivityKind::Conflict, _) => gettext_f("Conflicting copy {name}", &args),
}
}
/// The subtitle's first part: the daemon's detail in the user's language, or
/// `None` when it only repeats the title.
fn detail_label(entry: &ActivityEntry) -> Option<String> {
let detail = entry.detail.trim();
let name = |s: &str| file_name(s).to_string();
match detail {
""
| "uploaded"
| "created"
| "trashed"
| "trashed from the mount"
| "stopped sharing"
| "left"
| "link created"
| "link removed"
| "access removed"
| "removed" => {
return None;
}
"removed on Drive" => return Some(gettext("Removed on Proton Drive")),
"removed locally" => return Some(gettext("Removed on this computer")),
"replaced by a rename from the mount" => {
return Some(gettext("Replaced by a renamed file"));
}
"destination folder no longer exists; the file was left in place" => {
return Some(gettext(
"The destination folder no longer exists, so the file stayed where it was",
));
}
_ => {}
}
if entry.kind == ActivityKind::Upload
&& entry.target == "Google Photos import"
&& entry.ok
&& let Some(summary) = import_summary(detail)
{
return Some(summary);
}
if entry.kind == ActivityKind::Sync
&& let Some(summary) = sync_summary(detail)
{
return Some(summary);
}
if let Some(dest) = detail.strip_prefix("to ") {
// Translators: {name} is the folder an item was moved into.
return Some(gettext_f("To {name}", &[("name", &folder_name(dest))]));
}
if let Some(old) = detail.strip_prefix("was ") {
// Translators: {name} is an item's previous name.
return Some(gettext_f("Previously {name}", &[("name", &name(old))]));
}
if detail.starts_with("restored version ") {
return Some(gettext("Restored an earlier version"));
}
if detail.starts_with("deleted version ") {
return Some(gettext("Deleted an earlier version"));
}
if let Some(rest) = detail.strip_prefix("saved version ")
&& let Some((_, path)) = rest.split_once(" to ")
{
// Translators: {path} is where an earlier version was saved.
return Some(gettext_f(
"Saved an earlier version to {path}",
&[("path", &tilde_path(path))],
));
}
if let Some(copy) = detail.strip_prefix("kept both; the copy is now ") {
// Translators: {name} is the new name of the conflicting copy.
return Some(gettext_f(
"Kept both; the copy is now {name}",
&[("name", copy)],
));
}
if let Some(base) = detail
.strip_prefix("kept ")
.and_then(|rest| rest.strip_suffix("; the copy is in Trash"))
{
// Translators: {name} is the file that was kept.
return Some(gettext_f(
"Kept {name}; the copy is in the Trash",
&[("name", base)],
));
}
if detail.starts_with("kept the conflict copy ") {
return Some(gettext("Kept the conflicting copy"));
}
if let Some(base) = detail.strip_prefix("kept ") {
// Translators: {name} is the file that was kept.
return Some(gettext_f("Kept {name}", &[("name", base)]));
}
if let Some(base) = detail.strip_prefix("auto-removed duplicate of ") {
// Translators: {name} is the file the removed copy was identical to.
return Some(gettext_f(
"Same as {name}, so it was removed",
&[("name", base)],
));
}
if let Some(alt) = detail.strip_prefix("destination already had that name; moved as ") {
// Translators: {name} is the name the item was given instead.
return Some(gettext_f(
"The name was taken, so it was moved as {name}",
&[("name", alt)],
));
}
if let Some(alt) = detail.strip_prefix("name was taken remotely; renamed to ") {
// Translators: {name} is the name the item was given instead.
return Some(gettext_f(
"The name was taken on Proton Drive, so it was renamed to {name}",
&[("name", alt)],
));
}
if let Some(alt) = detail.strip_prefix("name was taken remotely; created as ") {
// Translators: {name} is the name the item was given instead.
return Some(gettext_f(
"The name was taken on Proton Drive, so it was created as {name}",
&[("name", alt)],
));
}
if let Some(alt) =
detail.strip_prefix("its folder was trashed remotely; created in the root as ")
{
// Translators: {name} is the name the item was given.
return Some(gettext_f(
"Its folder was moved to the Trash on Proton Drive, so it was created at the top as {name}",
&[("name", alt)],
));
}
if let Some(base) = detail.strip_prefix("differs from ") {
// Translators: {name} is the file the conflicting copy was made from.
return Some(gettext_f("Differs from {name}", &[("name", base)]));
}
if let Some(base) = detail
.strip_prefix("no original ")
.and_then(|rest| rest.strip_suffix(" to reconcile against"))
{
// Translators: {name} is the file the conflicting copy was made from.
return Some(gettext_f("{name} no longer exists", &[("name", base)]));
}
if let Some(base) = detail
.strip_prefix("identical to ")
.and_then(|rest| rest.split_once(';').map(|(base, _)| base))
{
// Translators: {name} is the file the conflicting copy was made from.
return Some(gettext_f("Same as {name}", &[("name", base)]));
}
if let Some((n, role)) = detail
.split_once(" recipient(s) as ")
.and_then(|(n, role)| Some((n.parse::<u64>().ok()?, role)))
{
return Some(match role {
"viewer" => ngettext_f("{n} person can view", "{n} people can view", n, &[]),
"editor" => ngettext_f("{n} person can edit", "{n} people can edit", n, &[]),
_ => ngettext_f("{n} person invited", "{n} people invited", n, &[]),
});
}
if let Some(n) = leading_count(detail, &["file(s) kept as conflict copies"]) {
return Some(ngettext_f(
"{n} file kept as a conflicting copy",
"{n} files kept as conflicting copies",
n,
&[],
));
}
if let Some(n) = leading_count(detail, &["item(s) failed; will retry"]) {
return Some(ngettext_f(
"{n} item failed and will be tried again",
"{n} items failed and will be tried again",
n,
&[],
));
}
Some(capitalize(detail))
}
/// A sync pass summary such as "2 uploaded, 1 folder(s) created", as a
/// list of counts. `None` when a part is not one the daemon writes.
fn sync_summary(detail: &str) -> Option<String> {
let mut parts = Vec::new();
for part in detail.split(", ") {
let (n, label) = part.split_once(' ')?;
let n = n.parse::<u64>().ok()?;
parts.push(match label {
// Translators: part of a list summing up a sync pass.
"uploaded" => ngettext_f("{n} uploaded", "{n} uploaded", n, &[]),
// Translators: part of a list summing up a sync pass.
"downloaded" => ngettext_f("{n} downloaded", "{n} downloaded", n, &[]),
// Translators: part of a list summing up a sync pass.
"folder(s) created" => ngettext_f("{n} folder created", "{n} folders created", n, &[]),
// Translators: part of a list summing up a sync pass.
"deleted" => ngettext_f("{n} deleted", "{n} deleted", n, &[]),
// Translators: part of a list summing up a sync pass.
"conflicted" => ngettext_f("{n} in conflict", "{n} in conflict", n, &[]),
// Translators: part of a list summing up a sync pass; the files are
// still open in another app.
"deferred (open for write)" => ngettext_f("{n} still open", "{n} still open", n, &[]),
// Translators: part of a list summing up a sync pass.
"failed" => ngettext_f("{n} failed", "{n} failed", n, &[]),
_ => return None,
});
}
Some(parts.join(", "))
}
/// A Google Photos import summary such as "40 uploaded · 2 already there".
fn import_summary(detail: &str) -> Option<String> {
let mut parts = Vec::new();
for part in detail.split(" · ") {
if part == "cancelled" {
parts.push(gettext("Cancelled"));
continue;
}
let (n, label) = part.split_once(' ')?;
let n = n.parse::<u64>().ok()?;
parts.push(match label {
"uploaded" => ngettext_f("{n} uploaded", "{n} uploaded", n, &[]),
// Translators: photos an import skipped because they were already
// in Proton Drive.
"already there" => ngettext_f("{n} already there", "{n} already there", n, &[]),
"albums" => ngettext_f("{n} album", "{n} albums", n, &[]),
"failed" => ngettext_f("{n} failed", "{n} failed", n, &[]),
_ => return None,
});
}
Some(parts.join(" · "))
}
/// The number in front of `text` when the rest is one of `units`, as in
/// "3 item(s)".
fn leading_count(text: &str, units: &[&str]) -> Option<u64> {
let (n, unit) = text.split_once(' ')?;
units.contains(&unit).then_some(())?;
n.parse().ok()
}
/// The name to show for a target: the last part of a path, or `None` when
/// the daemon logged a node id because it had no path.
fn target_name(target: &str) -> Option<String> {
let looks_like_uid =
target.len() >= 40 && target.matches('~').count() == 1 && !target.contains(['/', ' ', '.']);
if target.is_empty() || looks_like_uid {
return None;
}
Some(file_name(target).to_string())
}
/// The folder a destination path names, with the root read as "My files".
fn folder_name(dest: &str) -> String {
let name = file_name(dest.trim_end_matches('/'));
if name.is_empty() {
gettext("My files")
} else {
name.to_string()
}
}
/// The file a `name (sync-conflict <stamp>).ext` copy was made from.
pub(crate) fn conflict_base(name: &str) -> Option<String> {
let (start, close) = conflict_marker(name)?;
Some(format!("{}{}", &name[..start], &name[close + 1..]))
}
/// When a conflicting copy was made, from the stamp in its name.
fn conflict_stamp(name: &str) -> Option<i64> {
let (start, close) = conflict_marker(name)?;
let inner = &name[start + MARKER.len()..close];
inner.split('-').next()?.parse().ok()
}
const MARKER: &str = " (sync-conflict ";
/// Where the ` (sync-conflict <stamp>[-<n>])` marker starts and where its
/// closing parenthesis is. The same shape the daemon reads back.
fn conflict_marker(name: &str) -> Option<(usize, usize)> {
let start = name.find(MARKER)?;
let after = start + MARKER.len();
let close = after + name[after..].find(')')?;
let inner = &name[after..close];
let mut parts = inner.splitn(2, '-');
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if !digits(parts.next()?) || parts.next().is_some_and(|s| !digits(s)) {
return None;
}
Some((start, close))
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(kind: ActivityKind, target: &str, detail: &str, ok: bool) -> ActivityEntry {
ActivityEntry {
time: 0,
kind,
target: target.into(),
detail: detail.into(),
ok,
}
}
#[test]
fn details_that_repeat_the_title_are_dropped() {
let d = describe(&entry(ActivityKind::Upload, "a.txt", "uploaded", true));
assert_eq!(d.title, "Uploaded a.txt");
assert!(d.details.is_empty());
}
#[test]
fn paths_and_counts_read_as_names_and_numbers() {
let d = describe(&entry(
ActivityKind::Move,
"/mnt/a.txt",
"to /mnt/Docs",
true,
));
assert_eq!(d.title, "Moved a.txt");
assert_eq!(d.details, vec!["To Docs"]);
let d = describe(&entry(ActivityKind::Restore, "3 item(s)", "", true));
assert_eq!(d.title, "Restored 3 items");
let d = describe(&entry(ActivityKind::EmptyTrash, "1 item", "", true));
assert_eq!(d.title, "Emptied the Trash (1 item)");
}
#[test]
fn failures_say_what_could_not_happen() {
let d = describe(&entry(
ActivityKind::Upload,
"a.txt",
"quota exceeded",
false,
));
assert_eq!(d.title, "Couldn't upload a.txt");
assert_eq!(d.details, vec!["Quota exceeded"]);
}
#[test]
fn sync_passes_are_summed_up() {
let d = describe(&entry(
ActivityKind::Sync,
"Docs",
"2 uploaded, 1 folder(s) created",
true,
));
assert_eq!(d.title, "Synced Docs");
assert_eq!(d.details, vec!["2 uploaded, 1 folder created"]);
let d = describe(&entry(
ActivityKind::Sync,
"sync incomplete for Docs",
"2 item(s) failed; will retry",
false,
));
assert_eq!(d.title, "Some items in Docs didn't sync");
assert_eq!(d.details, vec!["2 items failed and will be tried again"]);
}
#[test]
fn a_conflicting_copy_names_its_original() {
let d = describe(&entry(
ActivityKind::Conflict,
"notes (sync-conflict 1700000000).txt",
"differs from notes.txt",
false,
));
assert_eq!(d.title, "Conflicting copy of notes.txt");
assert_eq!(d.details[0], "Differs from notes.txt");
assert_eq!(d.details.len(), 2);
assert_eq!(conflict_base("a (sync-conflict 12-2)"), Some("a".into()));
assert_eq!(conflict_base("a (sync-conflict x).txt"), None);
}
#[test]
fn node_ids_are_not_shown_as_names() {
let uid = format!("{}~{}", "A".repeat(30), "B".repeat(30));
let d = describe(&entry(
ActivityKind::Share,
&uid,
"1 recipient(s) as viewer",
true,
));
assert_eq!(d.title, "Shared an item");
assert_eq!(d.details, vec!["1 person can view"]);
}
#[test]
fn unknown_details_show_as_written() {
let d = describe(&entry(
ActivityKind::Download,
"a.txt",
"network timeout",
false,
));
assert_eq!(d.details, vec!["Network timeout"]);
}
}
+491 -229
View File
@@ -1,86 +1,120 @@
use crate::*;
pub(crate) struct DevicesState {
// Devices page: two sections (synced folders + other devices), rebuilt
// wholesale on each load.
// Computers page: one list of the account's computers, this one first, and
// a read-only browser over another computer's backup.
/// "list", "browse", or "status" while the page loads or can't be read.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
/// Rows added last time, removed before the next paint (adw groups have no
/// clear-all).
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) sync_group: adw::PreferencesGroup,
/// The single "This computer" summary row. The per-folder rows moved to the
/// Locations page, which owns every local path.
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
/// "Rename" action in the "This computer" header. Insensitive until the
/// device list identifies this machine's own device.
pub(crate) rename_this: gtk4::Button,
/// This machine's own device `(uid, name)`, from the last device list. The
/// current device is filtered out of the "Other computers" rows, so this is
/// where its rename target lives. `None` until identified.
pub(crate) this_device: RefCell<Option<(String, String)>>,
/// The browse view's files.
pub(crate) files: FileList,
/// The browse view: "list", or "empty".
pub(crate) files_content: gtk4::Stack,
/// The breadcrumb trail from "Computers" down to the current folder.
pub(crate) crumb: gtk4::Box,
/// Grid or list for the browse view; hidden on the device list.
pub(crate) layout: gtk4::Button,
/// Where the browse view is, as `(uid, name)`: the computer first, then each
/// folder below it. Empty = the device list. A backup has no path in the
/// mount, so descending is uid-addressed and the stack is the breadcrumb.
pub(crate) nav: RefCell<Vec<(String, String)>>,
/// The [`DevicesState::nav`] the page on screen was painted for, or `None`
/// before the first paint.
pub(crate) listed: RefCell<Option<Vec<(String, String)>>>,
/// Runs the loads; the rows stay up while one runs.
pub(crate) loader: Rc<Loader>,
pub(crate) loaded_at: Cell<Option<Instant>>,
}
impl DevicesState {
pub(crate) fn new(widgets: &DevicesWidgets) -> Self {
DevicesState {
content: widgets.content.clone(),
status: widgets.status.clone(),
retry: widgets.retry.clone(),
group: widgets.group.clone(),
rows: RefCell::new(Vec::new()),
files: widgets.files.clone(),
files_content: widgets.files_content.clone(),
crumb: widgets.crumb.clone(),
layout: widgets.layout.clone(),
nav: RefCell::new(Vec::new()),
listed: RefCell::new(None),
loader: Loader::new(&widgets.content),
loaded_at: Cell::new(None),
}
}
}
/// Widgets the Devices page's load/repaint touch.
pub(crate) struct DevicesWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
/// "This computer" — this machine's device identity and a pointer to its
/// folders on the Locations page.
pub(crate) sync_group: adw::PreferencesGroup,
/// "Rename" in the "This computer" header — renames this machine's device.
pub(crate) rename_this: gtk4::Button,
/// "Other computers" — the account's *other* registered devices. This
/// machine's own device is deliberately not among them; see
/// [`repaint_devices`].
/// Every computer on the account, this one first.
pub(crate) group: adw::PreferencesGroup,
pub(crate) files: FileList,
pub(crate) files_content: gtk4::Stack,
/// Goes up one folder, and from a computer's top level back to the list.
pub(crate) back: gtk4::Button,
pub(crate) crumb: gtk4::Box,
pub(crate) layout: gtk4::Button,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Computers page: a "This computer" section naming the device this machine
/// backs up to (renamable from its header), plus an "Other computers" section
/// listing the account's other registered devices. The synced folders
/// themselves live on the Sync page.
/// The Computers page: every computer backing up to the account, with this one
/// first. This computer's row leads to its folders on the Sync page; another
/// computer's row opens its backup read-only, in the same list as My files.
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
let refresh = refresh_button();
// This page is about device *identity* — which computer this is, which other
// computers back up to the account, and how to adopt one. The folders
// themselves are local paths, so they live on the Sync page; keeping a
// second copy of that list here would be two places to change the same mode.
let sync_group = adw::PreferencesGroup::builder()
.title(gettext("This computer"))
.description(gettext("The computer this machine backs up as."))
.build();
// Rename control for *this* machine's device, in the section header. The
// current device is filtered out of "Other computers", so this is the only
// place it can be renamed; insensitive until the device list identifies it.
let rename_this = gtk4::Button::builder()
.icon_name("document-edit-symbolic")
.tooltip_text(gettext("Rename this computer"))
.valign(gtk4::Align::Center)
.sensitive(false)
.build();
rename_this.add_css_class("flat");
sync_group.set_header_suffix(Some(&rename_this));
let group = adw::PreferencesGroup::builder()
.title(gettext("Other computers"))
.description(gettext("Other computers backing up to this account."))
.build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&sync_group);
groups.append(&group);
let clamp = adw::Clamp::builder().child(&groups).build();
let group = adw::PreferencesGroup::new();
let clamp = adw::Clamp::builder().child(&group).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
// Browse: a path bar over the list, as on Shared with me.
let files = FileList::new();
files.show_list(true);
let layout = layout_button(&files);
layout.set_visible(false);
let back = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text(gettext("Back"))
.build();
back.add_css_class("flat");
let crumb = gtk4::Box::new(gtk4::Orientation::Horizontal, 2);
crumb.set_valign(gtk4::Align::Center);
let crumb_scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::External)
.vscrollbar_policy(gtk4::PolicyType::Never)
.hexpand(true)
.child(&crumb)
.build();
let path_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
path_bar.append(&back);
path_bar.append(&crumb_scroll);
let files_empty = adw::StatusPage::builder()
.icon_name("folder-open-symbolic")
.title(gettext("This folder is empty"))
.description(gettext("Nothing in this folder has been backed up."))
.vexpand(true)
.build();
files_empty.add_css_class("compact");
let files_content = gtk4::Stack::new();
files_content.add_named(&files.views, Some("list"));
files_content.add_named(&files_empty, Some("empty"));
let browse = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
browse.append(&path_bar);
browse.append(&files_content);
let retry = gtk4::Button::builder()
.label(gettext("Retry"))
.halign(gtk4::Align::Center)
@@ -99,6 +133,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&browse, Some("browse"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
@@ -109,31 +144,67 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
inner.append(&content);
let (frame, header, _) = page_frame(&gettext("Computers"), &inner);
header.pack_end(&refresh);
header.pack_end(&layout);
(
frame.upcast(),
DevicesWidgets {
content,
status,
sync_group,
rename_this,
group,
files,
files_content,
back,
crumb,
layout,
retry,
refresh,
},
)
}
/// 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 |_| 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() {
prompt_rename_device(&ui_ren, &uid, &name);
}
/// Install the browse view's file list and the page's buttons.
pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
let files = &ui.devices.files;
files.wire(
ui,
FileListBehavior {
activate: open_device_entry,
entry_menu: device_entry_menu,
bulk_menu: device_bulk_menu,
background_menu: device_background_menu,
badges: false,
drag_and_drop: false,
},
);
files
.column_view
.append_column(&text_column(&pgettext("column", "Modified"), |e| {
if e.modified > 0 {
dates::short_date(e.modified)
} else {
"—".to_string()
}
}));
files
.column_view
.append_column(&text_column(&pgettext("column", "Size"), |e| {
if e.is_dir {
"—".to_string()
} else {
human_bytes(e.size)
}
}));
let ui_back = ui.clone();
widgets.back.connect_clicked(move |_| {
ui_back.devices.nav.borrow_mut().pop();
load_devices(&ui_back);
});
let ui_retry = ui.clone();
widgets
.retry
.connect_clicked(move |_| restart_service_then(&ui_retry, load_devices));
}
/// Show a status page in place of the Devices list.
@@ -145,20 +216,17 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
ui.devices.content.set_visible_child_name("status");
}
/// Fetch this machine's synced folders and the account's other devices, then
/// repaint both sections. The two requests are chained so a single unreachable
/// daemon collapses the whole page to a status view.
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
/// of another computer's backup.
pub(crate) fn load_devices(ui: &Rc<Ui>) {
let ui_p = ui.clone();
let ticket = ui.devices.loader.refresh(move || {
devices_status(
&ui_p,
"computer-symbolic",
&gettext("Loading…"),
&gettext("Reading your computers."),
false,
);
});
cancel_file_thumbnails(ui);
let nav = ui.devices.nav.borrow().clone();
ui.devices.layout.set_visible(!nav.is_empty());
if !nav.is_empty() {
load_device_folder(ui, &nav);
return;
}
let ticket = begin_devices_load(ui, "computer-symbolic", gettext("Reading your computers."));
ui.busy_begin();
// The two lists are independent and the daemon serves requests concurrently,
// so fire both up front and collect them, rather than paying two round trips
@@ -184,176 +252,350 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
return;
}
};
// Daemon reachable: show the list and paint the "This computer" section.
ui.devices.content.set_visible_child_name("list");
repaint_this_computer(&ui, &folders);
let devices = match devices_reply {
Ok(Ok(Response::Devices { items })) => items,
_ => Vec::new(),
};
repaint_devices(&ui, &devices);
ui.devices.content.set_visible_child_name("list");
repaint_devices(&ui, &devices, &folders);
repaint_device_crumb(&ui, &[]);
*ui.devices.listed.borrow_mut() = Some(Vec::new());
ui.devices.loaded_at.set(Some(Instant::now()));
});
}
/// List one folder of another computer's backup. The computer's own top level
/// is its restorable folders; below that, any folder lists by uid.
fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
let ticket = begin_devices_load(ui, "folder-symbolic", gettext("Reading this folder."));
ui.busy_begin();
let (uid, _) = nav.last().cloned().unwrap_or_default();
let request = if nav.len() == 1 {
Request::ListDeviceRestorableFolders { device: uid }
} else {
Request::ListSharedFolder { uid }
};
let rx = spawn_request(ui.dirs.control_socket(), request);
let ui = ui.clone();
let nav = nav.to_vec();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
if !ticket.is_current() {
return;
}
let entries = match result {
Ok(Ok(Response::Entries { entries })) => entries,
Ok(Ok(Response::RestorableFolders { items })) => {
items.into_iter().map(restorable_entry).collect()
}
Ok(Ok(Response::Error { message, kind })) => {
// The folder or the computer is gone: fall back a level rather
// than stranding the page on a dead uid.
ui.devices.nav.borrow_mut().pop();
toast_failure(&ui, &gettext("Couldn't open folder"), &message, kind);
load_devices(&ui);
return;
}
_ => {
ui.devices.loaded_at.set(None);
devices_unreachable(&ui);
return;
}
};
ui.devices.content.set_visible_child_name("browse");
repaint_device_crumb(&ui, &nav);
*ui.devices.listed.borrow_mut() = Some(nav);
if entries.is_empty() {
ui.devices.files.model.remove_all();
ui.devices.files_content.set_visible_child_name("empty");
} else {
ui.devices.files_content.set_visible_child_name("list");
replace_items(&ui.devices.files.model, &entries);
}
ui.devices.loaded_at.set(Some(Instant::now()));
});
}
/// A computer's top-level folder as a browsable entry.
fn restorable_entry(folder: RestorableFolder) -> DirEntry {
DirEntry {
name: folder.name,
is_dir: true,
uid: folder.remote_uid,
..DirEntry::default()
}
}
/// Start a load of the view [`DevicesState::nav`] points at. A reload of the
/// view on screen keeps it up and usable; moving elsewhere greys it out until
/// the new view arrives.
fn begin_devices_load(ui: &Rc<Ui>, icon: &'static str, description: String) -> LoadTicket {
let ui_p = ui.clone();
let placeholder = move || {
devices_status(&ui_p, icon, &gettext("Loading…"), &description, false);
};
if *ui.devices.listed.borrow() == Some(ui.devices.nav.borrow().clone()) {
ui.devices.loader.refresh(placeholder)
} else {
ui.devices.listed.borrow_mut().take();
ui.devices.loader.replace(placeholder)
}
}
/// The daemon didn't answer the Devices page.
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
service_unreachable(ui, "devices", devices_status, load_devices);
}
/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal
/// state (this machine may be the only device), so it shows a placeholder row
/// rather than collapsing the page.
/// Rebuild the device list: this computer first, then the others.
///
/// This machine's own device is filtered out. Deleting a device deletes its root
/// folder — every file backed up from it — and for *this* machine that would also
/// pull the ground out from under the synced folders listed directly above, so it
/// is not offered as a peer of "remove some other laptop". Removing this
/// computer's backup means removing its folders, each of which asks about its
/// cloud copy on its own terms.
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
/// This computer's row offers no Remove. Deleting a device deletes its root
/// folder — every file backed up from it — and for this machine that would also
/// pull the ground out from under its synced folders, so it is not offered as a
/// peer of "remove some other laptop". Removing this computer's backup means
/// removing its folders, each of which asks about its cloud copy on its own
/// terms.
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo], folders: &[SyncFolderInfo]) {
for row in ui.devices.rows.borrow_mut().drain(..) {
ui.devices.group.remove(&row);
}
// Identify this machine's own device so the "This computer" header's Rename
// action has a target. It never appears in the rows below, so this is the
// only place its name/uid is captured.
match devices.iter().find(|d| d.this_device) {
Some(me) => {
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
ui.devices
.sync_group
// Translators: {name} is the name of this computer's device.
.set_description(Some(&gettext_f(
"Backing up as “{name}”.",
&[("name", &me.name)],
)));
ui.devices.rename_this.set_sensitive(true);
ui.devices
.rename_this
// Translators: {name} is the name of this computer's device.
.set_tooltip_text(Some(&gettext_f(
"Rename this computer ({name})",
&[("name", &me.name)],
)));
}
None => {
*ui.devices.this_device.borrow_mut() = None;
ui.devices.rename_this.set_sensitive(false);
}
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
let me = devices.iter().find(|d| d.this_device);
rows.push(this_computer_row(ui, me, folders).upcast());
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
for dev in &others {
rows.push(other_computer_row(ui, dev).upcast());
}
if others.is_empty() {
let row = adw::ActionRow::builder()
.title(gettext("No other computers"))
.subtitle(gettext("Desktop apps syncing to this account appear here."))
.subtitle(gettext(
"Install Proton Drive on another computer to see it here.",
))
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
ui.devices.group.add(&row);
*ui.devices.rows.borrow_mut() = vec![row.upcast()];
return;
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for dev in others {
let row = adw::ActionRow::builder()
.title(&dev.name)
.subtitle(device_subtitle(dev))
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
// Adoption is how a reinstalled or renamed machine re-attaches to the
// device it used to be, instead of registering a duplicate. It only
// makes sense on *another* computer's row, which is the only place this
// loop paints.
let (ui_ren, uid_ren, name_ren) = (ui.clone(), dev.uid.clone(), dev.name.clone());
let (ui_ad, uid_ad, name_ad) = (ui.clone(), dev.uid.clone(), dev.name.clone());
let (ui_rm, uid_rm, name_rm) = (ui.clone(), dev.uid.clone(), dev.name.clone());
let (ui_rs, uid_rs, name_rs) = (ui.clone(), dev.uid.clone(), dev.name.clone());
row.add_suffix(&more_menu_button(vec![
(
&gettext("Restore to This Computer…"),
Box::new(move || {
prompt_restore_folders(&ui_rs, Some((uid_rs.clone(), name_rs.clone())))
}),
),
(
&gettext("Rename…"),
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)),
),
(
&gettext("Use This Computer's Identity…"),
Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)),
),
(
&gettext("Remove Computer…"),
Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)),
),
]));
ui.devices.group.add(&row);
let get = gtk4::LinkButton::with_label(PROTON_DRIVE_DOWNLOAD, &gettext("Get Proton Drive"));
get.set_valign(gtk4::Align::Center);
row.add_suffix(&get);
rows.push(row.upcast());
}
for row in &rows {
ui.devices.group.add(row);
}
*ui.devices.rows.borrow_mut() = rows;
}
/// A device row's subtitle: its platform, and when it last synced. A device that
/// has never synced says so — an unexplained missing date reads as a bug, and
/// "never" is the fact that tells the user this computer isn't backing anything up.
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
match dev.last_sync {
// Translators: {platform} is the device's platform (such as "Linux"), {time} a date and time.
Some(secs) if secs > 0 => gettext_f(
"{platform} · last synced {time}",
&[
("platform", &dev.device_type),
("time", &dates::relative(secs)),
],
),
// Translators: {platform} is the device's platform, such as "Linux".
_ => gettext_f(
"{platform} · never synced",
&[("platform", &dev.device_type)],
),
/// Where the Proton Drive apps for other systems are.
const PROTON_DRIVE_DOWNLOAD: &str = "https://proton.me/drive/download";
/// This computer's row: its device name with a "This computer" pill, and how
/// many folders it backs up. It leads to those folders on the Sync page, which
/// owns every local path. `me` is `None` until the daemon has registered this
/// machine as a device.
fn this_computer_row(
ui: &Rc<Ui>,
me: Option<&DeviceInfo>,
folders: &[SyncFolderInfo],
) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
.subtitle(this_computer_subtitle(folders))
.activatable(true)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
if me.is_some() {
let pill = gtk4::Label::new(Some(&gettext("This computer")));
pill.add_css_class("role-pill");
pill.add_css_class("caption");
pill.set_valign(gtk4::Align::Center);
row.add_suffix(&pill);
}
let mut menu = ActionMenu::new();
if let Some(me) = me {
let (ui_c, uid, name) = (ui.clone(), me.uid.clone(), me.name.clone());
menu.item(&gettext("Rename…"), move || {
prompt_rename_device(&ui_c, &uid, &name)
});
}
let ui_c = ui.clone();
menu.item(&gettext("Restore Folders…"), move || {
prompt_restore_folders(&ui_c, None)
});
row.add_suffix(&menu.button());
row.add_suffix(&gtk4::Image::from_icon_name("go-next-symbolic"));
let ui_c = ui.clone();
row.connect_activated(move |_| {
ui_c.locations.views.set_visible_child_name("folders");
ui_c.stack.set_visible_child_name("locations");
});
row
}
/// Another computer's row. Opening it browses that computer's backup.
fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(&dev.name)
.subtitle(device_subtitle(dev))
.activatable(true)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let (uid, name) = (dev.uid.clone(), dev.name.clone());
let mut menu = ActionMenu::new();
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Browse Files"), move || {
browse_device(&ui_c, &uid_c, &name_c)
});
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Restore to This Computer…"), move || {
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
});
menu.section();
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Rename…"), move || {
prompt_rename_device(&ui_c, &uid_c, &name_c)
});
// Adoption is how a reinstalled or renamed machine re-attaches to the device
// it used to be, instead of registering a duplicate. It only makes sense on
// another computer's row.
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Continue This Backup Here…"), move || {
prompt_adopt_device(&ui_c, &uid_c, &name_c)
});
menu.section();
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Remove…"), move || {
prompt_remove_device(&ui_c, &uid_c, &name_c)
});
row.add_suffix(&menu.button());
row.add_suffix(&gtk4::Image::from_icon_name("go-next-symbolic"));
let ui_c = ui.clone();
row.connect_activated(move |_| browse_device(&ui_c, &uid, &name));
row
}
/// Open another computer's backup at its top level.
fn browse_device(ui: &Rc<Ui>, uid: &str, name: &str) {
*ui.devices.nav.borrow_mut() = vec![(uid.to_string(), name.to_string())];
load_devices(ui);
}
/// Rebuild the breadcrumb trail for `nav`: "Computers", then the computer and
/// each folder down to the current one, which is a plain heading.
fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
let crumb = &ui.devices.crumb;
while let Some(child) = crumb.first_child() {
crumb.remove(&child);
}
crumb.append(&device_crumb_node(
ui,
&gettext("Computers"),
0,
nav.is_empty(),
));
for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
}
}
/// Rebuild the "This computer" section: one row naming the device this machine
/// backs up to, and how many folders it carries.
///
/// The per-folder rows (mode switch, sync now, remove) live on the Locations
/// page. They are local paths, and Locations is the one page that owns those;
/// two lists of the same folders would be two places to flip the same switch.
pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
ui.devices.sync_group.remove(&row);
/// One breadcrumb segment: a heading for the current folder, or a flat button
/// that goes back up to `depth` levels below the device list.
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
let ui = ui.clone();
button.connect_clicked(move |_| {
ui.devices.nav.borrow_mut().truncate(depth);
load_devices(&ui);
});
button.upcast()
}
/// Folders open in place; files download and open with the default app.
fn open_device_entry(ui: &Rc<Ui>, entry: &DirEntry) {
if entry.is_dir {
ui.devices
.nav
.borrow_mut()
.push((entry.uid.clone(), entry.name.clone()));
load_devices(ui);
} else {
open_shared_file(ui, &entry.uid, &entry.name);
}
}
fn device_entry_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
let mut menu = ActionMenu::new();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&pgettext("verb", "Open"), move || {
open_device_entry(&ui_c, &entry_c)
});
menu
}
fn device_bulk_menu(_ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
let mut menu = ActionMenu::new();
menu.labelled_section(&ngettext_f(
"{n} selected",
"{n} selected",
entries.len() as u64,
&[],
));
menu
}
fn device_background_menu(ui: &Rc<Ui>) -> ActionMenu {
let mut menu = ActionMenu::new();
let ui_c = ui.clone();
menu.item(&gettext("Select All"), move || {
ui_c.devices.files.selection.select_all();
});
let ui_c = ui.clone();
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
menu
}
/// A device row's subtitle: its platform, and when it last backed up. A device
/// that never did says so — an unexplained missing date reads as a bug, and
/// "no backups yet" is the fact that tells the user this computer isn't backing
/// anything up.
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
let platform = platform_label(&dev.device_type);
match dev.last_sync {
// Translators: {platform} is the device's platform (such as "Linux"), {time} a relative time such as "5 minutes ago".
Some(secs) if secs > 0 => gettext_f(
"{platform} · last backup {time}",
&[("platform", platform), ("time", &dates::relative(secs))],
),
// Translators: {platform} is the device's platform, such as "Linux".
_ => gettext_f("{platform} · no backups yet", &[("platform", platform)]),
}
}
/// The platform as its maker spells it. The API's names are enum variants.
fn platform_label(device_type: &str) -> &str {
match device_type {
"MacOs" => "macOS",
other => other,
}
let row = adw::ActionRow::builder()
.title(gettext("This computer"))
.subtitle(this_computer_subtitle(folders))
.build();
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 synced folders"))
.valign(gtk4::Align::Center)
.build();
manage.add_css_class("flat");
let ui_go = ui.clone();
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
row.add_suffix(&manage);
let ui_rs = ui.clone();
row.add_suffix(&more_menu_button(vec![(
&gettext("Restore Folders…"),
Box::new(move || prompt_restore_folders(&ui_rs, None)),
)]));
ui.devices.sync_group.add(&row);
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
}
/// How many folders this machine backs up, and whether any of them needs
/// attention — the one fact worth surfacing away from the folder list itself.
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
if folders.is_empty() {
return gettext("No folders backed up yet — add one on the Sync page.");
return gettext("No folders backed up yet");
}
let count = ngettext_f(
"{n} folder backed up",
@@ -366,11 +608,7 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
.filter(|f| f.state == "error" || f.state == "conflict")
.count();
match attention {
// Translators: {folders} is "N folders backed up".
0 => gettext_f(
"{folders} · manage them on the Sync page",
&[("folders", &count)],
),
0 => count,
// Translators: {folders} is "N folders backed up"; {n} counts the folders with a problem.
n => ngettext_f(
"{folders} · {n} needs attention",
@@ -827,7 +1065,8 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
dialog.present(win.as_ref());
}
/// Confirm, then adopt another device as this machine's identity.
/// Confirm, then continue another computer's backup on this machine (adopt its
/// device).
///
/// Worth a confirmation rather than a plain click: adoption re-points this
/// machine's syncing at another computer's device folder, and the folders
@@ -837,20 +1076,22 @@ 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?"))
// Translators: {name} is the name of the other computer whose identity is adopted.
.heading(gettext("Continue This Backup Here?"))
// Translators: {name} is the name of the other computer whose backup this one takes over.
.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.",
"This computer takes over the backup of “{name}”, for example after a reinstall.\n\nNew synced folders go under “{name}” in Proton Drive, even if this computer's name changes. Folders already synced here are not moved. Use “Restore to This Computer” afterwards to bring back what “{name}” was syncing.",
&[("name", name)],
))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("adopt", &gettext("Use Identity"));
dialog.add_response("adopt", &gettext("Continue Here"));
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
// Translators: {name} is the name of the computer whose backup this one took over.
let done = gettext_f("Now backing up as “{name}”", &[("name", name)]);
dialog.connect_response(None, move |_, resp| {
if resp == "adopt" {
run_devices_mutation(
@@ -858,8 +1099,8 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
Request::AdoptDevice {
uid: Some(uid.clone()),
},
&gettext("Identity adopted"),
&gettext("Couldn't adopt that computer"),
&done,
&gettext("Couldn't continue that backup here"),
);
}
});
@@ -912,20 +1153,17 @@ mod tests {
}
#[test]
fn a_machine_with_no_folders_is_told_where_to_add_one() {
assert_eq!(
this_computer_subtitle(&[]),
"No folders backed up yet — add one on the Sync page."
);
fn a_machine_with_no_folders_says_so() {
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
}
#[test]
fn folders_needing_attention_are_counted_not_hidden() {
// The per-folder rows moved to Locations, so this row is the only place
// the Computers page can surface that something is wrong.
// The per-folder rows live on the Sync page, so this row is the only
// place the Computers page can surface that something is wrong.
assert_eq!(
this_computer_subtitle(&[folder("idle"), folder("idle")]),
"2 folders backed up · manage them on the Sync page"
"2 folders backed up"
);
assert_eq!(
this_computer_subtitle(&[folder("idle"), folder("error")]),
@@ -943,4 +1181,28 @@ mod tests {
assert!(restore_picker_body(None).contains("this computer"));
assert!(nothing_to_restore(Some("laptop")).contains("“laptop”"));
}
fn device(device_type: &str, last_sync: Option<i64>) -> DeviceInfo {
DeviceInfo {
uid: "dev".into(),
name: "laptop".into(),
device_type: device_type.into(),
last_sync,
this_device: false,
adopted: false,
}
}
#[test]
fn a_computer_that_never_backed_up_says_no_backups_yet() {
assert_eq!(
device_subtitle(&device("Linux", None)),
"Linux · no backups yet"
);
assert_eq!(
device_subtitle(&device("Linux", Some(0))),
"Linux · no backups yet"
);
assert!(device_subtitle(&device("MacOs", Some(1))).starts_with("macOS · last backup "));
}
}
+250 -71
View File
@@ -1,5 +1,7 @@
//! The Sync page (formerly Locations): what is moving right now, and every local
//! place Proton Drive occupies on this machine.
//! The Sync page (formerly Locations), in three views: Overview (what sync
//! is doing, what needs a decision, what is moving), Folders (every local
//! place Proton Drive occupies on this machine) and History (the activity
//! feed, built in `activity.rs`).
//!
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
@@ -28,12 +30,20 @@ pub(crate) struct LocationsState {
pub(crate) card: SyncCard,
pub(crate) queue: QueueState,
pub(crate) conflicts: ConflictsState,
/// Overview, Folders and History.
pub(crate) views: adw::ViewStack,
/// Points at the Overview while conflicts or failures wait there.
pub(crate) banner: adw::Banner,
/// Changes the daemon failed to upload, from the last status.
pub(crate) failing: Cell<u64>,
}
/// The status card heading the Sync page: what sync is doing, and Pause/Resume.
/// The status card heading the Sync page's Overview, and Pause/Resume in the
/// page header.
pub(crate) struct SyncCard {
pub(crate) icon: gtk4::Image,
pub(crate) row: adw::ActionRow,
pub(crate) title: gtk4::Label,
pub(crate) detail: gtk4::Label,
pub(crate) pause: adw::SplitButton,
/// Whether the last status said paused, so the button knows which way to go.
pub(crate) paused: Cell<bool>,
@@ -97,17 +107,23 @@ pub(crate) struct LocationsWidgets {
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
pub(crate) add_folder: gtk4::Button,
/// Live transfers, above the folder list; painted by the refresh loop.
/// Live transfers, on the Overview; painted by the refresh loop.
pub(crate) transfers_group: adw::PreferencesGroup,
pub(crate) card_icon: gtk4::Image,
pub(crate) card_row: adw::ActionRow,
pub(crate) card_title: gtk4::Label,
pub(crate) card_detail: gtk4::Label,
pub(crate) pause: adw::SplitButton,
pub(crate) queue_group: adw::PreferencesGroup,
pub(crate) retry_all: gtk4::Button,
pub(crate) conflicts_group: adw::PreferencesGroup,
pub(crate) views: adw::ViewStack,
pub(crate) banner: adw::Banner,
}
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
/// The Sync page: Overview (the status, conflicts, transfers and queue),
/// Folders (this computer's synced folders) and History (the activity feed,
/// `history`), switched from the header.
pub(crate) fn build_locations_page(history: &gtk4::Widget) -> (gtk4::Widget, LocationsWidgets) {
let add_folder = gtk4::Button::builder()
.child(
&adw::ButtonContent::builder()
@@ -118,10 +134,13 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
.tooltip_text(gettext(
"Back a local folder up to this computer's Proton Drive device",
))
.valign(gtk4::Align::Center)
.build();
add_folder.add_css_class("flat");
let refresh = refresh_button();
let transfers_group = build_transfers_group();
let (card, card_icon, card_row, pause) = build_sync_card();
let (card, card_icon, card_title, card_detail) = build_sync_card();
let pause = build_pause_button();
let (queue_group, retry_all) = build_queue_group();
let conflicts_group = adw::PreferencesGroup::builder()
.title(gettext("Conflicts"))
@@ -131,28 +150,39 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
.visible(false)
.build();
// Overview: what sync is doing, then what needs a decision, then what is
// moving.
let overview = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
overview.append(&card);
overview.append(&conflicts_group);
overview.append(&transfers_group);
overview.append(&queue_group);
// Folders: the explanation of the two modes waits behind a "?" rather
// than sitting over the list as a paragraph.
let help = gtk4::Label::builder()
.label(gettext("Synced folders keep a full copy on this computer. Online-only folders fetch each file when you open it."))
.wrap(true)
.max_width_chars(40)
.xalign(0.0)
.margin_top(6)
.margin_bottom(6)
.margin_start(6)
.margin_end(6)
.build();
let help_button = gtk4::MenuButton::builder()
.icon_name("info-outline-symbolic")
.tooltip_text(gettext("About Sync Modes"))
.valign(gtk4::Align::Center)
.popover(&gtk4::Popover::builder().child(&help).build())
.build();
help_button.add_css_class("flat");
let suffix = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
suffix.append(&help_button);
suffix.append(&add_folder);
let group = adw::PreferencesGroup::builder()
.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();
// 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(&group);
groups.append(&transfers_group);
groups.append(&queue_group);
let clamp = adw::Clamp::builder().child(&groups).build();
// 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)
.child(&clamp)
.header_suffix(&suffix)
.build();
let retry = gtk4::Button::builder()
@@ -178,17 +208,45 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
.build();
let content = gtk4::Stack::new();
content.add_named(&scroll, Some("list"));
content.add_named(&scrolled_column(&group), Some("list"));
content.add_named(&status_scroll, Some("status"));
let page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
page.set_margin_top(18);
page.set_margin_bottom(18);
page.set_margin_start(18);
page.set_margin_end(18);
page.append(&content);
let (frame, header, _) = page_frame(&pgettext("page", "Sync"), &page);
header.pack_start(&add_folder);
let views = adw::ViewStack::new();
views.set_vexpand(true);
views.add_titled_with_icon(
&scrolled_column(&overview),
Some("overview"),
&pgettext("sync view", "Overview"),
"pdfs-sync-symbolic",
);
views.add_titled_with_icon(
&content,
Some("folders"),
&pgettext("sync view", "Folders"),
"folder-symbolic",
);
views.add_titled_with_icon(
history,
Some("history"),
&pgettext("sync view", "History"),
"document-open-recent-symbolic",
);
// Conflicts and failures are on the Overview; away from it, a banner
// says they are there.
let banner = adw::Banner::builder()
.button_label(gettext("Review"))
.build();
let column = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
column.append(&banner);
column.append(&views);
let switcher = adw::ViewSwitcher::builder()
.stack(&views)
.policy(adw::ViewSwitcherPolicy::Wide)
.build();
let (frame, header) = page_frame_with(&switcher, &column);
header.pack_start(&pause);
header.pack_end(&refresh);
let widgets = LocationsWidgets {
@@ -200,15 +258,35 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
add_folder,
transfers_group,
card_icon,
card_row,
card_title,
card_detail,
pause,
queue_group,
retry_all,
conflicts_group,
views,
banner,
};
(frame.upcast(), widgets)
}
/// `child` in a clamp that scrolls vertically only, with page margins.
fn scrolled_column(child: &impl IsA<gtk4::Widget>) -> gtk4::ScrolledWindow {
let clamp = adw::Clamp::builder().child(child).build();
clamp.set_margin_top(18);
clamp.set_margin_bottom(18);
clamp.set_margin_start(18);
clamp.set_margin_end(18);
// 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.
gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&clamp)
.build()
}
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
@@ -223,23 +301,121 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk
.queue
.retry_all
.connect_clicked(move |_| retry_queued(&ui_retry, None));
let ui_review = ui.clone();
ui.locations.banner.connect_button_clicked(move |_| {
ui_review.locations.views.set_visible_child_name("overview");
});
let ui_views = ui.clone();
ui.locations
.views
.connect_visible_child_name_notify(move |_| {
paint_sync_banner(&ui_views);
if ui_views.stack.visible_child_name().as_deref() == Some("locations") {
load_sync_view(&ui_views);
}
});
}
/// The status card: a large state icon, the state in words, and Pause/Resume
/// with timed pauses in its menu.
fn build_sync_card() -> (
adw::PreferencesGroup,
gtk4::Image,
adw::ActionRow,
adw::SplitButton,
) {
/// Load what the Sync page's current view shows, on arrival.
pub(crate) fn load_sync_view(ui: &Rc<Ui>) {
match ui.locations.views.visible_child_name().as_deref() {
// Activity changes out from under the page as background uploads and
// edits complete, so it reloads on every visit.
Some("history") => load_activity(ui),
_ => {
refresh_conflicts(ui, true);
if !page_fresh(&ui.locations.loaded_at) {
load_locations(ui);
}
}
}
}
/// Reload the Sync page's current view, for Refresh.
pub(crate) fn reload_sync_view(ui: &Rc<Ui>) {
if ui.locations.views.visible_child_name().as_deref() == Some("history") {
load_activity(ui);
} else {
refresh_conflicts(ui, true);
load_locations(ui);
}
}
/// Follow the refresh tick on the Sync page's current view.
pub(crate) fn tick_sync_view(ui: &Rc<Ui>) {
if ui.locations.views.visible_child_name().as_deref() == Some("history") {
refresh_activity(ui);
} else {
refresh_locations(ui);
refresh_queue(ui);
refresh_conflicts(ui, false);
}
}
/// Show the banner over Folders and History while conflicts or failed
/// changes wait on the Overview, and badge the sidebar's Sync row with them.
pub(crate) fn paint_sync_banner(ui: &Rc<Ui>) {
let state = &ui.locations;
let conflicts = state.conflicts.painted.borrow().len() as u64;
let failing = state.failing.get();
set_sidebar_badge(ui, "locations", conflicts + failing);
let title = match (conflicts, failing) {
(0, 0) => None,
(0, n) => Some(ngettext_f(
"{n} change couldn't be uploaded",
"{n} changes couldn't be uploaded",
n,
&[],
)),
(n, 0) => Some(ngettext_f(
"{n} file was changed in two places at once",
"{n} files were changed in two places at once",
n,
&[],
)),
_ => Some(gettext("Some files need your attention")),
};
let away = state.views.visible_child_name().as_deref() != Some("overview");
state.banner.set_title(title.as_deref().unwrap_or_default());
state.banner.set_revealed(away && title.is_some());
}
/// The status hero: a large state icon, the state in words, and what it
/// means.
fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) {
let icon = gtk4::Image::builder()
.icon_name("pdfs-sync-symbolic")
.pixel_size(32)
.margin_top(6)
.margin_bottom(6)
.pixel_size(48)
.valign(gtk4::Align::Center)
.build();
icon.add_css_class("sync-card-icon");
let title = gtk4::Label::builder()
.label(gettext("Checking…"))
.xalign(0.0)
.wrap(true)
.build();
title.add_css_class("title-2");
let detail = gtk4::Label::builder()
.xalign(0.0)
.wrap(true)
.visible(false)
.build();
detail.add_css_class("dim-label");
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
text.set_valign(gtk4::Align::Center);
text.set_hexpand(true);
text.append(&title);
text.append(&detail);
let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18);
card.add_css_class("card");
card.add_css_class("sync-card");
card.append(&icon);
card.append(&text);
(card, icon, title, detail)
}
/// Pause/Resume, with timed pauses in its menu.
fn build_pause_button() -> adw::SplitButton {
let menu = gio::Menu::new();
menu.append(
Some(&gettext("Pause for 1 Hour")),
@@ -253,7 +429,7 @@ fn build_sync_card() -> (
Some(&gettext("Pause for 24 Hours")),
Some("sync.pause-for(int64 86400)"),
);
let pause = adw::SplitButton::builder()
adw::SplitButton::builder()
.label(pgettext("verb", "Pause"))
.menu_model(&menu)
.valign(gtk4::Align::Center)
@@ -261,20 +437,7 @@ fn build_sync_card() -> (
"Stop uploading until you resume. Files still open as usual.",
))
.dropdown_tooltip(gettext("Pause for a while"))
.build();
let row = adw::ActionRow::builder()
.title(gettext("Checking…"))
.title_lines(1)
.subtitle_lines(2)
.build();
// 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();
group.add(&row);
(group, icon, row, pause)
.build()
}
/// Paint the Sync page's status card from what the sidebar strip shows.
@@ -295,8 +458,10 @@ pub(crate) fn paint_sync_card(
if let Some(class) = class {
card.icon.add_css_class(class);
}
card.row.set_title(title);
card.row.set_subtitle(detail.unwrap_or_default());
card.title.set_label(title);
card.detail
.set_visible(detail.is_some_and(|d| !d.is_empty()));
card.detail.set_label(detail.unwrap_or_default());
card.paused.set(paused);
card.pause.set_label(&if paused {
pgettext("verb", "Resume")
@@ -553,16 +718,29 @@ fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
/// How long a conflict listing stays fresh on the refresh tick.
pub(crate) const CONFLICTS_TTL: Duration = Duration::from_secs(30);
/// How long a conflict listing stays fresh for the sidebar badge while the
/// Sync page is not on screen.
const CONFLICTS_BADGE_TTL: Duration = Duration::from_secs(300);
/// Poll the conflict list while the Sync page is on screen. `force` skips the
/// TTL, for navigation and right after a resolution.
pub(crate) fn refresh_conflicts(ui: &Rc<Ui>, force: bool) {
fetch_conflicts(ui, if force { Duration::ZERO } else { CONFLICTS_TTL });
}
/// Poll the conflict list now and then from anywhere, for the sidebar badge.
pub(crate) fn refresh_conflicts_badge(ui: &Rc<Ui>) {
fetch_conflicts(ui, CONFLICTS_BADGE_TTL);
}
/// Ask for the conflict list unless the last one is younger than `ttl`.
fn fetch_conflicts(ui: &Rc<Ui>, ttl: Duration) {
let conflicts = &ui.locations.conflicts;
if conflicts.inflight.get()
|| (!force
&& conflicts
.fetched_at
.get()
.is_some_and(|at| at.elapsed() < CONFLICTS_TTL))
|| conflicts
.fetched_at
.get()
.is_some_and(|at| !ttl.is_zero() && at.elapsed() < ttl)
{
return;
}
@@ -600,6 +778,7 @@ fn repaint_conflicts(ui: &Rc<Ui>, items: &[ConflictInfo]) {
return;
}
*conflicts.painted.borrow_mut() = key;
paint_sync_banner(ui);
for row in conflicts.rows.borrow_mut().drain(..) {
conflicts.group.remove(&row);
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod activity;
pub(crate) mod activity_text;
pub(crate) mod albums;
pub(crate) mod browser;
pub(crate) mod devices;
+39 -2
View File
@@ -23,6 +23,9 @@ pub(crate) struct SharedState {
pub(crate) invitations_banner: adw::Banner,
/// The invitations from the last top-level load.
pub(crate) invitations: RefCell<Vec<InvitationInfo>>,
/// When the invitations were last read, for the sidebar badge's poll.
pub(crate) invitations_at: Cell<Option<Instant>>,
pub(crate) invitations_inflight: Cell<bool>,
/// The links view: "list", or "empty".
pub(crate) links_content: gtk4::Stack,
pub(crate) links_group: adw::PreferencesGroup,
@@ -58,6 +61,8 @@ impl SharedState {
crumb: widgets.crumb.clone(),
invitations_banner: widgets.invitations_banner.clone(),
invitations: RefCell::new(Vec::new()),
invitations_at: Cell::new(None),
invitations_inflight: Cell::new(false),
links_content: widgets.links_content.clone(),
links_group: widgets.links_group.clone(),
link_rows: RefCell::new(Vec::new()),
@@ -678,7 +683,7 @@ pub(crate) fn role_access(role: &str) -> Option<String> {
/// download-and-open, addressed by uid because the file lives outside the mount.
/// The daemon reports the download as a transfer, so it shows with its
/// progress beside every other one.
fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
pub(crate) fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
// Ignore a repeat activation of a file already downloading, so an impatient
// double-click doesn't kick off a second round-trip.
if !ui.opening.borrow_mut().insert(uid.to_string()) {
@@ -716,8 +721,40 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
});
}
/// Keep the top level's invitations and say in the banner how many wait.
/// How often the sidebar badge asks for invitations away from the page.
const INVITATIONS_TTL: Duration = Duration::from_secs(300);
/// Poll the invitations now and then from anywhere, so the sidebar's Shared
/// with me row can say that some wait.
pub(crate) fn refresh_invitations_badge(ui: &Rc<Ui>) {
let state = &ui.shared;
if state.invitations_inflight.get()
|| state
.invitations_at
.get()
.is_some_and(|at| at.elapsed() < INVITATIONS_TTL)
{
return;
}
state.invitations_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::ListInvitations);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.shared.invitations_inflight.set(false);
match result {
Ok(Ok(Response::Invitations { items })) => repaint_invitations(&ui, items),
// Try again at the next interval rather than on every tick.
_ => ui.shared.invitations_at.set(Some(Instant::now())),
}
});
}
/// Keep the top level's invitations and say in the banner and the sidebar
/// how many wait.
fn repaint_invitations(ui: &Rc<Ui>, invitations: Vec<InvitationInfo>) {
ui.shared.invitations_at.set(Some(Instant::now()));
set_sidebar_badge(ui, "shared", invitations.len() as u64);
let banner = &ui.shared.invitations_banner;
banner.set_revealed(!invitations.is_empty());
if !invitations.is_empty() {
+9 -8
View File
@@ -876,15 +876,14 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
// The quota sits in the sidebar footer, visible on every page; its TTL
// keeps this from asking more than once a minute.
refresh_quota(ui);
match ui.stack.visible_child_name().as_deref() {
Some("locations") => {
refresh_locations(ui);
refresh_queue(ui);
refresh_conflicts(ui, false);
}
Some("activity") => refresh_activity(ui),
_ => {}
if ui.stack.visible_child_name().as_deref() == Some("locations") {
tick_sync_view(ui);
} else {
// The sidebar badges count conflicts and invitations wherever the
// user is; both lists are costly enough to ask for rarely.
refresh_conflicts_badge(ui);
}
refresh_invitations_badge(ui);
}
/// How long a quota reading stays fresh. Account storage barely moves, so the
@@ -1237,6 +1236,8 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
SyncState::UpToDate { mountpoint }
};
paint_sync_status(&ui, state);
ui.locations.failing.set(failing_ops);
paint_sync_banner(&ui);
let fraction = if budget == 0 {
0.0
} else {
-12
View File
@@ -1,8 +1,5 @@
use crate::*;
/// An action run by an [`ActionMenu`] item.
pub(crate) type MenuAction = Box<dyn Fn()>;
/// A `gio::Menu` whose items run closures, for menus whose contents depend on
/// what they were opened for (a row, a selection, a photo). Each item gets its
/// own action in a private `menu` group, installed on whatever hosts the
@@ -148,12 +145,3 @@ fn popover_host(anchor: &gtk4::Widget) -> gtk4::Widget {
}
host
}
/// A flat ⋮ button whose menu lists `items` as (label, action).
pub(crate) fn more_menu_button(items: Vec<(&str, MenuAction)>) -> gtk4::MenuButton {
let mut menu = ActionMenu::new();
for (label, run) in items {
menu.item(label, run);
}
menu.button()
}
+1
View File
@@ -2,6 +2,7 @@ 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/activity_text.rs
crates/pdfs-gui/src/app/pages/albums.rs
crates/pdfs-gui/src/app/pages/browser.rs
crates/pdfs-gui/src/app/pages/devices.rs
+1114 -589
View File
File diff suppressed because it is too large. Load diff
+1139 -614
View File
File diff suppressed because it is too large. Load diff
+1114 -590
View File
File diff suppressed because it is too large. Load diff
+1119 -591
View File
File diff suppressed because it is too large. Load diff
+1116 -590
View File
File diff suppressed because it is too large. Load diff
+1112 -589
View File
File diff suppressed because it is too large. Load diff
+1037 -571
View File
File diff suppressed because it is too large. Load diff
+1141 -592
View File
File diff suppressed because it is too large. Load diff
+1112 -587
View File
File diff suppressed because it is too large. Load diff