diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index f64ad4d..862ccf7 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -31,6 +31,13 @@ const WRITE_TIMEOUT: Duration = Duration::from_secs(2); /// answers. const READ_TIMEOUT: Duration = Duration::from_secs(120); +/// Cap on the read half for the requests front ends poll ([`Request::is_poll`]). +/// The daemon answers those from memory or its database, so a reply that takes +/// longer than this means it is stuck. Waiting the full [`READ_TIMEOUT`] would +/// freeze the poller's read-out for two minutes; failing fast lets it say "not +/// responding" and ask again on its next tick. +const POLL_READ_TIMEOUT: Duration = Duration::from_secs(5); + /// A command sent from the CLI to the daemon. #[derive(Serialize, Deserialize, Debug)] pub enum Request { @@ -739,7 +746,7 @@ pub struct DeviceInfo { /// One `(sync-conflict …)` copy and the file it is a copy of (in /// [`Response::Conflicts`]). -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct ConflictInfo { /// Mountpoint-relative path of the copy, or its absolute local path when it /// sits in a synced folder. @@ -1025,7 +1032,7 @@ pub enum ActivityKind { /// One line in the daemon's activity log (in [`Response::Activity`]). Newest /// first. Records a mutation or transfer the daemon performed, with enough /// context to read as a sentence: "Uploaded report.pdf to /docs". -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct ActivityEntry { /// When it happened, epoch seconds. pub time: i64, @@ -1083,7 +1090,7 @@ pub struct TransferItem { } /// One entry in a [`Request::ListDir`] listing. -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct DirEntry { /// Decrypted node name. pub name: String, @@ -2046,13 +2053,45 @@ impl ErrorKind { } } +impl Request { + /// Whether this is one of the cheap state reads that front ends repeat on a + /// timer. The daemon answers these without touching the network, so they + /// get the short [`POLL_READ_TIMEOUT`]. + pub fn is_poll(&self) -> bool { + matches!( + self, + Request::Status + | Request::GetQueueStatus + | Request::ListLocations + | Request::ListPendingOps + | Request::ListConflicts + | Request::ListActivity { .. } + | Request::ThumbnailBuildStatus + | Request::PhotosRefreshStatus + | Request::RedateStatus + | Request::ImportStatus + ) + } + + /// How long to wait for the daemon's reply to this request. + pub fn read_timeout(&self) -> Duration { + if self.is_poll() { + POLL_READ_TIMEOUT + } else { + READ_TIMEOUT + } + } +} + /// Send one [`Request`] to the daemon listening on `socket` and read its -/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening. +/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening, and +/// one for which [`crate::Error::is_timeout`] holds if the daemon took longer +/// than [`Request::read_timeout`] to answer. /// /// Shared by the CLI and GUI so both speak the wire format identically. pub fn send(socket: &Path, req: &Request) -> Result { let mut stream = UnixStream::connect(socket)?; - stream.set_read_timeout(Some(READ_TIMEOUT))?; + stream.set_read_timeout(Some(req.read_timeout()))?; stream.set_write_timeout(Some(WRITE_TIMEOUT))?; let mut line = serde_json::to_vec(req)?; line.push(b'\n'); @@ -2068,6 +2107,36 @@ pub fn send(socket: &Path, req: &Request) -> Result { mod tests { use super::*; + #[test] + fn polls_wait_briefly_and_real_work_waits_long() { + assert_eq!(Request::Status.read_timeout(), POLL_READ_TIMEOUT); + assert_eq!( + Request::ListActivity { limit: 200 }.read_timeout(), + POLL_READ_TIMEOUT + ); + assert_eq!( + Request::OpenFile { + path: "a.txt".into(), + uid: None, + } + .read_timeout(), + READ_TIMEOUT + ); + } + + #[test] + fn a_daemon_that_never_answers_times_out_as_not_responding() { + let socket = std::env::temp_dir().join(format!("pdfs-silent-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&socket); + // Bound but never accepted: connect succeeds, no reply ever comes. + let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let started = std::time::Instant::now(); + let err = send(&socket, &Request::Status).unwrap_err(); + let _ = std::fs::remove_file(&socket); + assert!(err.is_timeout(), "{err}"); + assert!(started.elapsed() < READ_TIMEOUT); + } + #[test] fn photo_kind_classifies_by_extension_then_mime() { use PhotoKind::*; diff --git a/crates/pdfs-core/src/error.rs b/crates/pdfs-core/src/error.rs index 62224d5..1cf84df 100644 --- a/crates/pdfs-core/src/error.rs +++ b/crates/pdfs-core/src/error.rs @@ -66,6 +66,20 @@ impl Error { } } +impl Error { + /// Whether a control-socket round-trip gave up waiting for the reply: the + /// daemon is running but stuck, as opposed to not running at all. + pub fn is_timeout(&self) -> bool { + matches!( + self, + Error::Io(e) if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) + ) + } +} + pub type Result = std::result::Result; /// A failure on its way to a front-end: prose for the user, plus the diff --git a/crates/pdfs-gui/resources/style.css b/crates/pdfs-gui/resources/style.css index 0867624..903d1f6 100644 --- a/crates/pdfs-gui/resources/style.css +++ b/crates/pdfs-gui/resources/style.css @@ -93,6 +93,13 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; } /* Shared with Me: what a share lets me do, as a tinted pill beside the name. */ .role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); } +/* Activity: the feed sits on the window background as one card per row, under + * day headings, like a boxed list with its rows set apart. */ +listview.activity-feed { background: none; color: inherit; padding: 0 12px 18px 12px; } +listview.activity-feed > row { padding: 0; margin-bottom: 6px; background: none; } +listview.activity-feed > header { padding: 18px 6px 6px 6px; background: none; } +listview.activity-feed row.card { border-radius: 12px; } + /* Photos: how many timeline filters are on, as a small badge on the Filters button. */ .filter-count { min-width: 18px; padding: 0 5px; border-radius: 999px; font-size: 0.8em; font-weight: 700; color: var(--accent-fg-color); background-color: var(--accent-bg-color); } /* The Places map: sea and land come in the map's own colours; markers (drawn diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 0a09cd8..5672a92 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -4,6 +4,7 @@ pub(crate) mod dates; #[path = "../i18n.rs"] pub(crate) mod i18n; pub(crate) mod pages; +pub(crate) mod reload; pub(crate) mod widgets; use pages::activity::*; @@ -23,6 +24,7 @@ use pages::status::*; use pages::takeout::*; use pages::trash::*; use pages::verify::*; +use reload::{LoadTicket, Loader, replace_items}; use widgets::details::*; use widgets::menu::*; use widgets::share_dialog::*; @@ -584,7 +586,8 @@ fn build_window(app: &adw::Application) { thumbnail_build_running: Cell::new(false), thumbnail_cancel_pending: Cell::new(false), search_source: RefCell::new(None), - load_generation: Cell::new(0), + loader: Loader::new(&browser_widgets.content), + listed: RefCell::new(None), views: browser_widgets.views.clone(), column_view: browser_widgets.column_view.clone(), bulk: browser_widgets.bulk.clone(), @@ -615,6 +618,7 @@ fn build_window(app: &adw::Application) { selection: trash_widgets.selection.clone(), selection_bar: trash_widgets.selection_bar.clone(), selection_label: trash_widgets.selection_label.clone(), + loader: Loader::new(&trash_widgets.content), }, gallery: GalleryState { model: gallery_widgets.model.clone(), @@ -716,7 +720,8 @@ fn build_window(app: &adw::Application) { add_bookmark: shared_widgets.add_bookmark.clone(), nav: RefCell::new(Vec::new()), rows: RefCell::new(Vec::new()), - inflight: Cell::new(false), + loader: Loader::new(&shared_widgets.content), + listed: RefCell::new(None), loaded_at: Cell::new(None), }, shared_by_me: SharedByMeState { @@ -725,7 +730,7 @@ fn build_window(app: &adw::Application) { retry: shared_by_me_widgets.retry.clone(), group: shared_by_me_widgets.group.clone(), rows: RefCell::new(Vec::new()), - inflight: Cell::new(false), + loader: Loader::new(&shared_by_me_widgets.content), loaded_at: Cell::new(None), }, devices: DevicesState { @@ -738,7 +743,7 @@ fn build_window(app: &adw::Application) { sync_rows: RefCell::new(Vec::new()), rename_this: devices_widgets.rename_this.clone(), this_device: RefCell::new(None), - inflight: Cell::new(false), + loader: Loader::new(&devices_widgets.content), loaded_at: Cell::new(None), }, locations: LocationsState { @@ -748,6 +753,7 @@ fn build_window(app: &adw::Application) { group: locations_widgets.group.clone(), rows: RefCell::new(Vec::new()), inflight: Cell::new(false), + loader: Loader::new(&locations_widgets.content), loaded_at: Cell::new(None), card: SyncCard { icon: locations_widgets.card_icon.clone(), @@ -774,11 +780,10 @@ fn build_window(app: &adw::Application) { content: activity_widgets.content.clone(), status: activity_widgets.status.clone(), retry: activity_widgets.retry.clone(), - attention: activity_widgets.attention.clone(), - attention_rows: RefCell::new(Vec::new()), - days: activity_widgets.days.clone(), + model: activity_widgets.model.clone(), + feed: activity_widgets.feed.clone(), inflight: Cell::new(false), - key: RefCell::new(None), + loader: Loader::new(&activity_widgets.content), items: RefCell::new(Vec::new()), filter: Cell::new(ActivityFilter::All), conflicts: RefCell::new(None), @@ -843,7 +848,7 @@ fn build_window(app: &adw::Application) { wire_shared_by_me(&ui, &shared_by_me_widgets.retry); wire_devices(&ui, &devices_widgets.retry); wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder); - wire_activity(&ui, &activity_widgets.retry, &activity_widgets.filters); + wire_activity(&ui, &activity_widgets); wire_takeout(&ui, &takeout_widgets); wire_refresh( &ui, @@ -1795,10 +1800,10 @@ fn notify(id: &str, title: &str, body: &str) { fn spawn_request( socket: PathBuf, req: Request, -) -> async_channel::Receiver> { +) -> async_channel::Receiver> { let (tx, rx) = async_channel::bounded(1); std::thread::spawn(move || { - let result = send(&socket, &req).map_err(|e| e.to_string()); + let result = send(&socket, &req); let _ = tx.send_blocking(result); }); rx diff --git a/crates/pdfs-gui/src/app/pages/activity.rs b/crates/pdfs-gui/src/app/pages/activity.rs index c0d5bcb..cef981b 100644 --- a/crates/pdfs-gui/src/app/pages/activity.rs +++ b/crates/pdfs-gui/src/app/pages/activity.rs @@ -2,19 +2,21 @@ use crate::*; pub(crate) struct ActivityState { // Activity page: a "Needs attention" section for live conflicts, then the - // feed grouped by day, rebuilt wholesale on each change. + // feed grouped by day, in one list that a repaint updates in place. pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, pub(crate) retry: gtk4::Button, - pub(crate) attention: adw::PreferencesGroup, - pub(crate) attention_rows: RefCell>, - pub(crate) days: gtk4::Box, + /// The rows, as [`FeedItem`]s. A repaint swaps only the ones that + /// changed, so the page polling every couple of seconds keeps the user's + /// scroll position. + pub(crate) model: gio::ListStore, + /// The list, or the "No matching activity" status when the filter leaves + /// nothing. + pub(crate) feed: gtk4::Stack, pub(crate) inflight: Cell, - /// Fingerprint of what is on screen (see [`activity_key`]). The page polls - /// every couple of seconds and usually gets back exactly what it is already - /// showing; rebuilding every row for that would throw away the user's - /// scroll position several times a minute. - pub(crate) key: RefCell>, + /// Shows the loading state if a full load is slow; the rows stay up + /// until then. + pub(crate) loader: Rc, /// The last feed the daemon sent, so a filter change repaints without a /// round-trip. pub(crate) items: RefCell>, @@ -30,8 +32,9 @@ pub(crate) struct ActivityState { pub(crate) struct ActivityWidgets { pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, - pub(crate) attention: adw::PreferencesGroup, - pub(crate) days: gtk4::Box, + pub(crate) model: gio::ListStore, + pub(crate) list: gtk4::ListView, + pub(crate) feed: gtk4::Stack, pub(crate) filters: Vec<(ActivityFilter, gtk4::ToggleButton)>, pub(crate) retry: gtk4::Button, pub(crate) refresh: gtk4::Button, @@ -89,7 +92,8 @@ impl ActivityFilter { pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { let refresh = refresh_button(); - // Filter chips: a linked row of radio toggles. + // 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); bar.add_css_class("linked"); bar.set_halign(gtk4::Align::Start); @@ -104,25 +108,40 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { bar.append(&button); filters.push((filter, button)); } - - let attention = adw::PreferencesGroup::builder() - .title(gettext("Needs attention")) - .visible(false) - .build(); - let days = gtk4::Box::new(gtk4::Orientation::Vertical, 24); - - let column = gtk4::Box::new(gtk4::Orientation::Vertical, 24); - column.set_margin_top(18); - column.set_margin_bottom(18); - column.set_margin_start(18); - column.set_margin_end(18); - column.append(&bar); - column.append(&attention); - column.append(&days); - let clamp = adw::Clamp::builder() + bar.set_margin_top(12); + bar.set_margin_bottom(6); + bar.set_margin_start(12); + bar.set_margin_end(12); + let bar_clamp = adw::Clamp::builder() .maximum_size(900) .tightening_threshold(600) - .child(&column) + .child(&bar) + .build(); + + // Rows are sectioned by the key each item carries: the conflicts first, + // then one section per day. The items already arrive in that order, and + // the sort is stable, so this only marks where each section starts. + let model = gio::ListStore::new::(); + let sections = gtk4::SortListModel::new(Some(model.clone()), None::); + sections.set_section_sorter(Some(>k4::CustomSorter::new(|a, b| { + let key = |object: &glib::Object| { + object + .downcast_ref::() + .map_or(i64::MIN, |item| item.borrow::().section()) + }; + // Newest section first. + key(b).cmp(&key(a)).into() + }))); + let list = gtk4::ListView::builder() + .model(>k4::NoSelection::new(Some(sections))) + .single_click_activate(true) + .build(); + list.add_css_class("activity-feed"); + list.set_header_factory(Some(&feed_header_factory())); + let clamp = adw::ClampScrollable::builder() + .maximum_size(900) + .tightening_threshold(600) + .child(&list) .build(); let scroll = gtk4::ScrolledWindow::builder() .vexpand(true) @@ -130,6 +149,21 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { .child(&clamp) .build(); + let no_match = adw::StatusPage::builder() + .icon_name("edit-find-symbolic") + .title(gettext("No matching activity")) + .description(gettext("Nothing in the recent log fits this filter.")) + .vexpand(true) + .build(); + no_match.add_css_class("compact"); + let feed = gtk4::Stack::new(); + feed.add_named(&scroll, Some("rows")); + feed.add_named(&no_match, Some("empty")); + + let column = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + column.append(&bar_clamp); + column.append(&feed); + let retry = gtk4::Button::builder() .label(gettext("Retry")) .halign(gtk4::Align::Center) @@ -147,7 +181,7 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { let content = gtk4::Stack::new(); content.set_vexpand(true); content.set_transition_type(gtk4::StackTransitionType::Crossfade); - content.add_named(&scroll, Some("list")); + content.add_named(&column, Some("list")); content.add_named(&status, Some("status")); let (frame, header, _) = page_frame(&gettext("Activity"), &content); @@ -158,8 +192,9 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { ActivityWidgets { content, status, - attention, - days, + model, + list, + feed, filters, retry, refresh, @@ -167,15 +202,62 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { ) } -/// Install the Activity page's retry button. -pub(crate) fn wire_activity( - ui: &Rc, - retry: >k4::Button, - filters: &[(ActivityFilter, gtk4::ToggleButton)], -) { +/// Section headings: "Needs attention" with a line on what to do, or the day. +/// Each is read from the first item of its section. +fn feed_header_factory() -> gtk4::SignalListItemFactory { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, header| { + let header = header.downcast_ref::().unwrap(); + let title = gtk4::Label::builder().xalign(0.0).build(); + title.add_css_class("heading"); + let description = gtk4::Label::builder().xalign(0.0).wrap(true).build(); + description.add_css_class("dim-label"); + let column = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + column.add_css_class("activity-heading"); + column.append(&title); + column.append(&description); + header.set_child(Some(&column)); + }); + factory.connect_bind(|_, header| { + let header = header.downcast_ref::().unwrap(); + let Some(column) = header.child() else { + return; + }; + let Some(title) = column.first_child().and_downcast::() else { + return; + }; + let Some(description) = title.next_sibling().and_downcast::() else { + return; + }; + let Some(item) = header.item().and_downcast::() else { + return; + }; + let (heading, line) = match &*item.borrow::() { + FeedItem::Attention { open, .. } => ( + gettext("Needs attention"), + Some(ngettext_f( + "A file was changed in two places at once. Choose which version to keep.", + "{n} files were changed in two places at once. Choose which versions to keep.", + *open as u64, + &[], + )), + ), + FeedItem::Logged { day_label, .. } => (day_label.clone(), None), + }; + title.set_label(&heading); + description.set_visible(line.is_some()); + description.set_label(line.as_deref().unwrap_or_default()); + }); + factory +} + +/// Install the Activity page's retry button, filters and rows. +pub(crate) fn wire_activity(ui: &Rc, widgets: &ActivityWidgets) { let ui_retry = ui.clone(); - retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_activity)); - for (filter, button) in filters { + widgets + .retry + .connect_clicked(move |_| restart_service_then(&ui_retry, load_activity)); + for (filter, button) in &widgets.filters { let ui = ui.clone(); let filter = *filter; button.connect_toggled(move |button| { @@ -185,6 +267,56 @@ pub(crate) fn wire_activity( } }); } + + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_bind({ + let ui = ui.clone(); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let Some(object) = item.item().and_downcast::() else { + return; + }; + let row = match &*object.borrow::() { + FeedItem::Attention { conflict, .. } => { + item.set_activatable(true); + conflict_row(&ui, conflict) + } + FeedItem::Logged { + entry, + count, + first, + pending, + resolved, + .. + } => { + item.set_activatable(false); + feed_row(&ui, entry, *count, *first, pending.as_ref(), *resolved) + } + }; + row.add_css_class("card"); + item.set_child(Some(&row)); + } + }); + factory.connect_unbind(|_, item| { + let item = item.downcast_ref::().unwrap(); + item.set_child(None::<>k4::Widget>); + }); + widgets.list.set_factory(Some(&factory)); + // A list row does not emit the action row's own `activated`, so the + // conflicts open their dialog from here. + let ui = ui.clone(); + widgets.list.connect_activate(move |list, position| { + let Some(object) = list + .model() + .and_then(|sections| sections.item(position)) + .and_downcast::() + else { + return; + }; + if let FeedItem::Attention { conflict, .. } = &*object.borrow::() { + prompt_resolve_conflict(&ui, conflict); + } + }); } /// Show a status page in place of the Activity list. @@ -200,9 +332,6 @@ pub(crate) fn activity_status( ui.activity.status.set_description(Some(description)); ui.activity.retry.set_visible(retry); ui.activity.content.set_visible_child_name("status"); - // The list is no longer what is on screen, so the next repaint must not skip - // itself as a no-op and leave this status view up. - *ui.activity.key.borrow_mut() = None; } /// Refresh the Activity feed in place, with no status flash and no spinner. @@ -239,13 +368,16 @@ pub(crate) fn load_activity(ui: &Rc) { return; } ui.activity.inflight.set(true); - activity_status( - ui, - "document-open-recent-symbolic", - &gettext("Loading…"), - &gettext("Reading recent activity."), - false, - ); + let ui_p = ui.clone(); + let ticket = ui.activity.loader.refresh(move || { + activity_status( + &ui_p, + "document-open-recent-symbolic", + &gettext("Loading…"), + &gettext("Reading recent activity."), + false, + ); + }); ui.busy_begin(); let rx = spawn_request( ui.dirs.control_socket(), @@ -256,6 +388,7 @@ pub(crate) fn load_activity(ui: &Rc) { let result = rx.recv().await; ui.busy_end(); ui.activity.inflight.set(false); + drop(ticket); match result { Ok(Ok(Response::Activity { items })) => repaint_activity(&ui, &items), Ok(Ok(Response::Error { message, .. })) => activity_status( @@ -318,33 +451,49 @@ pub(crate) fn refresh_activity_conflicts(ui: &Rc, force: bool) { }); } -/// A cheap fingerprint of what the page shows: the feed's length and newest -/// entry (the log is append-only and newest-first, so that pins the feed), the -/// filter, and which conflicts are still open. -pub(crate) fn activity_key( - items: &[ActivityEntry], - filter: ActivityFilter, - conflicts: Option<&[ConflictInfo]>, -) -> String { - let newest = match items.first() { - Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail), - None => String::new(), - }; - let open = conflicts.map(|c| { - c.iter() - .map(|c| c.path.as_str()) - .collect::>() - .join("\n") - }); - format!("{newest}|{filter:?}|{open:?}") -} - /// Keep a fresh log from the daemon and repaint with it. pub(crate) fn repaint_activity(ui: &Rc, items: &[ActivityEntry]) { *ui.activity.items.borrow_mut() = items.to_vec(); paint_activity(ui); } +/// One row of the Activity list, with everything its row and its section +/// heading show, so that comparing two says whether the row must be redrawn. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum FeedItem { + /// A conflict still waiting for a decision. `open` counts all of them, + /// for the section heading. + Attention { conflict: ConflictInfo, open: usize }, + /// A logged activity, folded together with its repeats. + Logged { + entry: ActivityEntry, + /// How many times it was logged in a row (or, for a conflict, at all). + count: usize, + /// When the oldest collapsed repeat happened. + first: i64, + /// The local day it happened on as `yyyymmdd`, which picks its + /// section, and that day's heading. + day: i64, + day_label: String, + /// The open conflict a logged conflict names, which the row offers + /// to resolve. + pending: Option, + /// A logged conflict that is no longer open. + resolved: bool, + }, +} + +impl FeedItem { + /// The section the item belongs to. Sections run from the highest key + /// down: the conflicts, then the days, newest first. + fn section(&self) -> i64 { + match self { + FeedItem::Attention { .. } => i64::MAX, + FeedItem::Logged { day, .. } => *day, + } + } +} + /// One feed row: an entry and how many times it was logged in a row (or, for /// a conflict, at all). #[derive(Debug)] @@ -410,18 +559,66 @@ pub(crate) fn day_label(at: &glib::DateTime, now: &glib::DateTime) -> String { .unwrap_or_default() } -/// Rebuild the page from the kept feed and conflict list, unless it already -/// shows exactly this. +/// 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. +pub(crate) fn feed_items( + items: &[ActivityEntry], + filter: ActivityFilter, + conflicts: Option<&[ConflictInfo]>, + now: Option<&glib::DateTime>, +) -> Vec { + let open = conflicts.unwrap_or_default(); + let mut rows: Vec = open + .iter() + .map(|conflict| FeedItem::Attention { + conflict: conflict.clone(), + open: open.len(), + }) + .collect(); + for row in collapse_feed(items) { + if !filter.matches(row.entry) { + continue; + } + let a = row.entry; + let at = glib::DateTime::from_unix_local(a.time).ok(); + let (day, day_label) = match (&at, now) { + (Some(at), Some(now)) => { + let (y, m, d) = at.ymd(); + ( + i64::from(y) * 10_000 + i64::from(m) * 100 + i64::from(d), + day_label(at, now), + ) + } + _ => (0, String::new()), + }; + // A logged conflict names the copy; it is still open while the daemon + // still lists a copy by that name. + let pending = (a.kind == ActivityKind::Conflict) + .then(|| open.iter().find(|c| file_name(&c.path) == a.target)) + .flatten() + .cloned(); + let resolved = a.kind == ActivityKind::Conflict && pending.is_none() && conflicts.is_some(); + rows.push(FeedItem::Logged { + entry: a.clone(), + count: row.count, + first: row.first, + day, + day_label, + pending, + resolved, + }); + } + rows +} + +/// 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) { let state = &ui.activity; let items = state.items.borrow(); let conflicts = state.conflicts.borrow(); - let filter = state.filter.get(); - let key = activity_key(&items, filter, conflicts.as_deref()); - if state.key.borrow().as_deref() == Some(key.as_str()) { - return; - } - let open: &[ConflictInfo] = conflicts.as_deref().unwrap_or_default(); + let open = conflicts.as_deref().unwrap_or_default(); if items.is_empty() && open.is_empty() { activity_status( ui, @@ -430,95 +627,45 @@ pub(crate) fn paint_activity(ui: &Rc) { &gettext("Uploads, moves, shares and other changes appear here as they happen."), false, ); - return; - } - *state.key.borrow_mut() = Some(key); - state.content.set_visible_child_name("list"); - - // Needs attention: the live conflicts, each opening the resolve dialog. - for row in state.attention_rows.borrow_mut().drain(..) { - state.attention.remove(&row); - } - state.attention.set_visible(!open.is_empty()); - state.attention.set_description(Some(&ngettext_f( - "A file was changed in two places at once. Choose which version to keep.", - "{n} files were changed in two places at once. Choose which versions to keep.", - open.len() as u64, - &[], - ))); - let mut attention_rows = state.attention_rows.borrow_mut(); - for conflict in open { - let row = conflict_row(ui, conflict); - state.attention.add(&row); - attention_rows.push(row); - } - drop(attention_rows); - - while let Some(child) = state.days.first_child() { - state.days.remove(&child); - } - let rows: Vec = collapse_feed(&items) - .into_iter() - .filter(|row| filter.matches(row.entry)) - .collect(); - if rows.is_empty() { - let empty = adw::StatusPage::builder() - .icon_name("edit-find-symbolic") - .title(gettext("No matching activity")) - .description(gettext("Nothing in the recent log fits this filter.")) - .build(); - empty.add_css_class("compact"); - state.days.append(&empty); + state.model.remove_all(); return; } let now = glib::DateTime::now_local().ok(); - let mut group: Option<(String, adw::PreferencesGroup)> = None; - for row in rows { - let at = glib::DateTime::from_unix_local(row.entry.time).ok(); - let label = match (&at, &now) { - (Some(at), Some(now)) => day_label(at, now), - _ => String::new(), - }; - if group.as_ref().is_none_or(|(day, _)| *day != label) { - let section = adw::PreferencesGroup::builder().title(&label).build(); - state.days.append(§ion); - group = Some((label, section)); - } - if let Some((_, section)) = &group { - section.add(&feed_row(ui, &row, at.as_ref(), open)); - } - } + let rows = feed_items( + &items, + state.filter.get(), + conflicts.as_deref(), + now.as_ref(), + ); + replace_items(&state.model, &rows); + state + .feed + .set_visible_child_name(if rows.is_empty() { "empty" } else { "rows" }); + state.content.set_visible_child_name("list"); } -/// One row of the feed. +/// One logged row of the feed, with the fields of [`FeedItem::Logged`]. fn feed_row( ui: &Rc, - row: &FeedRow, - at: Option<&glib::DateTime>, - open: &[ConflictInfo], + a: &ActivityEntry, + count: usize, + first: i64, + pending: Option<&ConflictInfo>, + resolved: bool, ) -> adw::ActionRow { - let a = row.entry; let title = activity_title(a.kind, &a.target); - // A logged conflict names the copy; it is still open while the daemon - // still lists a copy by that name. - let pending = (a.kind == ActivityKind::Conflict) - .then(|| open.iter().find(|c| file_name(&c.path) == a.target)) - .flatten(); - let resolved = a.kind == ActivityKind::Conflict - && pending.is_none() - && ui.activity.conflicts.borrow().is_some(); let mut subtitle: Vec = Vec::new(); if !a.detail.is_empty() { subtitle.push(capitalize(&a.detail)); } - if row.count > 1 { - let since = dates::relative(row.first); + 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( "{n} time since {time}", "{n} times since {time}", - row.count as u64, + count as u64, &[("time", &since)], )); } @@ -554,7 +701,9 @@ fn feed_row( // 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( - &at.and_then(|at| at.format(&time_format).ok()) + &glib::DateTime::from_unix_local(a.time) + .ok() + .and_then(|at| at.format(&time_format).ok()) .map(|s| s.to_string()) .unwrap_or_default(), )); @@ -703,6 +852,81 @@ mod tests { assert!(!ActivityFilter::Sharing.matches(&fine)); } + fn conflict(path: &str) -> ConflictInfo { + ConflictInfo { + path: path.into(), + original_path: "docs/a.txt".into(), + original_exists: true, + size: 1, + modified: 0, + original_size: None, + original_modified: None, + identical: false, + } + } + + #[test] + fn open_conflicts_lead_and_logged_ones_know_their_state() { + let items = vec![ + entry( + 300, + ActivityKind::Conflict, + "a (sync-conflict).txt", + "", + false, + ), + entry( + 200, + ActivityKind::Conflict, + "b (sync-conflict).txt", + "", + false, + ), + 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); + assert_eq!(rows.len(), 4); + assert!(matches!(&rows[0], FeedItem::Attention { open: 1, .. })); + assert!(rows[0].section() > rows[1].section()); + let states: Vec<(bool, bool)> = rows[1..] + .iter() + .map(|row| match row { + FeedItem::Logged { + pending, resolved, .. + } => (pending.is_some(), *resolved), + FeedItem::Attention { .. } => unreachable!(), + }) + .collect(); + assert_eq!(states, vec![(true, false), (false, true), (false, false)]); + } + + #[test] + fn unknown_conflicts_resolve_nothing_and_filters_spare_the_attention_rows() { + let items = vec![ + entry( + 300, + ActivityKind::Conflict, + "a (sync-conflict).txt", + "", + false, + ), + entry(100, ActivityKind::Upload, "c.txt", "", true), + ]; + let rows = feed_items(&items, ActivityFilter::All, None, None); + assert!(matches!( + &rows[0], + FeedItem::Logged { + resolved: false, + .. + } + )); + let open = [conflict("docs/a (sync-conflict).txt")]; + let rows = feed_items(&items, ActivityFilter::Sharing, Some(&open), None); + assert_eq!(rows.len(), 1); + assert!(matches!(&rows[0], FeedItem::Attention { .. })); + } + #[test] fn days_read_today_yesterday_weekday_then_date() { let tz = glib::TimeZone::utc(); diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 29b0944..7a4019b 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -52,10 +52,14 @@ pub(crate) struct BrowserState { /// Pending debounce timer for the search box; replaced on every keystroke so /// only the last pause actually fires a [`Request::Search`]. pub(crate) search_source: RefCell>, - /// Identity of the newest folder/search request. Paths and queries can be - /// requested repeatedly, so comparing their text alone cannot reject an - /// older response that finishes after a manual refresh. - pub(crate) load_generation: Cell, + /// Runs folder loads and searches. Paths and queries can be requested + /// repeatedly, so only its tickets can tell an older reply that finishes + /// after a manual refresh from the newest one. + pub(crate) loader: Rc, + /// The folder the model holds rows for, or `None` while it holds search + /// hits or nothing. Reloading this folder keeps the rows up and usable; + /// loading any other greys them out until the new ones arrive. + pub(crate) listed: RefCell>, /// The grid/list view stack, read to find out which view is on screen. pub(crate) views: gtk4::Stack, /// The list view, whose Location column shows only for search hits. @@ -2815,24 +2819,33 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str { /// Request the current browser directory from the daemon and repaint both views. pub(crate) fn load_browser(ui: &Rc) { cancel_file_thumbnails(ui); - let generation = ui.browser.load_generation.get().wrapping_add(1); - ui.browser.load_generation.set(generation); let path = ui.browser.path.borrow().clone(); repaint_crumb(ui, &path); sync_history_actions(ui); sync_search_scope(ui); - ui.browser.summary.set_label(&gettext("Loading…")); - // Drop the previous folder's rows up front: a slow reply must not leave stale - // entries visible, where clicking one would open with a wrong relative path. - ui.browser.model.remove_all(); - browser_status( - ui, - "folder-symbolic", - &gettext("Loading…"), - &gettext("Reading this folder."), - false, - ); + // A reload of the folder on screen leaves its rows alone. Any other load + // keeps the old rows up only greyed out, with nothing selected: their + // paths resolve against the new folder, so acting on one would hit the + // wrong file. + let ui_p = ui.clone(); + let placeholder = move || { + ui_p.browser.model.remove_all(); + ui_p.browser.summary.set_label(&gettext("Loading…")); + browser_status( + &ui_p, + "folder-symbolic", + &gettext("Loading…"), + &gettext("Reading this folder."), + false, + ); + }; + let ticket = if ui.browser.listed.borrow().as_deref() == Some(path.as_str()) { + ui.browser.loader.refresh(placeholder) + } else { + forget_listing(ui); + ui.browser.loader.replace(placeholder) + }; ui.busy_begin(); let rx = spawn_request( @@ -2846,7 +2859,7 @@ pub(crate) fn load_browser(ui: &Rc) { // The user may have navigated on while this folder was loading. A stale // out-of-order reply must not repaint rows for a folder we've left, or // the breadcrumb and the grid would disagree. - if ui.browser.load_generation.get() != generation || *ui.browser.path.borrow() != path { + if !ticket.is_current() || *ui.browser.path.borrow() != path { return; } match result { @@ -2866,6 +2879,7 @@ pub(crate) fn load_browser(ui: &Rc) { /// in-band failures (a bad path, a permission error) — the mount is up, so Retry /// (which restarts the service) wouldn't help and isn't offered. pub(crate) fn browser_failed(ui: &Rc, message: &str, kind: ErrorKind) { + forget_listing(ui); ui.browser.model.remove_all(); ui.browser.summary.set_label(&gettext("Folder unavailable")); browser_status( @@ -2899,7 +2913,7 @@ pub(crate) fn browser_unreachable(ui: &Rc) { pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { show_location_column(ui, false); *ui.browser.listing.borrow_mut() = entries.to_vec(); - ui.browser.model.remove_all(); + *ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone()); ui.browser.summary.set_label(&listing_summary( entries.iter().filter(|entry| !entry.is_dir).count(), entries.iter().filter(|entry| entry.is_dir).count(), @@ -2917,6 +2931,7 @@ pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { &gettext("Drop files here, or upload a file or create a folder to get started."), false, ); + ui.browser.model.remove_all(); ui.browser.empty_actions.set_visible(true); return; } @@ -2924,9 +2939,17 @@ pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { let mut sorted = entries.to_vec(); sort_entries(&mut sorted, ui.browser.view.get()); - for entry in sorted { - ui.browser.model.append(&BoxedAnyObject::new(entry)); - } + // Only the rows that changed are swapped, so a refresh keeps the + // selection, the scroll position and the thumbnails already drawn. + replace_items(&ui.browser.model, &sorted); +} + +/// Forget which folder the rows belong to and drop the selection, before the +/// rows stop matching the folder on screen. +fn forget_listing(ui: &Rc) { + ui.browser.listed.borrow_mut().take(); + active_selection(ui).unselect_all(); + clear_details(ui); } /// Wire the browser header's search box: debounce keystrokes, then either run a @@ -2984,17 +3007,22 @@ fn search_scope(ui: &Rc) -> Option { /// (each hit carries its full path; see [`entry_rel`]). pub(crate) fn run_search(ui: &Rc, query: &str) { cancel_file_thumbnails(ui); - let generation = ui.browser.load_generation.get().wrapping_add(1); - ui.browser.load_generation.set(generation); - ui.browser.model.remove_all(); - ui.browser.summary.set_label(&gettext("Searching…")); - browser_status( - ui, - "system-search-symbolic", - &gettext("Searching…"), - &gettext_f("Looking for “{query}”.", &[("query", query)]), - false, - ); + // Hits carry their own paths, but the old rows may not: grey them out and + // drop the selection until the hits arrive. + forget_listing(ui); + let ui_p = ui.clone(); + let looking_for = query.to_string(); + let ticket = ui.browser.loader.replace(move || { + ui_p.browser.model.remove_all(); + ui_p.browser.summary.set_label(&gettext("Searching…")); + browser_status( + &ui_p, + "system-search-symbolic", + &gettext("Searching…"), + &gettext_f("Looking for “{query}”.", &[("query", &looking_for)]), + false, + ); + }); ui.busy_begin(); let query = query.to_string(); @@ -3013,9 +3041,7 @@ pub(crate) fn run_search(ui: &Rc, query: &str) { // The box may have been cleared or typed past while the reply was in // flight; if the query no longer matches, a fresher load/search already // owns the model — drop this stale, possibly out-of-order result. - if ui.browser.load_generation.get() != generation - || ui.browser.search.text().trim() != query - { + if !ticket.is_current() || ui.browser.search.text().trim() != query { return; } match result { @@ -3036,7 +3062,6 @@ pub(crate) fn run_search(ui: &Rc, query: &str) { /// handlers already understand. pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { show_location_column(ui, true); - ui.browser.model.remove_all(); let counts = listing_summary( hits.iter().filter(|hit| !hit.is_dir).count(), hits.iter().filter(|hit| hit.is_dir).count(), @@ -3067,6 +3092,7 @@ pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { &gettext("No files or folders match that search."), false, ); + ui.browser.model.remove_all(); return; } browser_views(ui); @@ -3089,9 +3115,7 @@ pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { }) .collect(); sort_entries(&mut entries, ui.browser.view.get()); - for entry in entries { - ui.browser.model.append(&BoxedAnyObject::new(entry)); - } + replace_items(&ui.browser.model, &entries); } fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String { diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index c6de6fb..5e7f23b 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -19,7 +19,8 @@ pub(crate) struct DevicesState { /// 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>, - pub(crate) inflight: Cell, + /// Runs the loads; the rows stay up while one runs. + pub(crate) loader: Rc, pub(crate) loaded_at: Cell>, } @@ -148,17 +149,16 @@ pub(crate) fn devices_status(ui: &Rc, icon: &str, title: &str, description: /// repaint both sections. The two requests are chained so a single unreachable /// daemon collapses the whole page to a status view. pub(crate) fn load_devices(ui: &Rc) { - if ui.devices.inflight.get() { - return; - } - ui.devices.inflight.set(true); - devices_status( - ui, - "computer-symbolic", - &gettext("Loading…"), - &gettext("Reading your computers."), - false, - ); + 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, + ); + }); 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 @@ -169,14 +169,16 @@ pub(crate) fn load_devices(ui: &Rc) { glib::spawn_future_local(async move { let sync = rx.recv().await; let devices_reply = rx2.recv().await; + ui.busy_end(); + if !ticket.is_current() { + return; + } let folders = match sync { Ok(Ok(Response::SyncFolders { items })) => items, // The daemon answered but not as expected — treat as empty and carry // on to the devices list rather than failing the whole page. Ok(Ok(_)) => Vec::new(), Ok(Err(_)) | Err(_) => { - ui.busy_end(); - ui.devices.inflight.set(false); ui.devices.loaded_at.set(None); devices_unreachable(&ui); return; @@ -190,8 +192,6 @@ pub(crate) fn load_devices(ui: &Rc) { Ok(Ok(Response::Devices { items })) => items, _ => Vec::new(), }; - ui.busy_end(); - ui.devices.inflight.set(false); repaint_devices(&ui, &devices); ui.devices.loaded_at.set(Some(Instant::now())); }); diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index 0375d8c..5255838 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -21,6 +21,9 @@ pub(crate) struct LocationsState { pub(crate) group: adw::PreferencesGroup, pub(crate) rows: RefCell>, pub(crate) inflight: Cell, + /// Shows the loading state if a full load is slow; the rows stay up + /// until then. + pub(crate) loader: Rc, pub(crate) loaded_at: Cell>, pub(crate) card: SyncCard, pub(crate) queue: QueueState, @@ -800,13 +803,16 @@ pub(crate) fn load_locations(ui: &Rc) { return; } ui.locations.inflight.set(true); - locations_status( - ui, - "drive-harddisk-symbolic", - &gettext("Loading…"), - &gettext("Reading this computer's Proton Drive locations."), - false, - ); + let ui_p = ui.clone(); + let ticket = ui.locations.loader.refresh(move || { + locations_status( + &ui_p, + "drive-harddisk-symbolic", + &gettext("Loading…"), + &gettext("Reading this computer's Proton Drive locations."), + false, + ); + }); ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations); let ui = ui.clone(); @@ -814,6 +820,7 @@ pub(crate) fn load_locations(ui: &Rc) { let result = rx.recv().await; ui.busy_end(); ui.locations.inflight.set(false); + drop(ticket); match result { Ok(Ok(Response::Locations { items })) => { ui.locations.content.set_visible_child_name("list"); diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index eddf4e8..cfb8e27 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -20,8 +20,12 @@ pub(crate) struct SharedState { /// mount, so descending is uid-addressed and the stack *is* the breadcrumb. pub(crate) nav: RefCell>, pub(crate) rows: RefCell>, - /// Guards the Shared page's load so overlapping navigations don't stack. - pub(crate) inflight: Cell, + /// Runs the loads. A newer one, such as a navigation made while a folder + /// was loading, supersedes the one before it. + pub(crate) loader: Rc, + /// The [`SharedState::nav`] the rows on screen were painted for, or `None` + /// before the first paint. + pub(crate) listed: RefCell>>, /// When the Shared page last painted good data. `None` = never / invalidated, /// forcing a fetch on next visit. See [`PAGE_TTL`]. pub(crate) loaded_at: Cell>, @@ -163,22 +167,16 @@ pub(crate) fn shared_status(ui: &Rc, icon: &str, title: &str, description: & /// children are fetched: invitations and bookmarks belong to the top level, and /// carrying them down a subtree would read as if they lived there. pub(crate) fn load_shared(ui: &Rc) { - if ui.shared.inflight.get() { - return; - } cancel_file_thumbnails(ui); let current = ui.shared.nav.borrow().last().cloned(); if let Some((uid, _)) = current { load_shared_folder(ui, uid); return; } - ui.shared.inflight.set(true); - shared_status( + let ticket = begin_shared_load( ui, "pdfs-people-symbolic", - &gettext("Loading…"), - &gettext("Reading your shared items."), - false, + gettext("Reading your shared items."), ); ui.busy_begin(); @@ -192,7 +190,9 @@ pub(crate) fn load_shared(ui: &Rc) { let invites = invites_rx.recv().await; let bookmarks = bookmarks_rx.recv().await; ui.busy_end(); - ui.shared.inflight.set(false); + if !ticket.is_current() { + return; + } // A transport failure on any of the three means the daemon isn't up. if matches!(shared, Ok(Err(_)) | Err(_)) @@ -225,13 +225,10 @@ pub(crate) fn load_shared(ui: &Rc) { /// The uid comes from the row that was activated (or from the nav stack on a /// reload) — a shared subtree is reachable no other way. fn load_shared_folder(ui: &Rc, uid: String) { - ui.shared.inflight.set(true); - shared_status( + let ticket = begin_shared_load( ui, "folder-symbolic", - &gettext("Loading…"), - &gettext("Reading this shared folder."), - false, + gettext("Reading this shared folder."), ); ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedFolder { uid }); @@ -239,7 +236,9 @@ fn load_shared_folder(ui: &Rc, uid: String) { glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); - ui.shared.inflight.set(false); + if !ticket.is_current() { + return; + } match result { Ok(Ok(Response::Entries { entries })) => { repaint_shared_folder(&ui, &entries); @@ -260,6 +259,22 @@ fn load_shared_folder(ui: &Rc, uid: String) { }); } +/// Start a load of the view [`SharedState::nav`] points at. A reload of the +/// view on screen keeps its rows up and usable. Moving to another view greys +/// them out until the new rows arrive, since they belong to another folder. +fn begin_shared_load(ui: &Rc, icon: &'static str, description: String) -> LoadTicket { + let ui_p = ui.clone(); + let placeholder = move || { + shared_status(&ui_p, icon, &gettext("Loading…"), &description, false); + }; + if *ui.shared.listed.borrow() == Some(ui.shared.nav.borrow().clone()) { + ui.shared.loader.refresh(placeholder) + } else { + ui.shared.listed.borrow_mut().take(); + ui.shared.loader.replace(placeholder) + } +} + /// The header subtitle inside a shared folder: the folders from the share root /// down, the way a breadcrumb reads. pub(crate) fn shared_path(nav: &[(String, String)]) -> String { @@ -295,6 +310,7 @@ fn repaint_shared_folder(ui: &Rc, entries: &[DirEntry]) { let nav = ui.shared.nav.borrow().clone(); ui.shared.title.set_subtitle(&shared_path(&nav)); + *ui.shared.listed.borrow_mut() = Some(nav); ui.shared.with_me_group.set_title(""); let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); @@ -529,6 +545,7 @@ pub(crate) fn repaint_shared( for (group, row) in ui.shared.rows.borrow_mut().drain(..) { group.remove(&row); } + *ui.shared.listed.borrow_mut() = Some(Vec::new()); ui.shared.title.set_subtitle(""); ui.shared.back.set_visible(false); ui.shared.add_bookmark.set_visible(true); diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index c10894a..95aa724 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -8,7 +8,8 @@ pub(crate) struct SharedByMeState { pub(crate) retry: gtk4::Button, pub(crate) group: adw::PreferencesGroup, pub(crate) rows: RefCell>, - pub(crate) inflight: Cell, + /// Runs the loads; the rows stay up while one runs. + pub(crate) loader: Rc, pub(crate) loaded_at: Cell>, } @@ -98,25 +99,26 @@ pub(crate) fn shared_by_me_status( /// Fetch the shared-by-me listing and repaint the page. pub(crate) fn load_shared_by_me(ui: &Rc) { - if ui.shared_by_me.inflight.get() { - return; - } cancel_file_thumbnails(ui); - ui.shared_by_me.inflight.set(true); - shared_by_me_status( - ui, - "pdfs-share-symbolic", - &gettext("Loading…"), - &gettext("Reading what you've shared."), - false, - ); + let ui_p = ui.clone(); + let ticket = ui.shared_by_me.loader.refresh(move || { + shared_by_me_status( + &ui_p, + "pdfs-share-symbolic", + &gettext("Loading…"), + &gettext("Reading what you've shared."), + false, + ); + }); ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); - ui.shared_by_me.inflight.set(false); + if !ticket.is_current() { + return; + } match result { Ok(Ok(Response::SharedByMe { items })) => { repaint_shared_by_me(&ui, &items); diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index dfad8d3..ecb92f9 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -1262,6 +1262,10 @@ pub(crate) fn refresh_status(ui: &Rc) { }); repaint_pins(&ui, &pins, true); } + // The daemon is up but stuck. Say so, and keep the mount state: + // announcing "disconnected" for a slow reply would be wrong, and + // the next tick asks again. + Ok(Err(e)) if e.is_timeout() => paint_sync_status(&ui, SyncState::NotResponding), // Daemon unreachable (still starting, or down): report not-mounted and // grey out the unpin buttons in place, but leave the last-known pin // rows and cache read-out so the page doesn't flicker on a blip. @@ -1305,13 +1309,15 @@ pub(crate) enum SyncState { }, /// No mount daemon answered: still starting, or the service is down. Disconnected, + /// The daemon is running but didn't answer in time. + NotResponding, } /// Paint the sidebar status strip and the Sync page's status card: icon, /// one-line state, and a detail line. fn paint_sync_status(ui: &Rc, state: SyncState) { let paused = matches!(state, SyncState::Paused { .. }); - let connected = !matches!(state, SyncState::Disconnected); + let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding); let (icon, class, title, detail) = match state { SyncState::Paused { until, queued } => ( "media-playback-pause-symbolic", @@ -1366,6 +1372,12 @@ fn paint_sync_status(ui: &Rc, state: SyncState) { gettext("Not connected"), Some(gettext("Proton Drive isn't running")), ), + SyncState::NotResponding => ( + "dialog-warning-symbolic", + Some("warning"), + gettext("Not responding"), + Some(gettext("Proton Drive is taking too long to answer")), + ), }; let image = &ui.status.status_icon; image.set_icon_name(Some(icon)); diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs index d512f29..a8c5be3 100644 --- a/crates/pdfs-gui/src/app/pages/trash.rs +++ b/crates/pdfs-gui/src/app/pages/trash.rs @@ -15,6 +15,8 @@ pub(crate) struct TrashState { /// The bottom bar acting on the selection; revealed while anything is selected. pub(crate) selection_bar: gtk4::Revealer, pub(crate) selection_label: gtk4::Label, + /// Runs the loads; the rows stay up while one runs. + pub(crate) loader: Rc, } /// The widgets of the Trash page that a load repaints. @@ -285,18 +287,21 @@ pub(crate) fn bound_entry(item: >k4::ListItem) -> Option { /// Fetch the trash listing and repaint the page. pub(crate) fn load_trash(ui: &Rc) { cancel_file_thumbnails(ui); - // Drop the old rows first: a stale row here would offer Restore on something - // that may already be gone. - ui.trash.model.remove_all(); - ui.trash.empty.set_sensitive(false); - sync_trash_selection(ui); - trash_status( - ui, - "user-trash-symbolic", - &gettext("Loading…"), - &gettext("Reading the trash."), - false, - ); + // The rows stay up while the trash is read again. Trashed items are + // addressed by uid, so acting on one that has gone meanwhile fails + // cleanly rather than hitting something else. + let ui_p = ui.clone(); + let ticket = ui.trash.loader.refresh(move || { + clear_trash(&ui_p); + ui_p.trash.empty.set_sensitive(false); + trash_status( + &ui_p, + "user-trash-symbolic", + &gettext("Loading…"), + &gettext("Reading the trash."), + false, + ); + }); ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash); @@ -304,6 +309,12 @@ pub(crate) fn load_trash(ui: &Rc) { glib::spawn_future_local(async move { let result = rx.recv().await; ui.busy_end(); + if !ticket.is_current() { + return; + } + if !matches!(result, Ok(Ok(Response::Entries { .. }))) { + clear_trash(&ui); + } match result { Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries), Ok(Ok(Response::Error { message, .. })) => trash_status( @@ -344,8 +355,6 @@ pub(crate) fn trash_status(ui: &Rc, icon: &str, title: &str, description: &s /// Repopulate the trash list, most recently modified first — the order in which a /// user looks for what they just deleted. pub(crate) fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { - ui.trash.model.remove_all(); - sync_trash_selection(ui); // An empty Trash has nothing to empty, so the button goes away rather than // sitting there greyed out. ui.trash.empty.set_sensitive(true); @@ -358,6 +367,7 @@ pub(crate) fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { &gettext("Items you delete from Proton Drive show up here."), false, ); + clear_trash(ui); return; } ui.trash.content.set_visible_child_name("list"); @@ -370,9 +380,16 @@ pub(crate) fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { let mut sorted = entries.to_vec(); sorted.sort_by_key(|e| std::cmp::Reverse(e.modified)); - for entry in sorted { - ui.trash.model.append(&BoxedAnyObject::new(entry)); - } + // Only the rows that changed are swapped, so a refresh keeps the + // selection and the scroll position. + replace_items(&ui.trash.model, &sorted); + sync_trash_selection(ui); +} + +/// Drop every row, and with them the selection and its bar. +fn clear_trash(ui: &Rc) { + ui.trash.model.remove_all(); + sync_trash_selection(ui); } /// Restore one trashed entry to the folder it was trashed from. diff --git a/crates/pdfs-gui/src/app/reload.rs b/crates/pdfs-gui/src/app/reload.rs new file mode 100644 index 0000000..d57461e --- /dev/null +++ b/crates/pdfs-gui/src/app/reload.rs @@ -0,0 +1,188 @@ +//! Reloading a page without flashing it. +//! +//! Pages used to swap their content for a "Loading…" status page on every +//! load, so every sidebar click and every folder change blinked. A [`Loader`] +//! keeps the last content on screen while the request runs and shows the +//! loading state only if the reply is slow. [`replace_items`] then swaps in the +//! new rows by changing only the ones that differ, so selection and scroll +//! position survive a refresh. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::gio; +use gtk4::glib; +use gtk4::glib::BoxedAnyObject; +use gtk4::glib::object::Cast; +use gtk4::prelude::*; + +/// How long a load may run before the page gives up its old content for a +/// loading state. Most replies arrive well within this, so the page updates in +/// place. +const LOADING_GRACE: Duration = Duration::from_millis(300); + +/// One page's loads. Each [`Loader::refresh`] or [`Loader::replace`] +/// supersedes the one before it. +pub(crate) struct Loader { + content: gtk4::Widget, + generation: Cell, + placeholder: RefCell>, +} + +impl Loader { + /// A loader for the page whose content is `content`. + pub(crate) fn new(content: &impl IsA) -> Rc { + Rc::new(Loader { + content: content.clone().upcast(), + generation: Cell::new(0), + placeholder: RefCell::new(None), + }) + } + + /// Start a load of what the page already shows. The content stays up and + /// usable; `placeholder` runs if the load is still going after + /// [`LOADING_GRACE`]. + pub(crate) fn refresh(self: &Rc, placeholder: impl FnOnce() + 'static) -> LoadTicket { + self.begin(false, placeholder) + } + + /// Start a load of something else, such as another folder. The old + /// content stays up but insensitive, since acting on it would act on the + /// wrong thing; `placeholder` runs if the load is still going after + /// [`LOADING_GRACE`]. + pub(crate) fn replace(self: &Rc, placeholder: impl FnOnce() + 'static) -> LoadTicket { + self.begin(true, placeholder) + } + + fn begin(self: &Rc, stale: bool, placeholder: impl FnOnce() + 'static) -> LoadTicket { + let generation = self.generation.get().wrapping_add(1); + self.generation.set(generation); + self.cancel_placeholder(); + self.content.set_sensitive(!stale); + let loader = Rc::downgrade(self); + let source = glib::timeout_add_local_once(LOADING_GRACE, move || { + let Some(loader) = loader.upgrade() else { + return; + }; + loader.placeholder.borrow_mut().take(); + loader.content.set_sensitive(true); + placeholder(); + }); + *self.placeholder.borrow_mut() = Some(source); + LoadTicket { + loader: self.clone(), + generation, + } + } + + fn cancel_placeholder(&self) { + if let Some(source) = self.placeholder.borrow_mut().take() { + source.remove(); + } + } +} + +/// A load in flight. Dropping it ends the load: the pending loading state is +/// cancelled and the content is usable again, unless a newer load has taken +/// over in the meantime. +pub(crate) struct LoadTicket { + loader: Rc, + generation: u64, +} + +impl LoadTicket { + /// Whether no newer load has started since this one. A superseded load's + /// reply is stale and must not be painted. + pub(crate) fn is_current(&self) -> bool { + self.loader.generation.get() == self.generation + } +} + +impl Drop for LoadTicket { + fn drop(&mut self) { + if self.is_current() { + self.loader.cancel_placeholder(); + self.loader.content.set_sensitive(true); + } + } +} + +/// Make `model`, a list of [`BoxedAnyObject`]s holding `T`, hold `items`, +/// replacing only the run of items that changed, in one `items-changed` +/// signal. Rows before and after that run keep their objects, so the views +/// keep their widgets, the selection and the scroll position. +pub(crate) fn replace_items(model: &gio::ListStore, items: &[T]) { + let held = |i: usize, item: &T| { + model + .item(i as u32) + .and_downcast::() + .is_some_and(|object| *object.borrow::() == *item) + }; + let Some((start, removed, added)) = changed_run(model.n_items() as usize, items, held) else { + return; + }; + let added: Vec = items[added] + .iter() + .cloned() + .map(BoxedAnyObject::new) + .collect(); + model.splice(start as u32, removed as u32, &added); +} + +/// The one run that differs between an old list of `old_len` items and `new`: +/// where it starts, how many old items it covers, and which new items replace +/// them. `held(i, item)` says whether old item `i` equals `item`. `None` when +/// the lists are equal. +fn changed_run( + old_len: usize, + new: &[T], + held: impl Fn(usize, &T) -> bool, +) -> Option<(usize, usize, std::ops::Range)> { + let shared = old_len.min(new.len()); + let prefix = (0..shared).take_while(|&i| held(i, &new[i])).count(); + let suffix = (0..shared - prefix) + .take_while(|&k| held(old_len - 1 - k, &new[new.len() - 1 - k])) + .count(); + let removed = old_len - prefix - suffix; + let added = prefix..new.len() - suffix; + (removed > 0 || !added.is_empty()).then_some((prefix, removed, added)) +} + +#[cfg(test)] +mod tests { + use super::changed_run; + + fn run(old: &[u32], new: &[u32]) -> Option<(usize, usize, Vec)> { + changed_run(old.len(), new, |i, item| old[i] == *item) + .map(|(start, removed, added)| (start, removed, new[added].to_vec())) + } + + #[test] + fn equal_lists_change_nothing() { + assert_eq!(run(&[1, 2, 3], &[1, 2, 3]), None); + assert_eq!(run(&[], &[]), None); + } + + #[test] + fn only_the_differing_middle_is_replaced() { + assert_eq!(run(&[1, 2, 3, 4], &[1, 9, 4]), Some((1, 2, vec![9]))); + assert_eq!(run(&[1, 2, 3], &[1, 2, 5, 3]), Some((2, 0, vec![5]))); + assert_eq!(run(&[1, 2, 3], &[1, 3]), Some((1, 1, vec![]))); + } + + #[test] + fn growing_or_shrinking_at_either_end() { + assert_eq!(run(&[1, 2], &[0, 1, 2]), Some((0, 0, vec![0]))); + assert_eq!(run(&[1, 2], &[1, 2, 3]), Some((2, 0, vec![3]))); + assert_eq!(run(&[1, 2, 3], &[]), Some((0, 3, vec![]))); + assert_eq!(run(&[], &[1]), Some((0, 0, vec![1]))); + } + + #[test] + fn repeated_items_are_not_counted_twice() { + // The prefix takes both 1s; the suffix must not reuse them. + assert_eq!(run(&[1, 1], &[1, 1, 1]), Some((2, 0, vec![1]))); + assert_eq!(run(&[1, 1, 1], &[1, 1]), Some((2, 1, vec![]))); + } +} diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index 85692c3..1cd0737 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -212,6 +212,17 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { paused: false, failing: 0, }, + // The daemon is up but stuck: say so rather than "not connected", and + // keep the mount actions, which still work on the folder itself. + Err(e) if e.is_timeout() => DriveState { + line: gettext("Proton Drive isn't responding"), + phase: Phase::Attention, + mounted: true, + mountpoint: default_mountpoint.to_path_buf(), + sync: String::new(), + paused: false, + failing: 0, + }, // No daemon: describe login state instead so the menu is still useful. Err(_) => { let (line, phase) = match auth::load() { diff --git a/po/de.po b/po/de.po index 0c6b1fc..fa43a73 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-09-28 13:18+0200\n" +"POT-Creation-Date: 2026-09-28 13:42+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -30,7 +30,7 @@ msgstr[1] "vor {n} Min." #. Translators: strftime format for the time of day in the activity feed, such as "14:05". #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 -#: crates/pdfs-gui/src/app/pages/activity.rs:555 +#: crates/pdfs-gui/src/app/pages/activity.rs:702 #: crates/pdfs-gui/src/app/pages/photos.rs:2774 msgid "%H:%M" msgstr "%H:%M" @@ -57,105 +57,106 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:983 -#: crates/pdfs-gui/src/app/pages/browser.rs:505 -#: crates/pdfs-gui/src/app/pages/browser.rs:1063 -#: crates/pdfs-gui/src/app/pages/browser.rs:1596 -#: crates/pdfs-gui/src/app/pages/locations.rs:1308 +#: crates/pdfs-gui/src/app/main.rs:988 +#: crates/pdfs-gui/src/app/pages/browser.rs:509 +#: crates/pdfs-gui/src/app/pages/browser.rs:1067 +#: crates/pdfs-gui/src/app/pages/browser.rs:1600 +#: crates/pdfs-gui/src/app/pages/locations.rs:1315 #: crates/pdfs-gui/src/prompt.rs:169 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:986 crates/pdfs-gui/src/app/main.rs:1633 +#: crates/pdfs-gui/src/app/main.rs:991 crates/pdfs-gui/src/app/main.rs:1634 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:898 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:991 -#: crates/pdfs-gui/src/app/pages/shared.rs:115 +#: crates/pdfs-gui/src/app/main.rs:996 +#: crates/pdfs-gui/src/app/pages/shared.rs:119 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:996 -#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:63 +#: crates/pdfs-gui/src/app/main.rs:1001 +#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:64 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:999 -#: crates/pdfs-gui/src/app/pages/devices.rs:109 +#: crates/pdfs-gui/src/app/main.rs:1004 +#: crates/pdfs-gui/src/app/pages/devices.rs:110 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:1000 -#: crates/pdfs-gui/src/app/pages/trash.rs:106 +#: crates/pdfs-gui/src/app/main.rs:1005 +#: crates/pdfs-gui/src/app/pages/trash.rs:108 msgid "Trash" msgstr "Papierkorb" -#: crates/pdfs-gui/src/app/main.rs:1004 +#. Translators: sidebar entry for the page listing this computer's synced folders. +#: crates/pdfs-gui/src/app/main.rs:1007 msgid "Sync" msgstr "Synchronisierung" -#: crates/pdfs-gui/src/app/main.rs:1009 -#: crates/pdfs-gui/src/app/pages/activity.rs:153 +#: crates/pdfs-gui/src/app/main.rs:1010 +#: crates/pdfs-gui/src/app/pages/activity.rs:187 msgid "Activity" msgstr "Aktivität" -#: crates/pdfs-gui/src/app/main.rs:1155 +#: crates/pdfs-gui/src/app/main.rs:1156 msgid "Refresh (F5)" msgstr "Aktualisieren (F5)" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1329 +#: crates/pdfs-gui/src/app/main.rs:1330 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1348 +#: crates/pdfs-gui/src/app/main.rs:1349 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1349 +#: crates/pdfs-gui/src/app/main.rs:1350 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1350 +#: crates/pdfs-gui/src/app/main.rs:1351 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1351 +#: crates/pdfs-gui/src/app/main.rs:1352 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1352 +#: crates/pdfs-gui/src/app/main.rs:1353 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1393 crates/pdfs-gui/src/app/main.rs:1615 +#: crates/pdfs-gui/src/app/main.rs:1394 crates/pdfs-gui/src/app/main.rs:1616 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "Einstellungen" -#: crates/pdfs-gui/src/app/main.rs:1394 crates/pdfs-gui/src/app/main.rs:1672 +#: crates/pdfs-gui/src/app/main.rs:1395 crates/pdfs-gui/src/app/main.rs:1673 msgid "Keyboard Shortcuts" msgstr "Tastenkürzel" -#: crates/pdfs-gui/src/app/main.rs:1396 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "About Proton Drive for Linux" msgstr "Info zu Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1402 msgid "Main menu" msgstr "Hauptmenü" -#: crates/pdfs-gui/src/app/main.rs:1457 +#: crates/pdfs-gui/src/app/main.rs:1458 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1465 +#: crates/pdfs-gui/src/app/main.rs:1466 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -167,23 +168,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1470 +#: crates/pdfs-gui/src/app/main.rs:1471 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1474 +#: crates/pdfs-gui/src/app/main.rs:1475 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1478 +#: crates/pdfs-gui/src/app/main.rs:1479 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1483 +#: crates/pdfs-gui/src/app/main.rs:1484 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1487 +#: crates/pdfs-gui/src/app/main.rs:1488 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -208,7 +209,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1496 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1504 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -228,7 +229,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1512 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1504 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +226,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1512 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1504 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +227,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1512 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1504 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -227,7 +228,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1512 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1504 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +226,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1512 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1504 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +227,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1512 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -33,7 +33,7 @@ msgstr[1] "" #. Translators: strftime format for the time of day in the activity feed, such as "14:05". #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 -#: crates/pdfs-gui/src/app/pages/activity.rs:555 +#: crates/pdfs-gui/src/app/pages/activity.rs:702 #: crates/pdfs-gui/src/app/pages/photos.rs:2774 msgid "%H:%M" msgstr "" @@ -60,105 +60,106 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:983 -#: crates/pdfs-gui/src/app/pages/browser.rs:505 -#: crates/pdfs-gui/src/app/pages/browser.rs:1063 -#: crates/pdfs-gui/src/app/pages/browser.rs:1596 -#: crates/pdfs-gui/src/app/pages/locations.rs:1308 +#: crates/pdfs-gui/src/app/main.rs:988 +#: crates/pdfs-gui/src/app/pages/browser.rs:509 +#: crates/pdfs-gui/src/app/pages/browser.rs:1067 +#: crates/pdfs-gui/src/app/pages/browser.rs:1600 +#: crates/pdfs-gui/src/app/pages/locations.rs:1315 #: crates/pdfs-gui/src/prompt.rs:169 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:986 crates/pdfs-gui/src/app/main.rs:1633 +#: crates/pdfs-gui/src/app/main.rs:991 crates/pdfs-gui/src/app/main.rs:1634 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:898 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:991 -#: crates/pdfs-gui/src/app/pages/shared.rs:115 +#: crates/pdfs-gui/src/app/main.rs:996 +#: crates/pdfs-gui/src/app/pages/shared.rs:119 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:996 -#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:63 +#: crates/pdfs-gui/src/app/main.rs:1001 +#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:64 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:999 -#: crates/pdfs-gui/src/app/pages/devices.rs:109 +#: crates/pdfs-gui/src/app/main.rs:1004 +#: crates/pdfs-gui/src/app/pages/devices.rs:110 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1000 -#: crates/pdfs-gui/src/app/pages/trash.rs:106 +#: crates/pdfs-gui/src/app/main.rs:1005 +#: crates/pdfs-gui/src/app/pages/trash.rs:108 msgid "Trash" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1004 +#. Translators: sidebar entry for the page listing this computer's synced folders. +#: crates/pdfs-gui/src/app/main.rs:1007 msgid "Sync" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1009 -#: crates/pdfs-gui/src/app/pages/activity.rs:153 +#: crates/pdfs-gui/src/app/main.rs:1010 +#: crates/pdfs-gui/src/app/pages/activity.rs:187 msgid "Activity" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1155 +#: crates/pdfs-gui/src/app/main.rs:1156 msgid "Refresh (F5)" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1329 +#: crates/pdfs-gui/src/app/main.rs:1330 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1348 +#: crates/pdfs-gui/src/app/main.rs:1349 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1349 +#: crates/pdfs-gui/src/app/main.rs:1350 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1350 +#: crates/pdfs-gui/src/app/main.rs:1351 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1351 +#: crates/pdfs-gui/src/app/main.rs:1352 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1352 +#: crates/pdfs-gui/src/app/main.rs:1353 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1393 crates/pdfs-gui/src/app/main.rs:1615 +#: crates/pdfs-gui/src/app/main.rs:1394 crates/pdfs-gui/src/app/main.rs:1616 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1394 crates/pdfs-gui/src/app/main.rs:1672 +#: crates/pdfs-gui/src/app/main.rs:1395 crates/pdfs-gui/src/app/main.rs:1673 msgid "Keyboard Shortcuts" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1396 +#: crates/pdfs-gui/src/app/main.rs:1397 msgid "About Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1401 +#: crates/pdfs-gui/src/app/main.rs:1402 msgid "Main menu" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1457 +#: crates/pdfs-gui/src/app/main.rs:1458 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1465 +#: crates/pdfs-gui/src/app/main.rs:1466 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -166,23 +167,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1470 +#: crates/pdfs-gui/src/app/main.rs:1471 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1474 +#: crates/pdfs-gui/src/app/main.rs:1475 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1478 +#: crates/pdfs-gui/src/app/main.rs:1479 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1483 +#: crates/pdfs-gui/src/app/main.rs:1484 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1487 +#: crates/pdfs-gui/src/app/main.rs:1488 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -195,21 +196,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1495 +#: crates/pdfs-gui/src/app/main.rs:1496 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1504 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1512 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1504 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -229,7 +230,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1512 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1503 +#: crates/pdfs-gui/src/app/main.rs:1504 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +226,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1512 msgid "" "The street map, when turned on, comes from OpenFreeMap in the