From 127b71efd0943b6896368e2bc4ffe45cac5c7cb1 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 28 Sep 2026 14:35:37 +0200 Subject: [PATCH] feat(core,fuse,gui): rebuild Trash and shared pages on one file list The list and grid view from My files moves into widgets/file_list.rs as FileList, so Trash, Shared with me and Shared by me share its columns, selection, context menus, keyboard handling and list/grid switch instead of each drawing its own rows. Trash shows where each item came from and when it was deleted. Proton does not expose the trash time through the SDK yet, so the daemon records when it first sees a node in the trash (migration V35, trash_seen) and reports that; the date becomes exact once proton-sdk-rs exposes Node::trash_time. Restoring offers a Show action, and opening a trashed item explains that it needs to be restored first. Shared with me splits files and saved links into two views. Files show who shared them, with a warning pill for unverified senders, the access they give and when they were shared. Pending invitations sit in a banner that opens a dialog to accept or decline each one. Several items can be left at once. Saved links get their own list with open, copy and remove actions. Shared by me gains a filter (all, links, people, expired links) and columns for location, people and link state, with a copy button for live links. Several items can stop being shared at once, the page refreshes after the share dialog closes, and a reload rebinds only the rows that changed so the selection survives. All catalogs are updated and complete. --- crates/pdfs-cli/src/main.rs | 2 + crates/pdfs-core/src/control.rs | 17 +- crates/pdfs-core/src/db/migrations.rs | 28 +- crates/pdfs-core/src/db/tests.rs | 11 + crates/pdfs-core/src/db/trash.rs | 29 +- crates/pdfs-fuse/src/lib.rs | 67 +- crates/pdfs-fuse/src/sharing.rs | 2 + crates/pdfs-gui/resources/style.css | 1 + crates/pdfs-gui/src/app/main.rs | 104 +- crates/pdfs-gui/src/app/pages/browser.rs | 510 +----- crates/pdfs-gui/src/app/pages/shared.rs | 1158 +++++++------ crates/pdfs-gui/src/app/pages/shared_by_me.rs | 647 ++++++-- crates/pdfs-gui/src/app/pages/status.rs | 6 +- crates/pdfs-gui/src/app/pages/trash.rs | 412 +++-- crates/pdfs-gui/src/app/widgets/file_list.rs | 522 ++++++ crates/pdfs-gui/src/app/widgets/mod.rs | 1 + .../pdfs-gui/src/app/widgets/share_dialog.rs | 9 + crates/pdfs-gui/src/app/widgets/thumbnails.rs | 13 - po/POTFILES.in | 1 + po/de.po | 1406 +++++++++------- po/en_GB.po | 1435 ++++++++++------- po/es.po | 1406 +++++++++------- po/fr.po | 1408 +++++++++------- po/it.po | 1406 +++++++++------- po/nl.po | 1418 +++++++++------- po/pdfs.pot | 1319 ++++++++------- po/pl.po | 1419 +++++++++------- po/pt_BR.po | 1417 +++++++++------- 28 files changed, 9501 insertions(+), 6673 deletions(-) create mode 100644 crates/pdfs-gui/src/app/widgets/file_list.rs diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 01db02b..d2c367e 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -3364,6 +3364,8 @@ mod login_wait_tests { shared_by: String::new(), shared_at: 0, shared_by_unverified: false, + trashed_at: 0, + trashed_from: None, }; assert_eq!(shared_by_line(&entry), None, "no invitation, no line"); entry.shared_by = "alice@proton.me".into(); diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 862ccf7..6d7ff08 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -905,7 +905,7 @@ pub struct ShareEntry { } /// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]). -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct PublicLinkInfo { /// Public-link id — the handle for [`Request::RemovePublicLink`]. pub id: String, @@ -918,6 +918,9 @@ pub struct PublicLinkInfo { pub expires: Option, /// Whether a custom password additionally protects the link. pub has_password: bool, + /// When the link was made, epoch seconds; 0 when unknown. + #[serde(default)] + pub created: i64, } /// One invitation addressed to me (in [`Response::Invitations`]). @@ -950,7 +953,7 @@ pub struct BookmarkInfo { /// the share's state so the "Shared" view renders in one pass: how many people /// have access, how many invitations are still pending, and the public link if /// the node has one. -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct SharedItem { /// Node uid in `volume~link` form — the handle for opening the node's share /// (via its mount path) or its details. @@ -1138,6 +1141,16 @@ pub struct DirEntry { /// warning worth showing. #[serde(default)] pub shared_by_unverified: bool, + /// When a [`Request::ListTrash`] entry went to the trash, epoch seconds; 0 + /// when unknown, and for anything not in the trash. The API does not report + /// it to this client, so the daemon notes when it first saw the node there. + #[serde(default)] + pub trashed_at: i64, + /// The folder a [`Request::ListTrash`] entry goes back to on restore, as a + /// mountpoint-relative path (`""` for the root). `None` when that folder is + /// not known, and for anything not in the trash. + #[serde(default)] + pub trashed_from: Option, } /// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index 4ca2fb6..6d073ce 100644 --- a/crates/pdfs-core/src/db/migrations.rs +++ b/crates/pdfs-core/src/db/migrations.rs @@ -9,7 +9,7 @@ use super::Db; use crate::Result; /// Current schema version. Bump on every forward migration added below. -pub(super) const SCHEMA_VERSION: i64 = 34; +pub(super) const SCHEMA_VERSION: i64 = 35; impl Db { pub(super) fn migrate(&self) -> Result<()> { @@ -316,6 +316,22 @@ impl Db { tx.execute_batch(MIGRATION_V34)?; } } + if current < 35 { + // Same guards as V26-V34. + let has_column: bool = tx.query_row( + "SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'trashed_at'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + let has_trash: bool = tx.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + if has_trash && !has_column { + tx.execute_batch(MIGRATION_V35)?; + } + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -1033,3 +1049,13 @@ UPDATE photos SET resolved_at = NULL; const MIGRATION_V34: &str = " ALTER TABLE photos ADD COLUMN similar_hash INTEGER; "; + +/// Schema v35: when each trashed node went to the trash. +/// +/// The API does not tell this client, so `trashed_at` is when the daemon first +/// saw the node in the trash, in epoch seconds. `NULL` when it cannot say: rows +/// from before this version, and nodes found by a refresh that did not follow a +/// complete one, which may have been in the trash for a long time already. +const MIGRATION_V35: &str = " +ALTER TABLE trash ADD COLUMN trashed_at INTEGER; +"; diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index e6af0e3..0e8e04b 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -583,6 +583,7 @@ fn trash_replace_lists_folders_first() { size: 10, mtime: 1, parent_uid: Some("t2".into()), + trashed_at: Some(100), }, StoredTrash { uid: "t2".into(), @@ -591,6 +592,7 @@ fn trash_replace_lists_folders_first() { size: 0, mtime: 2, parent_uid: None, + trashed_at: None, }, ]) .unwrap(); @@ -613,6 +615,15 @@ fn trash_replace_lists_folders_first() { ] ); + // The first-seen times round-trip, unknown ones included. + let mut seen: Vec<_> = db.trash_seen().unwrap().into_iter().collect(); + seen.sort(); + assert_eq!( + seen, + [("t1".to_string(), Some(100)), ("t2".to_string(), None)] + ); + assert_eq!(items[1].trashed_at, Some(100)); + // A replace is a replace: emptying the trash on the server empties it here. db.trash_replace(&[]).unwrap(); assert!(db.trash_list().unwrap().is_empty()); diff --git a/crates/pdfs-core/src/db/trash.rs b/crates/pdfs-core/src/db/trash.rs index f7c90dc..f7a25e3 100644 --- a/crates/pdfs-core/src/db/trash.rs +++ b/crates/pdfs-core/src/db/trash.rs @@ -1,5 +1,7 @@ //! Trashed nodes, persisted so the trash view opens without a round-trip. +use std::collections::HashMap; + use rusqlite::params; use super::Db; @@ -15,6 +17,9 @@ pub struct StoredTrash { /// row written before schema v29, and for a node whose parent the server /// did not report. pub parent_uid: Option, + /// When the daemon first saw the node in the trash, epoch seconds. `None` + /// when it cannot say (see schema v35). + pub trashed_at: Option, } impl Db { @@ -24,8 +29,8 @@ impl Db { tx.execute("DELETE FROM trash", [])?; { let mut stmt = tx.prepare( - "INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", )?; for item in items { stmt.execute(params![ @@ -34,7 +39,8 @@ impl Db { item.is_dir as i64, item.size, item.mtime, - item.parent_uid + item.parent_uid, + item.trashed_at ])?; } } @@ -47,7 +53,7 @@ impl Db { pub fn trash_list(&self) -> Result> { let conn = self.read(); let mut stmt = conn.prepare( - "SELECT uid, name, is_dir, size, mtime, parent_uid FROM trash + "SELECT uid, name, is_dir, size, mtime, parent_uid, trashed_at FROM trash ORDER BY is_dir DESC, name COLLATE NOCASE", )?; let rows = stmt.query_map([], |r| { @@ -58,6 +64,7 @@ impl Db { size: r.get(3)?, mtime: r.get(4)?, parent_uid: r.get(5)?, + trashed_at: r.get(6)?, }) })?; let mut items = Vec::new(); @@ -81,5 +88,19 @@ impl Db { Ok(pairs) } + /// When each node now in the trash was first seen there, keyed by uid, so + /// a refresh can carry the times over into the listing it writes. + pub fn trash_seen(&self) -> Result>> { + let conn = self.read(); + let mut stmt = conn.prepare("SELECT uid, trashed_at FROM trash")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + let mut seen = HashMap::new(); + for row in rows { + let (uid, at) = row?; + seen.insert(uid, at); + } + Ok(seen) + } + // ---- device sync (devices.md) ----------------------------------------- } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 32b3d62..ae62d0b 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -271,6 +271,12 @@ const PHOTOS_AVAILABLE: &str = "photos_available"; const ALBUMS_SYNCED_MS: &str = "albums_synced_ms"; const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:"; const TRASH_SYNCED_MS: &str = "trash_synced_ms"; +/// Set when a trash refresh finishes and cleared when one starts, so it is only +/// present while the stored listing is the whole trash as of some moment. A node +/// that a refresh finds and that listing lacks went to the trash since then; one +/// found after an interrupted refresh may have been there all along. Unlike +/// [`TRASH_SYNCED_MS`], our own mutations leave it alone. +const TRASH_COMPLETE_MS: &str = "trash_complete_ms"; const SHARED_WITH_ME_NAME: &str = "shared_with_me_name"; const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms"; const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:"; @@ -3815,6 +3821,8 @@ impl Core { shared_by: String::new(), shared_at: 0, shared_by_unverified: false, + trashed_at: 0, + trashed_from: None, }) .collect()) } @@ -4406,12 +4414,21 @@ impl Core { self.await_trash_refresh(TRASH_FIRST_WAIT); } + // Most of a trash comes from a handful of folders. + let mut origins: HashMap> = HashMap::new(); Ok(self .db .trash_list() .map_err(CoreError::from)? .into_iter() .map(|item| DirEntry { + trashed_from: item.parent_uid.as_ref().and_then(|parent| { + origins + .entry(parent.clone()) + .or_insert_with(|| self.db.node_path(parent).ok().flatten()) + .clone() + }), + trashed_at: item.trashed_at.unwrap_or(0), name: item.name, is_dir: item.is_dir, size: item.size.max(0) as u64, @@ -4450,6 +4467,18 @@ impl Core { "trash refresh: enumerated" ); + // What the listing held before, and whether it was the whole trash: the + // chunks below overwrite it, so both are read up front. + let seen = self.db.trash_seen().unwrap_or_default(); + let complete = self + .db + .state_i64(TRASH_COMPLETE_MS) + .ok() + .flatten() + .is_some(); + let _ = self.db.clear_state(TRASH_COMPLETE_MS); + let now = now_secs(); + if uids.is_empty() { self.db.trash_replace(&[]).map_err(CoreError::from)?; } @@ -4470,6 +4499,7 @@ impl Core { // The folder the node will go back to, which is also what tells // a restore whether that folder is itself in the trash. parent_uid: node.parent_uid.as_ref().map(|uid| uid.to_string()), + trashed_at: first_seen_in_trash(&seen, &node.uid.to_string(), complete, now), })); // Cumulative, so the table is always a prefix of the real trash // rather than a mix of this refresh and the last one. @@ -4484,6 +4514,7 @@ impl Core { } let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms()); + let _ = self.db.set_state_i64(TRASH_COMPLETE_MS, now_ms()); info!( count = items.len(), elapsed_ms = started.elapsed().as_millis() as u64, @@ -4997,6 +5028,7 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo { role: role_to_str(link.role).to_string(), expires: link.expiration_time, has_password: link.has_custom_password, + created: link.creation_time, } } @@ -5429,6 +5461,25 @@ fn node_size(node: &Node) -> u64 { } } +/// When a node a trash refresh found went to the trash, as far as the daemon +/// can tell. A node the stored listing already had keeps the time it had. A new +/// one went there since the last refresh, so now is close, but only when that +/// refresh saw the whole trash (`complete`): after a first or interrupted one, +/// a node missing from the listing may be years old, and no date beats a wrong +/// one. +fn first_seen_in_trash( + seen: &HashMap>, + uid: &str, + complete: bool, + now: i64, +) -> Option { + match seen.get(uid) { + Some(at) => *at, + None if complete => Some(now), + None => None, + } +} + /// Expand a restore request across the trashed tree, and order it so a parent is /// always restored before its children. /// @@ -6220,8 +6271,8 @@ mod tests { Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot, SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan, accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore, - fuse_name, is_stale_mount, node_visible, note_self_change, orphan_past_retention, - parse_node_uid, prepare_shared_roots, preserve_on_access_denied, + first_seen_in_trash, fuse_name, is_stale_mount, node_visible, note_self_change, + orphan_past_retention, parse_node_uid, prepare_shared_roots, preserve_on_access_denied, publish_virtual_root_in_listing, reconcile_virtual_root_in_listing, release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue, require_node_parent_access, require_rename_access, @@ -6364,6 +6415,18 @@ mod tests { ); } + /// A node keeps the time it was first seen in the trash; a new one is dated + /// now only when the previous refresh saw the whole trash. + #[test] + fn trash_dates_are_kept_and_only_guessed_after_a_complete_refresh() { + let seen = HashMap::from([("old".to_string(), Some(5)), ("unknown".to_string(), None)]); + assert_eq!(first_seen_in_trash(&seen, "old", true, 9), Some(5)); + assert_eq!(first_seen_in_trash(&seen, "old", false, 9), Some(5)); + assert_eq!(first_seen_in_trash(&seen, "unknown", true, 9), None); + assert_eq!(first_seen_in_trash(&seen, "new", true, 9), Some(9)); + assert_eq!(first_seen_in_trash(&seen, "new", false, 9), None); + } + /// A parent chain that loops back on itself must terminate. #[test] fn a_cyclic_listing_terminates() { diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index ee9b21a..f058f45 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -73,6 +73,8 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry { shared_by, shared_at, shared_by_unverified, + trashed_at: 0, + trashed_from: None, } } diff --git a/crates/pdfs-gui/resources/style.css b/crates/pdfs-gui/resources/style.css index 903d1f6..a4c9e38 100644 --- a/crates/pdfs-gui/resources/style.css +++ b/crates/pdfs-gui/resources/style.css @@ -92,6 +92,7 @@ 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); } +.role-pill.warning-pill { color: var(--warning-color); background-color: color-mix(in srgb, var(--warning-bg-color) 20%, 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. */ diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 5672a92..5b72a98 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -26,6 +26,7 @@ use pages::trash::*; use pages::verify::*; use reload::{LoadTicket, Loader, replace_items}; use widgets::details::*; +use widgets::file_list::*; use widgets::menu::*; use widgets::share_dialog::*; use widgets::thumbnails::*; @@ -563,7 +564,7 @@ fn build_window(app: &adw::Application) { transfer_batch: RefCell::new(None), }, browser: BrowserState { - model: browser_widgets.model.clone(), + files: browser_widgets.files.clone(), history: RefCell::new(Vec::new()), future: RefCell::new(Vec::new()), crumb: browser_widgets.crumb.clone(), @@ -588,8 +589,6 @@ fn build_window(app: &adw::Application) { search_source: RefCell::new(None), 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(), bulk_label: browser_widgets.bulk_label.clone(), bulk_trash: browser_widgets.bulk_trash.clone(), @@ -599,23 +598,20 @@ fn build_window(app: &adw::Application) { empty_actions: browser_widgets.empty_actions.clone(), summary: browser_widgets.summary.clone(), zoom: browser_widgets.zoom.clone(), - grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT), - grid_tiles: RefCell::new(Vec::new()), }, details: DetailsState { details: browser_widgets.details, details_entry: RefCell::new(None), details_suppress: Cell::new(false), - selection: browser_widgets.selection.clone(), + selection: browser_widgets.files.selection.clone(), }, trash: TrashState { - model: trash_widgets.model.clone(), + files: trash_widgets.files.clone(), content: trash_widgets.content.clone(), status: trash_widgets.status.clone(), retry: trash_widgets.retry.clone(), empty: trash_widgets.empty.clone(), subtitle: trash_widgets.subtitle.clone(), - 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), @@ -708,31 +704,8 @@ fn build_window(app: &adw::Application) { select_trash: gallery_widgets.select_trash.clone(), select_album: gallery_widgets.select_album.clone(), }, - shared: SharedState { - content: shared_widgets.content.clone(), - status: shared_widgets.status.clone(), - retry: shared_widgets.retry.clone(), - with_me_group: shared_widgets.shared_with_me.clone(), - invitations_group: shared_widgets.invitations.clone(), - bookmarks_group: shared_widgets.bookmarks.clone(), - title: shared_widgets.title.clone(), - back: shared_widgets.back.clone(), - add_bookmark: shared_widgets.add_bookmark.clone(), - nav: RefCell::new(Vec::new()), - rows: RefCell::new(Vec::new()), - loader: Loader::new(&shared_widgets.content), - listed: RefCell::new(None), - loaded_at: Cell::new(None), - }, - shared_by_me: SharedByMeState { - content: shared_by_me_widgets.content.clone(), - status: shared_by_me_widgets.status.clone(), - retry: shared_by_me_widgets.retry.clone(), - group: shared_by_me_widgets.group.clone(), - rows: RefCell::new(Vec::new()), - loader: Loader::new(&shared_by_me_widgets.content), - loaded_at: Cell::new(None), - }, + 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(), @@ -820,7 +793,7 @@ fn build_window(app: &adw::Application) { &main_widgets.mountpoint_button, ); wire_sidebar(&ui); - wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view); + wire_browser(&ui); wire_bulk( &ui, &browser_widgets.bulk_clear, @@ -844,8 +817,8 @@ fn build_window(app: &adw::Application) { wire_albums(&ui); wire_places(&ui); wire_trash(&ui, &trash_widgets); - wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); - wire_shared_by_me(&ui, &shared_by_me_widgets.retry); + wire_shared(&ui, &shared_widgets); + wire_shared_by_me(&ui, &shared_by_me_widgets); wire_devices(&ui, &devices_widgets.retry); wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder); wire_activity(&ui, &activity_widgets); @@ -1094,11 +1067,21 @@ fn page_frame( content: &impl IsA, ) -> (adw::ToolbarView, adw::HeaderBar, adw::WindowTitle) { let window_title = adw::WindowTitle::new(title, ""); + let (toolbar, header) = page_frame_with(&window_title, content); + (toolbar, header, window_title) +} + +/// [`page_frame`] with `title` in the header's title slot instead of a plain +/// title, such as a view switcher. +fn page_frame_with( + title: &impl IsA, + content: &impl IsA, +) -> (adw::ToolbarView, adw::HeaderBar) { let spinner = gtk4::Spinner::new(); spinner.set_visible(false); BUSY_SPINNERS.with(|spinners| spinners.borrow_mut().push(spinner.clone())); let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - title_box.append(&window_title); + title_box.append(title); title_box.append(&spinner); let header = adw::HeaderBar::new(); @@ -1106,7 +1089,7 @@ fn page_frame( let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); toolbar.set_content(Some(content)); - (toolbar, header, window_title) + (toolbar, header) } /// The sidebar destination a page belongs under. Most pages are their own @@ -1693,6 +1676,7 @@ fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) { let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK); let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK); let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser"); + let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash"); match key { // Refresh works on every page, so it is matched before the // browser-only bindings. @@ -1708,20 +1692,18 @@ fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) { ui.browser.search.grab_focus(); } gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui), - gtk4::gdk::Key::_1 if ctrl && on_browser => set_files_view( + gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view( &ui, FilesView { - list: false, - ..ui.browser.view.get() - }, - ), - gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view( - &ui, - FilesView { - list: true, + list: key == gtk4::gdk::Key::_2, ..ui.browser.view.get() }, ), + gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && visible_file_list(&ui).is_some() => { + if let Some(list) = visible_file_list(&ui) { + list.show_list(key == gtk4::gdk::Key::_2); + } + } gtk4::gdk::Key::Left if alt && on_browser => { ui.browser.actions.activate_action("back", None) } @@ -1737,14 +1719,21 @@ fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) { ui.browser.actions.activate_action("back", None) } gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui), - gtk4::gdk::Key::Menu if on_browser => popup_keyboard_context_menu(&ui), + gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => { + if let Some(list) = visible_file_list(&ui) { + list.popup_keyboard_menu(&ui); + } + } gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter if alt && on_browser => { toggle_details(&ui) } gtk4::gdk::Key::F10 - if on_browser && state.contains(gtk4::gdk::ModifierType::SHIFT_MASK) => + if visible_file_list(&ui).is_some() + && state.contains(gtk4::gdk::ModifierType::SHIFT_MASK) => { - popup_keyboard_context_menu(&ui) + if let Some(list) = visible_file_list(&ui) { + list.popup_keyboard_menu(&ui); + } } gtk4::gdk::Key::F2 if on_browser => { // Renaming is one name at a time; saying so beats a key that @@ -1764,6 +1753,12 @@ fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) { trash_entries(&ui, entries); } } + gtk4::gdk::Key::Delete if on_trash => { + let entries = ui.trash.files.selected(); + if !entries.is_empty() { + prompt_delete_forever(&ui, &entries); + } + } gtk4::gdk::Key::Escape if on_browser && (ui.browser.split.shows_sidebar() || ui.browser.bulk.reveals_child()) => @@ -1773,6 +1768,15 @@ fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) { clear_details(&ui); ui.details.details.toggle.set_active(false); } + gtk4::gdk::Key::Escape + if !on_browser + && visible_file_list(&ui) + .is_some_and(|list| list.selection.selection().size() > 0) => + { + if let Some(list) = visible_file_list(&ui) { + list.selection.unselect_all(); + } + } _ => return glib::Propagation::Proceed, } glib::Propagation::Stop diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 7a4019b..be5009d 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -3,8 +3,8 @@ use crate::*; pub(crate) struct BrowserState { // Files (browser) page. - /// Shared model behind the grid and column views; repopulated per directory. - pub(crate) model: gio::ListStore, + /// The grid and list; the model is repopulated per directory. + pub(crate) files: FileList, /// Folders left by navigating, newest last, and folders left by going back: /// the `files.back` and `files.forward` history. Up is Alt+Up or a /// breadcrumb. @@ -60,10 +60,6 @@ pub(crate) struct BrowserState { /// 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. - pub(crate) column_view: gtk4::ColumnView, /// The bulk-action bar, revealed once more than one entry is selected. pub(crate) bulk: gtk4::Revealer, pub(crate) bulk_label: gtk4::Label, @@ -81,10 +77,6 @@ pub(crate) struct BrowserState { /// zoom value, and Proton account storage usage. pub(crate) summary: gtk4::Label, pub(crate) zoom: gtk4::Scale, - pub(crate) grid_thumbnail_size: Cell, - /// Weak references to realised grid cells. Zoom resizes only these visible, - /// recycled surfaces instead of invalidating the whole list model. - pub(crate) grid_tiles: RefCell, glib::WeakRef)>>, } /// Idle pause after the last keystroke before a search query is sent, so typing @@ -102,11 +94,8 @@ pub(crate) const GRID_THUMB_DEFAULT: i32 = 72; pub(crate) const GRID_THUMB_STEP: i32 = 8; /// The Files page: a Nautilus-style file manager. A back/breadcrumb header with -/// a grid/list view toggle sits over a [`gtk4::Stack`] that swaps between an -/// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`] -/// with Name / Size / Modified columns). Both views are driven by one shared -/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`DirEntry`]s, so a directory -/// load repopulates the model once and both views update. +/// a grid/list view toggle sits over a [`FileList`] with Name / Size / Modified +/// columns, so a directory load repopulates one model and both views update. /// /// The factories that render entries — and the columns — need the [`Ui`] handle /// for activation and the right-click menu, so they're installed later in @@ -117,10 +106,8 @@ pub(crate) const GRID_THUMB_STEP: i32 = 8; /// so "this folder is empty" and "the mount is down" read as first-class states /// rather than a stray line above a blank grid. pub(crate) struct BrowserWidgets { - pub(crate) model: gio::ListStore, + pub(crate) files: FileList, pub(crate) crumb: gtk4::Box, - pub(crate) grid: gtk4::GridView, - pub(crate) column_view: gtk4::ColumnView, /// Swaps the content area between the grid/list views and the status page. pub(crate) content: gtk4::Stack, /// The empty/loading/error surface shown in place of the views. @@ -141,11 +128,6 @@ pub(crate) struct BrowserWidgets { /// Wraps the views + the details pane; the pane shows the selection while its header toggle is on. pub(crate) split: adw::OverlaySplitView, pub(crate) details: DetailsWidgets, - /// The selection shared by both views, so a selection change can drive the - /// details pane and an action can re-read the entries the user highlighted. - pub(crate) selection: gtk4::MultiSelection, - /// The grid/list stack, so the page can tell which view is on screen. - pub(crate) views: gtk4::Stack, pub(crate) bulk: gtk4::Revealer, pub(crate) bulk_label: gtk4::Label, pub(crate) bulk_trash: gtk4::Button, @@ -159,8 +141,6 @@ pub(crate) struct BrowserWidgets { } pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { - let model = gio::ListStore::new::(); - let back = gtk4::Button::builder() .icon_name("go-previous-symbolic") .tooltip_text(gettext("Back (Alt+Left)")) @@ -333,40 +313,8 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { .build(); status.add_css_class("compact"); - // Icon grid. Multi-select: acting on a batch is the common case for trashing - // and for taking a folder's worth of files offline, and doing it one - // confirmation dialog at a time is not a workflow. - // One selection for both views: switching layout keeps what is selected. - // Rubberband drags a selection box from empty space, as a file manager does. - let selection = gtk4::MultiSelection::new(Some(model.clone())); - let grid = gtk4::GridView::builder() - .model(&selection) - .min_columns(2) - .max_columns(32) - .enable_rubberband(true) - .build(); - grid.add_css_class("file-grid"); - let grid_scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&grid) - .build(); - - // Column list, over the same selection. - let column_view = gtk4::ColumnView::builder() - .model(&selection) - .enable_rubberband(true) - .build(); - column_view.add_css_class("data-table"); - let column_scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&column_view) - .build(); - - // Stack swapped by the `files.view` action. - let view_stack = gtk4::Stack::new(); - view_stack.set_vexpand(true); - view_stack.add_named(&grid_scroll, Some("grid")); - view_stack.add_named(&column_scroll, Some("list")); + let files = FileList::new(); + let view_stack = files.views.clone(); // Outer stack: the views, or the status page when there's nothing to show. let content = gtk4::Stack::new(); @@ -520,10 +468,8 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { ( frame.upcast(), BrowserWidgets { - model, + files, crumb, - grid, - column_view, content, status, retry, @@ -540,8 +486,6 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { refresh, split, details, - selection, - views: view_stack, bulk, bulk_label, bulk_trash, @@ -570,28 +514,14 @@ pub(crate) fn browser_status(ui: &Rc, icon: &str, title: &str, description: clear_details(ui); } -/// The selection model shared by the grid and the list. -pub(crate) fn active_selection(ui: &Rc) -> gtk4::MultiSelection { - ui.details.selection.clone() -} - -/// Every entry highlighted in the view on screen, in model order. -/// -/// Walks the model rather than the selection bitset: a listing is at most a few -/// thousand rows, and asking each position whether it is selected keeps this -/// free of bitset-iterator lifetimes for no measurable cost. +/// Every entry highlighted in My files, in model order. pub(crate) fn selected_entries(ui: &Rc) -> Vec { - let selection = active_selection(ui); - let count = selection.n_items(); - (0..count) - .filter(|i| selection.is_selected(*i)) - .filter_map(|i| entry_at(Some(&selection), i)) - .collect() + ui.browser.files.selected() } /// Drop the selection, which also retracts the bulk bar. pub(crate) fn clear_selection(ui: &Rc) { - ui.details.selection.unselect_all(); + ui.browser.files.selection.unselect_all(); } /// Reflect the current selection in the bulk bar: how many are selected, and @@ -822,13 +752,6 @@ pub(crate) fn wire_bulk( sync_bulk_bar(&ui_clear); }); - // Switching views switches which selection is live, so the bar has to be - // re-derived rather than left showing the other view's count. - let ui_view = ui.clone(); - ui.browser - .views - .connect_visible_child_name_notify(move |_| sync_bulk_bar(&ui_view)); - let ui_upload = ui.clone(); empty_upload.connect_clicked(move |_| prompt_upload(&ui_upload)); let ui_new = ui.clone(); @@ -840,21 +763,27 @@ pub(crate) fn browser_views(ui: &Rc) { ui.browser.content.set_visible_child_name("views"); } -/// Install the entry factories, columns, activation handlers and the back -/// button. Split out from [`build_browser_page`] because every renderer needs +/// Install the entry factories, columns, activation handlers and the zoom +/// slider. Split out from [`build_browser_page`] because every renderer needs /// the [`Ui`] handle to open entries and raise the context menu. -pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k4::ColumnView) { - attach_background_menu(ui, grid); - attach_background_menu(ui, column_view); - attach_background_deselect(ui, grid); - attach_background_deselect(ui, column_view); +pub(crate) fn wire_browser(ui: &Rc) { + let files = &ui.browser.files; + files.wire( + ui, + FileListBehavior { + activate: activate_entry, + entry_menu: entry_context_menu, + bulk_menu: bulk_context_menu, + background_menu: background_context_menu, + badges: true, + drag_and_drop: true, + }, + ); // Resize only realised grid cells. Rebuilding the whole model for every // slider step would repeatedly tear down selection state while the pointer // is still moving. - let zoom = ui.browser.zoom.clone(); let ui_zoom = ui.clone(); - let grid_zoom = grid.clone(); - zoom.connect_value_changed(move |scale| { + ui.browser.zoom.connect_value_changed(move |scale| { let size = (scale.value().round() as i32).clamp(GRID_THUMB_MIN, GRID_THUMB_MAX); scale.set_tooltip_text(Some(&ngettext_f( "Size: {n} pixel", @@ -862,199 +791,27 @@ pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k size as u64, &[], ))); - if ui_zoom.browser.grid_thumbnail_size.replace(size) == size { - return; - } - ui_zoom - .browser - .grid_tiles - .borrow_mut() - .retain(|(thumbnail_ref, label_ref)| { - let (Some(thumbnail), Some(label)) = (thumbnail_ref.upgrade(), label_ref.upgrade()) - else { - return false; - }; - resize_grid_tile(&thumbnail, &label, size); - true - }); - grid_zoom.queue_resize(); + ui_zoom.browser.files.set_thumbnail_size(size); }); - // Grid tiles: a thumbnail over an ellipsized name, with a right-click menu. - let factory = gtk4::SignalListItemFactory::new(); - factory.connect_setup({ - let ui = ui.clone(); - move |_, item| { - let item = item.downcast_ref::().unwrap(); - let size = ui.browser.grid_thumbnail_size.get(); - let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size)); - // Keep the sync-state badge inside the thumbnail surface. - let badge = gtk4::Image::builder() - .pixel_size(18) - .halign(gtk4::Align::End) - .valign(gtk4::Align::Start) - .margin_top(2) - .margin_end(2) - .build(); - badge.add_css_class("file-badge"); - thumbnail.add_overlay(&badge); - // `WordChar` rather than the default `Word`: a name with no spaces - // offers no word-break opportunity, so word wrapping cannot break it - // at all and the label asks for its full natural width instead — - // one tile stretches to the width of the window and the grid - // collapses to a single column. Allowing a mid-word break is what - // keeps the two-line-then-ellipsis budget below enforceable for - // *every* name rather than only the ones that happen to have spaces. - let label = gtk4::Label::builder() - .ellipsize(gtk4::pango::EllipsizeMode::End) - .justify(gtk4::Justification::Center) - .max_width_chars(13) - .width_chars(13) - .wrap(true) - .wrap_mode(gtk4::pango::WrapMode::WordChar) - .lines(2) - .build(); - ui.browser - .grid_tiles - .borrow_mut() - .push((thumbnail.downgrade(), label.downgrade())); - let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - tile.add_css_class("file-tile"); - tile.append(&thumbnail); - tile.append(&label); - attach_context_menu(&ui, item, &tile); - attach_drag(&ui, item, &tile); - attach_drop(&ui, item, &tile); - item.set_child(Some(&tile)); - } - }); - factory.connect_bind({ - let ui = ui.clone(); - move |_, item| { - let item = item.downcast_ref::().unwrap(); - let tile = item.child().and_downcast::().unwrap(); - let thumbnail = tile.first_child().and_downcast::().unwrap(); - let badge = thumbnail - .last_child() - .and_downcast::() - .unwrap(); - let label = thumbnail - .next_sibling() - .and_downcast::() - .unwrap(); - let obj = item.item().and_downcast::().unwrap(); - let entry = obj.borrow::(); - let size = ui.browser.grid_thumbnail_size.get(); - resize_grid_tile(&thumbnail, &label, size); - bind_file_thumbnail(&ui, &thumbnail, &entry, false); - label.set_label(&entry.name); - label.set_tooltip_text(Some(&entry.name)); - apply_badge(&badge, &entry); - } - }); - grid.set_factory(Some(&factory)); - - let ui_grid = ui.clone(); - grid.connect_activate(move |grid, pos| { - if let Some(entry) = entry_at(grid.model().as_ref(), pos) { - activate_entry(&ui_grid, &entry); - } - }); - - // Column list: Name (icon + label, right-clickable), Size, Modified. - column_view.append_column(&name_column(ui)); - column_view.append_column(&text_column(&pgettext("column", "Size"), |e| { - if e.is_dir { - "—".to_string() - } else { - human_bytes(e.size) - } - })); - column_view.append_column(&text_column(&pgettext("column", "Modified"), |e| { - dates::short_date(e.modified) - })); + files + .column_view + .append_column(&text_column(&pgettext("column", "Size"), |e| { + if e.is_dir { + "—".to_string() + } else { + human_bytes(e.size) + } + })); + files + .column_view + .append_column(&text_column(&pgettext("column", "Modified"), |e| { + dates::short_date(e.modified) + })); // Search hits come from anywhere in the Drive; this says where. let location = text_column(&gettext(LOCATION_COLUMN), |e| hit_location(&e.path)); location.set_visible(false); - column_view.append_column(&location); - - let ui_col = ui.clone(); - column_view.connect_activate(move |view, pos| { - if let Some(entry) = entry_at(view.model().as_ref(), pos) { - activate_entry(&ui_col, &entry); - } - }); -} - -fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) { - resize_file_thumbnail(thumbnail, size, grid_fallback_size(size)); - let name_width = (size / 6 + 1).clamp(8, 24); - label.set_width_chars(name_width); - label.set_max_width_chars(name_width); -} - -fn grid_fallback_size(thumbnail_size: i32) -> i32 { - (thumbnail_size * 8 / 9).clamp(24, thumbnail_size) -} - -/// Build the Name column: a small thumbnail with its local-state badge overlaid, -/// followed by the name and the same right-click menu the grid tiles carry. -pub(crate) fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { - let factory = gtk4::SignalListItemFactory::new(); - factory.connect_setup({ - let ui = ui.clone(); - move |_, item| { - let item = item.downcast_ref::().unwrap(); - let thumbnail = file_thumbnail_widget(28, 16); - let badge = gtk4::Image::builder() - .pixel_size(14) - .halign(gtk4::Align::End) - .valign(gtk4::Align::Start) - .build(); - badge.add_css_class("file-badge"); - thumbnail.add_overlay(&badge); - // Ellipsized so the Name column can be *narrower* than its longest - // name. Without it the label's minimum width is the whole string, - // the column inherits that minimum, and one long name pushes Size - // and Modified off the right edge of the window for every row. - let label = gtk4::Label::builder() - .halign(gtk4::Align::Start) - .ellipsize(gtk4::pango::EllipsizeMode::End) - .build(); - let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - cell.append(&thumbnail); - cell.append(&label); - attach_context_menu(&ui, item, &cell); - attach_drag(&ui, item, &cell); - attach_drop(&ui, item, &cell); - item.set_child(Some(&cell)); - } - }); - factory.connect_bind({ - let ui = ui.clone(); - move |_, item| { - let item = item.downcast_ref::().unwrap(); - let cell = item.child().and_downcast::().unwrap(); - let thumbnail = cell.first_child().and_downcast::().unwrap(); - let badge = thumbnail - .last_child() - .and_downcast::() - .unwrap(); - let label = thumbnail - .next_sibling() - .and_downcast::() - .unwrap(); - let obj = item.item().and_downcast::().unwrap(); - let entry = obj.borrow::(); - bind_file_thumbnail(&ui, &thumbnail, &entry, true); - label.set_label(&entry.name); - label.set_tooltip_text(Some(&entry.name)); - apply_badge(&badge, &entry); - } - }); - let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Name")), Some(factory)); - column.set_expand(true); - column + files.column_view.append_column(&location); } /// Title of the search-hit folder column, also how the column is found again. @@ -1070,7 +827,7 @@ fn hit_location(path: &str) -> String { /// Show the Location column only while the list holds search hits. fn show_location_column(ui: &Rc, visible: bool) { - let columns = ui.browser.column_view.columns(); + let columns = ui.browser.files.column_view.columns(); for column in (0..columns.n_items()) .filter_map(|i| columns.item(i).and_downcast::()) { @@ -1080,141 +837,6 @@ fn show_location_column(ui: &Rc, visible: bool) { } } -/// Build a trailing text column whose cell text is derived from each [`DirEntry`] -/// by `render`. -pub(crate) fn text_column( - title: &str, - render: impl Fn(&DirEntry) -> String + 'static, -) -> gtk4::ColumnViewColumn { - let factory = gtk4::SignalListItemFactory::new(); - factory.connect_setup(|_, item| { - let item = item.downcast_ref::().unwrap(); - let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); - label.add_css_class("dim-label"); - item.set_child(Some(&label)); - }); - factory.connect_bind(move |_, item| { - let item = item.downcast_ref::().unwrap(); - let label = item.child().and_downcast::().unwrap(); - let obj = item.item().and_downcast::().unwrap(); - let entry = obj.borrow::(); - label.set_label(&render(&entry)); - }); - gtk4::ColumnViewColumn::new(Some(title), Some(factory)) -} - -/// Attach a secondary-button [`gtk4::GestureClick`] to a cell that pops a context -/// menu for whatever entry the owning `item` is currently bound to. Capturing the -/// [`gtk4::ListItem`] (rather than a snapshot of the entry) keeps the menu correct -/// as the view recycles cells while scrolling. The click is claimed, so the -/// view's own background menu does not open on top of it. -pub(crate) fn attach_context_menu(ui: &Rc, item: >k4::ListItem, anchor: >k4::Box) { - let gesture = gtk4::GestureClick::new(); - gesture.set_button(gtk4::gdk::BUTTON_SECONDARY); - let ui = ui.clone(); - let item = item.clone(); - let target = anchor.clone(); - gesture.connect_pressed(move |gesture, _, x, y| { - gesture.set_state(gtk4::EventSequenceState::Claimed); - if let Some(obj) = item.item().and_downcast::() { - let entry = obj.borrow::().clone(); - // Right-clicking a row that is part of a multi-selection acts on the - // batch; right-clicking outside one acts on the row, as before. - let selected = selected_entries(&ui); - if selected.len() > 1 && selected.iter().any(|e| e.uid == entry.uid) { - bulk_context_menu(&ui, selected).popup_at(&target, x, y); - } else { - entry_context_menu(&ui, &entry).popup_at(&target, x, y); - } - } - }); - anchor.add_controller(gesture); -} - -/// Right-clicking the empty space of a view offers the folder's own actions. -pub(crate) fn attach_background_menu(ui: &Rc, view: &impl IsA) { - let gesture = gtk4::GestureClick::new(); - gesture.set_button(gtk4::gdk::BUTTON_SECONDARY); - let ui = ui.clone(); - let target = view.clone().upcast::(); - gesture.connect_pressed(move |_, _, x, y| { - background_context_menu(&ui).popup_at(&target, x, y); - }); - view.add_controller(gesture); -} - -/// Clicking the empty space of a view drops the selection, as in a file -/// manager. GTK's list views leave it alone, so a stray highlight otherwise -/// sticks around until another item is clicked. -pub(crate) fn attach_background_deselect(ui: &Rc, view: &impl IsA) { - let gesture = gtk4::GestureClick::new(); - gesture.set_button(gtk4::gdk::BUTTON_PRIMARY); - // Capture sees the press before an item's own gesture claims it; this only - // looks, so the item still gets its click. - gesture.set_propagation_phase(gtk4::PropagationPhase::Capture); - let ui = ui.clone(); - let target = view.clone().upcast::(); - gesture.connect_pressed(move |gesture, _, x, y| { - let modifiers = gesture.current_event_state(); - if modifiers - .intersects(gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::SHIFT_MASK) - { - return; - } - if !on_item(&target, x, y) { - clear_selection(&ui); - sync_bulk_bar(&ui); - } - }); - view.add_controller(gesture); -} - -/// Whether `(x, y)` in `view` lands on an item: a grid tile ("child"), a list -/// row ("row"), or a column header ("header"), which has clicks of its own. -fn on_item(view: >k4::Widget, x: f64, y: f64) -> bool { - let mut widget = view.pick(x, y, gtk4::PickFlags::DEFAULT); - while let Some(w) = widget { - if &w == view { - return false; - } - if matches!(w.css_name().as_str(), "child" | "row" | "header") { - return true; - } - widget = w.parent(); - } - false -} - -/// The Menu key or Shift+F10: the menu for the selection, or for the folder -/// when nothing is selected, opened at the focused item. -pub(crate) fn popup_keyboard_context_menu(ui: &Rc) { - let view = ui - .browser - .views - .visible_child() - .unwrap_or_else(|| ui.browser.views.clone().upcast()); - // The focused cell is where the eye is; without one, the middle of the view. - let (x, y) = view - .root() - .and_then(|root| root.focus()) - .filter(|focus| focus.is_ancestor(&view)) - .and_then(|focus| focus.compute_bounds(&view)) - .map(|b| { - ( - (b.x() + b.width() / 2.0) as f64, - (b.y() + b.height() / 2.0) as f64, - ) - }) - .unwrap_or((view.width() as f64 / 2.0, view.height() as f64 / 2.0)); - let selected = selected_entries(ui); - let menu = match selected.len() { - 0 => background_context_menu(ui), - 1 => entry_context_menu(ui, &selected[0]), - _ => bulk_context_menu(ui, selected), - }; - menu.popup_at(&view, x, y); -} - /// The menu for one entry, in the order of `docs/UI_UX_PLAN.md` §6: open, /// offline, sharing, organising, and the destructive item last on its own. pub(crate) fn entry_context_menu(ui: &Rc, entry: &DirEntry) -> ActionMenu { @@ -1334,7 +956,7 @@ pub(crate) fn background_context_menu(ui: &Rc) -> ActionMenu { } let ui_c = ui.clone(); menu.item(&gettext("Select All"), move || { - active_selection(&ui_c).select_all(); + ui_c.browser.files.selection.select_all(); }); let ui_c = ui.clone(); menu.item(&gettext("Refresh"), move || reload_listing(&ui_c)); @@ -1795,7 +1417,7 @@ pub(crate) fn set_files_view(ui: &Rc, view: FilesView) { /// Bring the view stack, the view button and the actions' check marks in line /// with `view`. fn apply_files_view(ui: &Rc, view: FilesView) { - ui.browser.views.set_visible_child_name(layout_name(view)); + ui.browser.files.show_list(view.list); // The button offers the other layout, the way a toggle reads. ui.browser.view_button.set_icon_name(if view.list { "view-grid-symbolic" @@ -1852,6 +1474,13 @@ pub(crate) fn browse_to(ui: &Rc, target: String) { load_browser(ui); } +/// Switch to My files and open `folder`, a mountpoint-relative path. +pub(crate) fn open_in_my_files(ui: &Rc, folder: String) { + ui.stack.set_visible_child_name("browser"); + ui.browser.search.set_text(""); + browse_to(ui, folder); +} + /// Back: out of a search first, then to the previous folder. fn browse_back(ui: &Rc) { if !ui.browser.search.text().is_empty() { @@ -2186,6 +1815,17 @@ fn thumbnail_build_failed(ui: &Rc) { /// `failed` is an untranslated msgid: callers mark it with [`gettext_noop`] and /// it is translated here. pub(crate) fn run_mutation(ui: &Rc, req: Request, done: String, failed: &'static str) { + run_mutation_then(ui, req, failed, move |ui| toast(ui, &done)); +} + +/// [`run_mutation`] with the confirmation left to `done`, for an outcome that +/// deserves more than a plain toast. +pub(crate) fn run_mutation_then( + ui: &Rc, + req: Request, + failed: &'static str, + done: impl FnOnce(&Rc) + 'static, +) { let failed = gettext(failed); if !*ui.mounted.borrow() { toast_error(ui, &failed, &gettext("Proton Drive isn't connected.")); @@ -2202,7 +1842,7 @@ pub(crate) fn run_mutation(ui: &Rc, req: Request, done: String, failed: &'st // The listing the mutation changed is stale now; reload it, then // confirm, so the toast lands over the updated view. reload_listing(&ui); - toast(&ui, &done); + done(&ui); } Ok(Ok(Response::Error { message, kind })) => { toast_failure(&ui, &failed, &message, kind) @@ -2830,7 +2470,7 @@ pub(crate) fn load_browser(ui: &Rc) { // wrong file. let ui_p = ui.clone(); let placeholder = move || { - ui_p.browser.model.remove_all(); + ui_p.browser.files.model.remove_all(); ui_p.browser.summary.set_label(&gettext("Loading…")); browser_status( &ui_p, @@ -2880,7 +2520,7 @@ pub(crate) fn load_browser(ui: &Rc) { /// (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.files.model.remove_all(); ui.browser.summary.set_label(&gettext("Folder unavailable")); browser_status( ui, @@ -2931,7 +2571,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.files.model.remove_all(); ui.browser.empty_actions.set_visible(true); return; } @@ -2941,14 +2581,14 @@ pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { sort_entries(&mut sorted, ui.browser.view.get()); // 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); + replace_items(&ui.browser.files.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_selection(ui); clear_details(ui); } @@ -3013,7 +2653,7 @@ pub(crate) fn run_search(ui: &Rc, query: &str) { 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.files.model.remove_all(); ui_p.browser.summary.set_label(&gettext("Searching…")); browser_status( &ui_p, @@ -3092,7 +2732,7 @@ pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { &gettext("No files or folders match that search."), false, ); - ui.browser.model.remove_all(); + ui.browser.files.model.remove_all(); return; } browser_views(ui); @@ -3112,10 +2752,12 @@ pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { shared_by: String::new(), shared_at: 0, shared_by_unverified: false, + trashed_at: 0, + trashed_from: None, }) .collect(); sort_entries(&mut entries, ui.browser.view.get()); - replace_items(&ui.browser.model, &entries); + replace_items(&ui.browser.files.model, &entries); } fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String { @@ -3169,6 +2811,8 @@ mod tests { shared_by: String::new(), shared_at: 0, shared_by_unverified: false, + trashed_at: 0, + trashed_from: None, } } diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index cfb8e27..60473d2 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -1,29 +1,42 @@ use crate::*; pub(crate) struct SharedState { - // Shared page. Three sections rebuilt wholesale on each load: shared-with-me, - // incoming invitations, and bookmarks. Rows are tracked so a reload can remove - // the old ones before appending the new (adw groups have no clear-all). + // Shared with me page. Two views under a switcher in the header: what other + // people share with me, browsed like My files, and the public links I saved. + // Neither lives in the mount, so both are addressed by uid or token and + // re-listed from the daemon. + pub(crate) files: FileList, + /// "files" and "links". + pub(crate) views: adw::ViewStack, + /// "list", 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) with_me_group: adw::PreferencesGroup, - pub(crate) invitations_group: adw::PreferencesGroup, - pub(crate) bookmarks_group: adw::PreferencesGroup, - /// The header title; its subtitle carries the path inside a shared folder. - pub(crate) title: adw::WindowTitle, - /// Header button that leaves the current shared folder. + /// The files view: "list", or "empty" with [`SharedState::files_empty`]. + pub(crate) files_content: gtk4::Stack, + pub(crate) files_empty: adw::StatusPage, + /// Leaves the current shared folder. pub(crate) back: gtk4::Button, - pub(crate) add_bookmark: gtk4::Button, + /// The breadcrumb trail from "Shared with me" down to the current folder. + pub(crate) crumb: gtk4::Box, + /// "{n} invitations waiting", with Review opening the invitations dialog. + pub(crate) invitations_banner: adw::Banner, + /// The invitations from the last top-level load. + pub(crate) invitations: RefCell>, + /// The links view: "list", or "empty". + pub(crate) links_content: gtk4::Stack, + pub(crate) links_group: adw::PreferencesGroup, + /// Link rows added last time, removed before the next paint (adw groups + /// have no clear-all). + pub(crate) link_rows: RefCell>, /// Where in a shared folder the page currently is, as `(uid, name)` from the /// top level down. Empty = the top level. Shared subtrees have no path in the /// mount, so descending is uid-addressed and the stack *is* the breadcrumb. pub(crate) nav: RefCell>, - pub(crate) rows: RefCell>, /// 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` + /// The [`SharedState::nav`] the files 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, @@ -31,57 +44,140 @@ pub(crate) struct SharedState { pub(crate) loaded_at: Cell>, } -/// Widgets the Shared page's load/repaint touch. -pub(crate) struct SharedWidgets { - pub(crate) content: gtk4::Stack, - pub(crate) status: adw::StatusPage, - pub(crate) shared_with_me: adw::PreferencesGroup, - pub(crate) invitations: adw::PreferencesGroup, - pub(crate) bookmarks: adw::PreferencesGroup, - pub(crate) retry: gtk4::Button, - pub(crate) add_bookmark: gtk4::Button, - pub(crate) refresh: gtk4::Button, - pub(crate) title: adw::WindowTitle, - pub(crate) back: gtk4::Button, +impl SharedState { + pub(crate) fn new(widgets: &SharedWidgets) -> Self { + SharedState { + files: widgets.files.clone(), + views: widgets.views.clone(), + content: widgets.content.clone(), + status: widgets.status.clone(), + retry: widgets.retry.clone(), + files_content: widgets.files_content.clone(), + files_empty: widgets.files_empty.clone(), + back: widgets.back.clone(), + crumb: widgets.crumb.clone(), + invitations_banner: widgets.invitations_banner.clone(), + invitations: RefCell::new(Vec::new()), + links_content: widgets.links_content.clone(), + links_group: widgets.links_group.clone(), + link_rows: RefCell::new(Vec::new()), + nav: RefCell::new(Vec::new()), + loader: Loader::new(&widgets.content), + listed: RefCell::new(None), + loaded_at: Cell::new(None), + } + } } -/// The Shared page: three stacked sections — items shared *with* me (each with a -/// Leave action), invitations addressed to me pending accept/reject, and public -/// links I've bookmarked (open / remove). All three live outside the mount tree, -/// so the page addresses them by uid/id/token and always re-lists from the daemon. +/// Widgets the Shared page's load/repaint touch. +pub(crate) struct SharedWidgets { + pub(crate) files: FileList, + pub(crate) views: adw::ViewStack, + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) retry: gtk4::Button, + pub(crate) files_content: gtk4::Stack, + pub(crate) files_empty: adw::StatusPage, + pub(crate) back: gtk4::Button, + pub(crate) crumb: gtk4::Box, + pub(crate) invitations_banner: adw::Banner, + pub(crate) links_content: gtk4::Stack, + pub(crate) links_group: adw::PreferencesGroup, + pub(crate) refresh: gtk4::Button, + pub(crate) layout: gtk4::Button, + pub(crate) add_link: gtk4::Button, + pub(crate) add_link_empty: gtk4::Button, +} + +/// The Shared with me page: the files and folders other people share with me, +/// in the same list and grid as My files, and a second view of the public +/// links I saved. Pending invitations wait in a banner above both. pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { - // A label, not a bookmark glyph: nothing about the icon says what a Drive - // bookmark is. - let add_bookmark = gtk4::Button::builder() - .label(gettext("Add Bookmark…")) - .tooltip_text(gettext("Save a public link to open it from here")) - .build(); - add_bookmark.add_css_class("flat"); let refresh = refresh_button(); - - // Only sections with something in them are shown, so an account with one - // share reads as one list rather than three boxes, two of them saying - // "nothing here". Invitations come first: they are the one thing on the - // page waiting for a decision. - let invitations = adw::PreferencesGroup::builder() - .title(gettext("Invitations")) - .description(gettext("Accept to add the item to your shared files.")) - .visible(false) - .build(); - let shared_with_me = adw::PreferencesGroup::new(); - let bookmarks = adw::PreferencesGroup::builder() - .title(gettext("Bookmarks")) + let add_link = gtk4::Button::builder() + .label(gettext("Save Link…")) + .tooltip_text(gettext("Save a public link to open it from here")) .visible(false) .build(); + add_link.add_css_class("flat"); - let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 24); - groups.append(&invitations); - groups.append(&shared_with_me); - groups.append(&bookmarks); - let clamp = adw::Clamp::builder().child(&groups).build(); - let scroll = gtk4::ScrolledWindow::builder() + // Files: a path bar over the list, as in My files. + let files = FileList::new(); + files.show_list(true); + let layout = layout_button(&files); + let back = gtk4::Button::builder() + .icon_name("go-previous-symbolic") + .tooltip_text(gettext("Back")) + .sensitive(false) + .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("pdfs-people-symbolic") .vexpand(true) - .child(&clamp) + .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 files_page = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + files_page.append(&path_bar); + files_page.append(&files_content); + + // Saved links: few enough, and plain enough, for a boxed list. + let links_group = adw::PreferencesGroup::new(); + let links_scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&adw::Clamp::builder().child(&links_group).build()) + .build(); + let add_link_empty = gtk4::Button::builder() + .label(gettext("Save Link…")) + .halign(gtk4::Align::Center) + .build(); + add_link_empty.add_css_class("pill"); + add_link_empty.add_css_class("suggested-action"); + let links_empty = adw::StatusPage::builder() + .icon_name("pdfs-link-symbolic") + .title(gettext("No Saved Links")) + .description(gettext( + "Save a Proton Drive public link someone sent you to open it from here later.", + )) + .vexpand(true) + .child(&add_link_empty) + .build(); + links_empty.add_css_class("compact"); + let links_content = gtk4::Stack::new(); + links_content.add_named(&links_scroll, Some("list")); + links_content.add_named(&links_empty, Some("empty")); + + let views = adw::ViewStack::new(); + views.set_vexpand(true); + views.add_titled_with_icon( + &files_page, + Some("files"), + &gettext("Shared with me"), + "pdfs-people-symbolic", + ); + views.add_titled_with_icon( + &links_content, + Some("links"), + &gettext("Saved Links"), + "pdfs-link-symbolic", + ); + let switcher = adw::ViewSwitcher::builder() + .stack(&views) + .policy(adw::ViewSwitcherPolicy::Wide) .build(); let retry = gtk4::Button::builder() @@ -101,57 +197,120 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { 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(&views, Some("list")); content.add_named(&status, Some("status")); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(18); - inner.set_margin_bottom(18); - inner.set_margin_start(18); - inner.set_margin_end(18); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); inner.append(&content); - let back = gtk4::Button::builder() - .icon_name("go-previous-symbolic") - .tooltip_text(gettext("Back")) - .visible(false) + let invitations_banner = adw::Banner::builder() + .button_label(gettext("Review")) .build(); - let (frame, header, title) = page_frame(&gettext("Shared with me"), &inner); - header.pack_start(&back); + + let (frame, header) = page_frame_with(&switcher, &inner); + // Under the header, above both views: an invitation is waiting for a + // decision whichever view is open. + frame.add_top_bar(&invitations_banner); header.pack_end(&refresh); - header.pack_end(&add_bookmark); + header.pack_end(&layout); + header.pack_end(&add_link); ( frame.upcast(), SharedWidgets { + files, + views, content, status, - shared_with_me, - invitations, - bookmarks, retry, - add_bookmark, - refresh, - title, + files_content, + files_empty, back, + crumb, + invitations_banner, + links_content, + links_group, + refresh, + layout, + add_link, + add_link_empty, }, ) } -/// Install the Shared page's retry, back and Add-Bookmark buttons. -pub(crate) fn wire_shared(ui: &Rc, retry: >k4::Button, add_bookmark: >k4::Button) { +/// Install the file list, its columns, and the page's buttons. +pub(crate) fn wire_shared(ui: &Rc, widgets: &SharedWidgets) { + let files = &ui.shared.files; + files.wire( + ui, + FileListBehavior { + activate: open_shared_entry, + entry_menu: shared_entry_menu, + bulk_menu: shared_bulk_menu, + background_menu: shared_background_menu, + badges: false, + drag_and_drop: false, + }, + ); + files.column_view.append_column(&shared_by_column()); + files + .column_view + .append_column(&text_column(&pgettext("column", "Access"), |e| { + role_access(&e.role).unwrap_or_else(|| "—".to_string()) + })); + files + .column_view + .append_column(&text_column(&pgettext("column", "Date Shared"), |e| { + if e.shared_at > 0 { + dates::short_date(e.shared_at) + } 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(); - ui.shared.back.connect_clicked(move |_| { + widgets.back.connect_clicked(move |_| { ui_back.shared.nav.borrow_mut().pop(); load_shared(&ui_back); }); let ui_retry = ui.clone(); - retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared)); - let ui_add = ui.clone(); - add_bookmark.connect_clicked(move |_| prompt_add_bookmark(&ui_add)); + widgets + .retry + .connect_clicked(move |_| restart_service_then(&ui_retry, load_shared)); + for button in [&widgets.add_link, &widgets.add_link_empty] { + let ui_add = ui.clone(); + button.connect_clicked(move |_| prompt_add_bookmark(&ui_add)); + } + let ui_review = ui.clone(); + widgets + .invitations_banner + .connect_button_clicked(move |_| open_invitations_dialog(&ui_review)); + + // The header offers what the view on screen can use. + let (layout, add_link) = (widgets.layout.clone(), widgets.add_link.clone()); + let sync = move |views: &adw::ViewStack| { + let links = views.visible_child_name().as_deref() == Some("links"); + layout.set_visible(!links); + add_link.set_visible(links); + }; + sync(&widgets.views); + widgets.views.connect_visible_child_name_notify(sync); } -/// Show a status page in place of the Shared sections. +/// Show a status page in place of both views. pub(crate) fn shared_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { ui.shared.status.set_icon_name(Some(icon)); ui.shared.status.set_title(title); @@ -160,12 +319,11 @@ pub(crate) fn shared_status(ui: &Rc, icon: &str, title: &str, description: & ui.shared.content.set_visible_child_name("status"); } -/// Fetch the three Shared sections (shared-with-me, invitations, bookmarks) in -/// parallel and repaint the page once all three land. +/// Fetch the top level (shared-with-me, invitations, saved links) in parallel +/// and repaint the page once all three land. /// /// Inside a shared folder ([`SharedState::nav`] non-empty) only that folder's -/// children are fetched: invitations and bookmarks belong to the top level, and -/// carrying them down a subtree would read as if they lived there. +/// children are fetched: invitations and saved links belong to the top level. pub(crate) fn load_shared(ui: &Rc) { cancel_file_thumbnails(ui); let current = ui.shared.nav.borrow().last().cloned(); @@ -216,14 +374,16 @@ pub(crate) fn load_shared(ui: &Rc) { Ok(Ok(Response::Bookmarks { items })) => items, _ => Vec::new(), }; - repaint_shared(&ui, &shared_items, &invitations, &bookmark_items); + repaint_invitations(&ui, invitations); + repaint_links(&ui, &bookmark_items); + repaint_shared_files(&ui, &shared_items); ui.shared.loaded_at.set(Some(Instant::now())); }); } -/// List one shared folder's children and repaint the page as that folder's view. -/// 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. +/// List one shared folder's children and repaint the files view as that +/// folder. The uid comes from the entry that was opened (or from the nav stack +/// on a reload) — a shared subtree is reachable no other way. fn load_shared_folder(ui: &Rc, uid: String) { let ticket = begin_shared_load( ui, @@ -241,7 +401,7 @@ fn load_shared_folder(ui: &Rc, uid: String) { } match result { Ok(Ok(Response::Entries { entries })) => { - repaint_shared_folder(&ui, &entries); + repaint_shared_files(&ui, &entries); ui.shared.loaded_at.set(Some(Instant::now())); } Ok(Ok(Response::Error { message, kind })) => { @@ -260,11 +420,12 @@ 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. +/// folder on screen keeps its entries up and usable. Moving to another folder +/// greys them out until the new entries arrive, since they belong elsewhere. fn begin_shared_load(ui: &Rc, icon: &'static str, description: String) -> LoadTicket { let ui_p = ui.clone(); let placeholder = move || { + ui_p.shared.files.model.remove_all(); shared_status(&ui_p, icon, &gettext("Loading…"), &description, false); }; if *ui.shared.listed.borrow() == Some(ui.shared.nav.borrow().clone()) { @@ -275,158 +436,212 @@ fn begin_shared_load(ui: &Rc, icon: &'static str, description: String) -> Lo } } -/// 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 { - nav.iter() - .map(|(_, name)| name.as_str()) - .collect::>() - .join(" / ") +/// The daemon didn't answer the Shared page. Same still-starting vs. down split +/// as the other pages. +pub(crate) fn shared_unreachable(ui: &Rc) { + service_unreachable(ui, "shared", shared_status, load_shared); } -/// The Invitations heading, with the count once there is more than one. -pub(crate) fn invitations_title(count: usize) -> String { - if count > 1 { - // Translators: the Invitations heading; {count} is the number of pending invitations (2 or more). - gettext_f("Invitations ({count})", &[("count", &count.to_string())]) - } else { - gettext("Invitations") - } -} - -/// Paint the contents of the shared folder at the top of the nav stack; the -/// header's back button leads up again. Invitations and bookmarks are hidden here — -/// they are top-level things, not contents of this folder. -fn repaint_shared_folder(ui: &Rc, entries: &[DirEntry]) { - for (group, row) in ui.shared.rows.borrow_mut().drain(..) { - group.remove(&row); - } +/// Paint the folder [`SharedState::nav`] points at (the top level when empty). +fn repaint_shared_files(ui: &Rc, entries: &[DirEntry]) { ui.shared.content.set_visible_child_name("list"); - ui.shared.invitations_group.set_visible(false); - ui.shared.bookmarks_group.set_visible(false); - ui.shared.back.set_visible(true); - ui.shared.with_me_group.set_visible(true); - ui.shared.add_bookmark.set_visible(false); - let nav = ui.shared.nav.borrow().clone(); - ui.shared.title.set_subtitle(&shared_path(&nav)); + repaint_shared_crumb(ui, &nav); + let top = nav.is_empty(); *ui.shared.listed.borrow_mut() = Some(nav); - ui.shared.with_me_group.set_title(""); - - let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); - if entries.is_empty() { - let row = dim_row(&gettext("This folder is empty.")); - ui.shared.with_me_group.add(&row); - rows.push((ui.shared.with_me_group.clone(), row.upcast())); - } else { - for entry in entries { - let row = shared_entry_row(ui, entry, false); - ui.shared.with_me_group.add(&row); - rows.push((ui.shared.with_me_group.clone(), row.upcast())); - } - } - *ui.shared.rows.borrow_mut() = rows; -} - -/// A row for one node shared with me: folders descend into, files download and -/// open with the user's default application. -/// -/// `leavable` rows are share roots: only those carry a Leave action, since -/// leaving a folder's child on its own is not a thing the API offers. -fn shared_entry_row(ui: &Rc, entry: &DirEntry, leavable: bool) -> adw::ActionRow { - let row = adw::ActionRow::builder() - .title(&entry.name) - .activatable(true) - .build(); - let subtitle = shared_row_subtitle(entry); - if !subtitle.is_empty() { - row.set_subtitle(&subtitle); - } - // What I am allowed to do with someone else's file is the thing this page - // cannot leave implicit: a viewer share is browsable and unwritable, and - // that is only visible if it is said. - if let Some(pill) = role_pill(&entry.role) { - row.add_suffix(&pill); - } - // The "shared by" in the subtitle is what the invitation claims. When its - // signature does not check out against that person's keys, the claim must - // not read as fact — someone may be impersonating a contact to get a file - // opened. - if entry.shared_by_unverified { - let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic"); - warning.add_css_class("warning"); - warning.set_valign(gtk4::Align::Center); - warning.set_tooltip_text(Some(&gettext(UNVERIFIED_INVITER_TOOLTIP))); - row.add_suffix(&warning); - } - row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true)); - row.add_suffix(&shared_entry_menu(ui, entry, leavable)); - if entry.is_dir { - row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic")); - } - let ui_act = ui.clone(); - let uid = entry.uid.clone(); - let name = entry.name.clone(); - let is_dir = entry.is_dir; - row.connect_activated(move |_| { - if is_dir { - ui_act - .shared - .nav - .borrow_mut() - .push((uid.clone(), name.clone())); - load_shared(&ui_act); + ui.shared.files.model.remove_all(); + let (icon, title, description) = if top { + ( + "pdfs-people-symbolic", + gettext("Nothing Shared with You"), + gettext("Files and folders other people share with you appear here."), + ) } else { - open_shared_file(&ui_act, &uid, &name); - } - }); - row + ( + "folder-open-symbolic", + gettext("This folder is empty"), + gettext("Nothing has been added to this shared folder yet."), + ) + }; + ui.shared.files_empty.set_icon_name(Some(icon)); + ui.shared.files_empty.set_title(&title); + ui.shared.files_empty.set_description(Some(&description)); + ui.shared.files_content.set_visible_child_name("empty"); + return; + } + ui.shared.files_content.set_visible_child_name("list"); + // Only the rows that changed are swapped, so a refresh keeps the selection + // and the scroll position. + replace_items(&ui.shared.files.model, entries); } -/// A shared row's ⋮ menu: open it, find it in My Files when the mount has -/// interned it, and leave it (share roots only). -fn shared_entry_menu(ui: &Rc, entry: &DirEntry, leavable: bool) -> gtk4::MenuButton { - let mut items: Vec<(String, MenuAction)> = Vec::new(); - let (ui_open, uid, name, is_dir) = ( - ui.clone(), - entry.uid.clone(), - entry.name.clone(), - entry.is_dir, - ); - items.push(( - pgettext("verb", "Open"), - Box::new(move || { - if is_dir { - ui_open - .shared - .nav - .borrow_mut() - .push((uid.clone(), name.clone())); - load_shared(&ui_open); - } else { - open_shared_file(&ui_open, &uid, &name); - } - }), +/// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder +/// down to the current one, which is a plain heading. +fn repaint_shared_crumb(ui: &Rc, nav: &[(String, String)]) { + let crumb = &ui.shared.crumb; + while let Some(child) = crumb.first_child() { + crumb.remove(&child); + } + ui.shared.back.set_sensitive(!nav.is_empty()); + crumb.append(&shared_crumb_node( + ui, + &gettext("Shared with me"), + 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(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len())); + } +} + +/// One breadcrumb segment: a heading for the current folder, or a flat button +/// that goes back up to `depth` folders below the top level. +fn shared_crumb_node(ui: &Rc, 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.shared.nav.borrow_mut().truncate(depth); + load_shared(&ui); + }); + button.upcast() +} + +/// Whether the files on screen are the top level, where each entry is a share +/// root of its own and can be left. +fn at_share_roots(ui: &Rc) -> bool { + ui.shared + .listed + .borrow() + .as_ref() + .is_some_and(|nav| nav.is_empty()) +} + +/// Folders open in place; files download and open with the default app. +fn open_shared_entry(ui: &Rc, entry: &DirEntry) { + if entry.is_dir { + ui.shared + .nav + .borrow_mut() + .push((entry.uid.clone(), entry.name.clone())); + load_shared(ui); + } else { + open_shared_file(ui, &entry.uid, &entry.name); + } +} + +fn shared_entry_menu(ui: &Rc, entry: &DirEntry) -> ActionMenu { + let mut menu = ActionMenu::new(); + let (ui_c, entry_c) = (ui.clone(), entry.clone()); + menu.item(&pgettext("verb", "Open"), move || { + open_shared_entry(&ui_c, &entry_c) + }); + // Only once the mount has interned the share does it have a place there. if !entry.path.is_empty() { - let (ui_show, entry_show) = (ui.clone(), entry.clone()); - items.push(( - gettext("Show in My Files"), - Box::new(move || show_in_my_files(&ui_show, &entry_show)), - )); + let (ui_c, entry_c) = (ui.clone(), entry.clone()); + menu.item(&gettext("Show in My Files"), move || { + show_in_my_files(&ui_c, &entry_c) + }); } - if leavable { - let (ui_leave, uid, name) = (ui.clone(), entry.uid.clone(), entry.name.clone()); - items.push(( - gettext("Leave…"), - Box::new(move || prompt_leave_shared(&ui_leave, &uid, &name)), - )); + // Leaving a folder's child on its own is not a thing the API offers. + if at_share_roots(ui) { + menu.section(); + let (ui_c, entry_c) = (ui.clone(), entry.clone()); + menu.item(&gettext("Leave…"), move || { + prompt_leave_shared(&ui_c, std::slice::from_ref(&entry_c)) + }); } - let (labels, actions): (Vec, Vec) = items.into_iter().unzip(); - let button = more_menu_button(labels.iter().map(String::as_str).zip(actions).collect()); - button.set_valign(gtk4::Align::Center); - button + menu +} + +fn shared_bulk_menu(ui: &Rc, entries: Vec) -> ActionMenu { + let mut menu = ActionMenu::new(); + menu.labelled_section(&ngettext_f( + "{n} selected", + "{n} selected", + entries.len() as u64, + &[], + )); + if at_share_roots(ui) { + let ui_c = ui.clone(); + menu.item(&gettext("Leave…"), move || { + prompt_leave_shared(&ui_c, &entries) + }); + } + menu +} + +fn shared_background_menu(ui: &Rc) -> ActionMenu { + let mut menu = ActionMenu::new(); + let ui_c = ui.clone(); + menu.item(&gettext("Select All"), move || { + ui_c.shared.files.selection.select_all(); + }); + let ui_c = ui.clone(); + menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c)); + menu.section(); + let ui_c = ui.clone(); + menu.item(&gettext("Save Link…"), move || prompt_add_bookmark(&ui_c)); + menu +} + +/// The Shared by column: who shared the item, and a warning pill when their +/// invitation did not verify. +fn shared_by_column() -> gtk4::ColumnViewColumn { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let item = item.downcast_ref::().unwrap(); + let label = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + label.add_css_class("dim-label"); + // The name beside it is what the invitation claims. When its signature + // does not check out against that person's keys, the claim must not + // read as fact — someone may be impersonating a contact to get a file + // opened. + let pill = gtk4::Label::new(Some(&gettext("Unverified sender"))); + pill.add_css_class("role-pill"); + pill.add_css_class("warning-pill"); + pill.add_css_class("caption"); + pill.set_valign(gtk4::Align::Center); + pill.set_tooltip_text(Some(&gettext(UNVERIFIED_INVITER_TOOLTIP))); + let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + cell.append(&label); + cell.append(&pill); + item.set_child(Some(&cell)); + }); + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().unwrap(); + let cell = item.child().and_downcast::().unwrap(); + let label = cell.first_child().and_downcast::().unwrap(); + let pill = cell.last_child().unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + let text = if entry.shared_by.is_empty() { + "—" + } else { + entry.shared_by.as_str() + }; + label.set_label(text); + label.set_tooltip_text(Some(text)); + pill.set_visible(entry.shared_by_unverified); + }); + let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Shared by")), Some(factory)); + column.set_resizable(true); + column } /// Tooltip on the warning shown next to a share whose invitation did not verify. @@ -434,24 +649,6 @@ const UNVERIFIED_INVITER_TOOLTIP: &str = gettext_noop( "The invitation's signature doesn't match the sender's keys. It may not really be from them.", ); -/// A shared row's subtitle: who shared it and when (share roots only — that is -/// where the invitation lives) and its size (files only). Where the share sits -/// in the mount is one menu item away ("Show in My Files"), not spelled out on -/// every row, where the long path wrapped the subtitle onto a second line. -pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String { - let mut parts: Vec = Vec::new(); - if !entry.shared_by.is_empty() { - parts.push(entry.shared_by.clone()); - if entry.shared_at > 0 { - parts.push(dates::short_date(entry.shared_at)); - } - } - if !entry.is_dir { - parts.push(human_bytes(entry.size)); - } - parts.join(" · ") -} - /// The display name for a wire role, or `None` when there is nothing to show: /// owned content carries no role, and a role the API did not report is not /// guessed at (the SDK's `from_permissions_exact` returns `None` for an @@ -465,8 +662,8 @@ pub(crate) fn role_label(role: &str) -> Option { } } -/// What a role lets me do, as a short phrase for the row's pill. A bare -/// "Editor" beside a Leave button read like one more button. +/// What a role lets me do, as a short phrase. A bare "Editor" read like a +/// job title rather than a permission. pub(crate) fn role_access(role: &str) -> Option { match role { "viewer" => Some(gettext("Can view")), @@ -476,19 +673,11 @@ pub(crate) fn role_access(role: &str) -> Option { } } -/// A tinted, rounded pill saying what I may do with a share I did not create. -pub(crate) fn role_pill(role: &str) -> Option { - let pill = gtk4::Label::new(Some(&role_access(role)?)); - pill.add_css_class("role-pill"); - pill.add_css_class("caption"); - pill.set_valign(gtk4::Align::Center); - pill.set_tooltip_text(role_label(role).as_deref()); - Some(pill) -} - /// Download a file shared with me into the daemon's cache and hand it to the /// user's default application — the shared-item twin of the browser's /// 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, 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. @@ -527,153 +716,169 @@ fn open_shared_file(ui: &Rc, uid: &str, name: &str) { }); } -/// The daemon didn't answer the Shared page. Same still-starting vs. down split -/// as the other pages. -pub(crate) fn shared_unreachable(ui: &Rc) { - service_unreachable(ui, "shared", shared_status, load_shared); +/// Keep the top level's invitations and say in the banner how many wait. +fn repaint_invitations(ui: &Rc, invitations: Vec) { + let banner = &ui.shared.invitations_banner; + banner.set_revealed(!invitations.is_empty()); + if !invitations.is_empty() { + banner.set_title(&ngettext_f( + "{n} invitation waiting", + "{n} invitations waiting", + invitations.len() as u64, + &[], + )); + } + *ui.shared.invitations.borrow_mut() = invitations; } -/// Rebuild the three Shared sections. Rows added last time are removed first — -/// adw groups have no clear-all — then each section is repopulated, showing a -/// dim placeholder row when empty. -pub(crate) fn repaint_shared( - ui: &Rc, - shared: &[DirEntry], - invitations: &[InvitationInfo], - bookmarks: &[BookmarkInfo], -) { - 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); - if shared.is_empty() && invitations.is_empty() && bookmarks.is_empty() { - shared_status( - ui, - "pdfs-people-symbolic", - &gettext("Nothing Shared with You"), - &gettext( - "Files and folders other people share with you appear here. Public links you bookmark are kept here too.", - ), - false, - ); +/// The invitations waiting for a decision, one card each with Decline and +/// Accept. A card leaves the dialog once answered; the dialog closes with the +/// last one. +fn open_invitations_dialog(ui: &Rc) { + let invitations = ui.shared.invitations.borrow().clone(); + if invitations.is_empty() { return; } - ui.shared.content.set_visible_child_name("list"); - ui.shared - .invitations_group - .set_visible(!invitations.is_empty()); - ui.shared.bookmarks_group.set_visible(!bookmarks.is_empty()); - ui.shared.with_me_group.set_visible(!shared.is_empty()); - // The list only needs a heading when another section sits beside it. - ui.shared - .with_me_group - .set_title(&if invitations.is_empty() && bookmarks.is_empty() { - String::new() - } else { - gettext("Shared with you") - }); - ui.shared - .invitations_group - .set_title(&invitations_title(invitations.len())); - let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); - - for entry in shared { - let row = shared_entry_row(ui, entry, true); - ui.shared.with_me_group.add(&row); - rows.push((ui.shared.with_me_group.clone(), row.upcast())); - } - - // Invitations: inviter + item, Accept / Decline. + let group = adw::PreferencesGroup::builder() + .description(gettext("Accept to add the item to your shared files.")) + .build(); + let dialog = adw::Dialog::builder() + .title(gettext("Invitations")) + .content_width(480) + .build(); + let left = Rc::new(Cell::new(invitations.len())); for inv in invitations { - let item = inv.name.clone().unwrap_or_else(|| gettext("a shared item")); - let row = adw::ActionRow::builder() - .title(&item) - // Translators: {email} is the address of the person who sent the invitation. - .subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)])) - .build(); - row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir { - "folder-symbolic" - } else { - "text-x-generic-symbolic" - })); - // Labelled: a close glyph here reads as "dismiss this row". - let reject = gtk4::Button::builder() - .label(gettext("Decline")) - .valign(gtk4::Align::Center) - .build(); - reject.add_css_class("flat"); - reject.add_css_class("pill"); - let accept = gtk4::Button::builder() - .label(gettext("Accept")) - .valign(gtk4::Align::Center) - .build(); - accept.add_css_class("suggested-action"); - accept.add_css_class("pill"); - let ui_acc = ui.clone(); - let id_acc = inv.id.clone(); + let row = invitation_row(&inv); + let answered = { + let (group, row, dialog, left) = + (group.clone(), row.clone(), dialog.clone(), left.clone()); + move || { + group.remove(&row); + left.set(left.get() - 1); + if left.get() == 0 { + dialog.close(); + } + } + }; + let (accept, decline) = invitation_buttons(&row); + let (ui_c, id, answered_c) = (ui.clone(), inv.id.clone(), answered.clone()); accept.connect_clicked(move |_| { - respond_invitation(&ui_acc, &id_acc, true); + respond_invitation(&ui_c, &id, true); + answered_c(); }); - let ui_rej = ui.clone(); - let id_rej = inv.id.clone(); - let name_rej = inv.name.clone().unwrap_or_else(|| gettext("this item")); - reject.connect_clicked(move |btn| { - let ui = ui_rej.clone(); - let id = id_rej.clone(); + let (ui_c, id) = (ui.clone(), inv.id.clone()); + let name = inv.name.clone().unwrap_or_else(|| gettext("this item")); + decline.connect_clicked(move |btn| { + let (ui, id, answered) = (ui_c.clone(), id.clone(), answered.clone()); confirm_destructive( btn, &gettext("Decline Invitation?"), // Translators: {name} is the shared item's name, or "this item". &gettext_f( "You won't have access to {name} unless it is shared again.", - &[("name", &name_rej)], + &[("name", &name)], ), &gettext("Decline"), - move || respond_invitation(&ui, &id, false), + move || { + respond_invitation(&ui, &id, false); + answered(); + }, ); }); - row.add_suffix(&accept); - row.add_suffix(&reject); - ui.shared.invitations_group.add(&row); - rows.push((ui.shared.invitations_group.clone(), row.upcast())); + group.add(&row); } + let page = adw::PreferencesPage::new(); + page.add(&group); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&adw::HeaderBar::new()); + toolbar.set_content(Some(&page)); + dialog.set_child(Some(&toolbar)); + dialog.present(ui_window(ui).as_ref()); +} - // Bookmarks: name/URL, Open / Remove. +/// One invitation's card: the item, and who it is from. +fn invitation_row(inv: &InvitationInfo) -> adw::ActionRow { + let item = inv.name.clone().unwrap_or_else(|| gettext("a shared item")); + let row = adw::ActionRow::builder() + .title(&item) + // Translators: {email} is the address of the person who sent the invitation. + .subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)])) + .build(); + row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir { + "folder-symbolic" + } else { + "text-x-generic-symbolic" + })); + row +} + +/// Add labelled Accept and Decline buttons to an invitation's card. +fn invitation_buttons(row: &adw::ActionRow) -> (gtk4::Button, gtk4::Button) { + let decline = gtk4::Button::builder() + .label(gettext("Decline")) + .valign(gtk4::Align::Center) + .build(); + decline.add_css_class("flat"); + let accept = gtk4::Button::builder() + .label(gettext("Accept")) + .valign(gtk4::Align::Center) + .build(); + accept.add_css_class("suggested-action"); + row.add_suffix(&decline); + row.add_suffix(&accept); + (accept, decline) +} + +/// Rebuild the Saved Links view. +fn repaint_links(ui: &Rc, bookmarks: &[BookmarkInfo]) { + for row in ui.shared.link_rows.borrow_mut().drain(..) { + ui.shared.links_group.remove(&row); + } + ui.shared + .links_content + .set_visible_child_name(if bookmarks.is_empty() { + "empty" + } else { + "list" + }); + let mut rows = Vec::new(); for bm in bookmarks { let title = bm.name.clone().unwrap_or_else(|| gettext("Shared link")); let row = adw::ActionRow::builder() .title(&title) .subtitle(&bm.url) - .build(); - row.add_prefix(>k4::Image::from_icon_name("emblem-symbolic-link")); - let remove = gtk4::Button::builder() - .icon_name("user-trash-symbolic") - .tooltip_text(gettext("Remove bookmark")) - .valign(gtk4::Align::Center) - .build(); - remove.add_css_class("flat"); - let open = gtk4::Button::builder() - .icon_name("adw-external-link-symbolic") + .subtitle_lines(1) + .activatable(true) .tooltip_text(gettext("Open in browser")) - .valign(gtk4::Align::Center) .build(); - open.add_css_class("flat"); - let url_open = bm.url.clone(); - open.connect_clicked(move |_| open_uri(&url_open)); - let ui_rm = ui.clone(); - let token = bm.token.clone(); - let name_rm = title.clone(); - remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm)); - row.add_suffix(&open); - row.add_suffix(&remove); - ui.shared.bookmarks_group.add(&row); - rows.push((ui.shared.bookmarks_group.clone(), row.upcast())); - } + row.add_prefix(>k4::Image::from_icon_name(if bm.is_dir { + "folder-symbolic" + } else { + "pdfs-link-symbolic" + })); + let url = bm.url.clone(); + row.connect_activated(move |_| open_uri(&url)); + row.add_suffix(>k4::Image::from_icon_name("adw-external-link-symbolic")); - *ui.shared.rows.borrow_mut() = rows; + let mut menu = ActionMenu::new(); + let url = bm.url.clone(); + menu.item(&gettext("Open in Browser"), move || open_uri(&url)); + let (ui_c, row_c, url) = (ui.clone(), row.clone(), bm.url.clone()); + menu.item(&gettext("Copy Link"), move || { + row_c.clipboard().set_text(&url); + toast(&ui_c, &gettext("Link copied")); + }); + menu.section(); + let (ui_c, token, name) = (ui.clone(), bm.token.clone(), title.clone()); + menu.item(&gettext("Remove…"), move || { + prompt_remove_bookmark(&ui_c, &token, &name) + }); + row.add_suffix(&menu.button()); + + ui.shared.links_group.add(&row); + rows.push(row.upcast()); + } + *ui.shared.link_rows.borrow_mut() = rows; } /// Accept or reject an invitation, then reload the Shared page. @@ -697,45 +902,103 @@ pub(crate) fn respond_invitation(ui: &Rc, id: &str, accept: bool) { run_shared_mutation(ui, req, &done, &failed); } -/// Confirm, then leave a node shared with me. -pub(crate) fn prompt_leave_shared(ui: &Rc, uid: &str, name: &str) { - let win = ui_window(ui); +/// Confirm, then leave shared items. Only share roots can be left. +pub(crate) fn prompt_leave_shared(ui: &Rc, entries: &[DirEntry]) { + let body = match entries { + // Translators: {name} is the shared item's name. + [one] => gettext_f( + "Leave “{name}”? You'll lose access until you're invited again.", + &[("name", &one.name)], + ), + _ => ngettext_f( + "Leave {n} shared item? You'll lose access until you're invited again.", + "Leave {n} shared items? You'll lose access until you're invited again.", + entries.len() as u64, + &[], + ), + }; let dialog = adw::AlertDialog::builder() .heading(gettext("Leave Shared Item?")) - // Translators: {name} is the shared item's name. - .body(gettext_f( - "Leave “{name}”? You'll lose access until you're invited again.", - &[("name", name)], - )) + .body(body) .build(); dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("leave", &pgettext("verb", "Leave")); dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive); dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); - let ui = ui.clone(); - let uid = uid.to_string(); - dialog.connect_response(None, move |_, resp| { - if resp == "leave" { - run_shared_mutation( - &ui, - Request::LeaveShared { uid: uid.clone() }, - &gettext("Left shared item"), - &gettext("Couldn't leave the shared item"), - ); - } + let ui_c = ui.clone(); + let uids: Vec = entries.iter().map(|e| e.uid.clone()).collect(); + dialog.connect_response(Some("leave"), move |_, _| { + let count = uids.len(); + run_requests( + &ui_c, + uids.iter() + .map(|uid| Request::LeaveShared { uid: uid.clone() }) + .collect(), + gettext_noop("Couldn't leave the shared item"), + move |ui| { + load_shared(ui); + toast( + ui, + &ngettext_f( + "Left {n} shared item", + "Left {n} shared items", + count as u64, + &[], + ), + ); + }, + ); }); - dialog.present(win.as_ref()); + dialog.present(ui_window(ui).as_ref()); } -/// Confirm, then remove a saved bookmark. +/// Send `requests` one after the other, stopping at the first failure, which +/// is reported under `failed` (an untranslated msgid). `done` runs once all +/// succeeded. After a failure the page on screen is reloaded instead, since +/// the requests before it went through. +pub(crate) fn run_requests( + ui: &Rc, + requests: Vec, + failed: &'static str, + done: impl FnOnce(&Rc) + 'static, +) { + ui.busy_begin(); + let ui = ui.clone(); + let socket = ui.dirs.control_socket(); + glib::spawn_future_local(async move { + for req in requests { + let result = spawn_request(socket.clone(), req).recv().await; + let error = match result { + Ok(Ok(Response::Ok { .. })) => continue, + Ok(Ok(Response::Error { message, kind })) => Some((message, kind)), + _ => None, + }; + ui.busy_end(); + reload_current_page(&ui); + match error { + Some((message, kind)) => toast_failure(&ui, &gettext(failed), &message, kind), + None => toast_error( + &ui, + &gettext(failed), + &gettext("The Proton Drive service didn't respond."), + ), + } + return; + } + ui.busy_end(); + done(&ui); + }); +} + +/// Confirm, then remove a saved link. The shared item itself is untouched. pub(crate) fn prompt_remove_bookmark(ui: &Rc, token: &str, name: &str) { let win = ui_window(ui); let dialog = adw::AlertDialog::builder() - .heading(gettext("Remove Bookmark?")) - // Translators: {name} is the bookmark's name. + .heading(gettext("Remove Saved Link?")) + // Translators: {name} is the name of the item the saved link points to. .body(gettext_f( - "Remove the bookmark for “{name}”?", + "Remove “{name}” from your saved links? The shared item itself is not affected.", &[("name", name)], )) .build(); @@ -753,19 +1016,19 @@ pub(crate) fn prompt_remove_bookmark(ui: &Rc, token: &str, name: &str) { Request::DeleteBookmark { token: token.clone(), }, - &gettext("Bookmark removed"), - &gettext("Couldn't remove the bookmark"), + &gettext("Link removed"), + &gettext("Couldn't remove the link"), ); } }); dialog.present(win.as_ref()); } -/// Prompt for a public-link URL (and optional password) and save it as a bookmark. +/// Prompt for a public-link URL (and optional password) and save it. pub(crate) fn prompt_add_bookmark(ui: &Rc) { let win = ui_window(ui); let dialog = adw::AlertDialog::builder() - .heading(gettext("Add Bookmark")) + .heading(gettext("Save Link")) .body(gettext("Paste a Proton Drive public link to save it here.")) .build(); let group = adw::PreferencesGroup::new(); @@ -781,31 +1044,33 @@ pub(crate) fn prompt_add_bookmark(ui: &Rc) { group.add(&pw_row); dialog.set_extra_child(Some(&group)); dialog.add_response("cancel", &gettext("Cancel")); - dialog.add_response("add", &gettext("Add")); - dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("add")); + dialog.add_response("save", &gettext("Save")); + dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("save")); dialog.set_close_response("cancel"); let ui = ui.clone(); dialog.connect_response(None, move |_, resp| { - if resp != "add" { + if resp != "save" { return; } let url = url_row.text().trim().to_string(); if url.is_empty() { toast_error( &ui, - &gettext("Couldn't add bookmark"), + &gettext("Couldn't save the link"), &gettext("A URL is required."), ); return; } let pw = pw_row.text().to_string(); let password = if pw.is_empty() { None } else { Some(pw) }; + // The new link lands in the Saved Links view; show it there. + ui.shared.views.set_visible_child_name("links"); run_shared_mutation( &ui, Request::CreateBookmark { url, password }, - &gettext("Bookmark saved"), - &gettext("Couldn't add the bookmark"), + &gettext("Link saved"), + &gettext("Couldn't save the link"), ); }); dialog.present(win.as_ref()); @@ -842,65 +1107,14 @@ pub(crate) fn run_shared_mutation(ui: &Rc, req: Request, done: &str, failed: mod tests { use super::*; - fn entry(is_dir: bool, path: &str, role: &str) -> DirEntry { - DirEntry { - name: "Budget.ods".into(), - is_dir, - size: 2048, - modified: 0, - pinned: false, - cached: false, - uid: "vol~link".into(), - path: path.into(), - role: role.into(), - shared_by: String::new(), - shared_at: 0, - shared_by_unverified: false, - } - } - #[test] - fn a_share_root_says_who_shared_it() { - let mut root = entry(true, "", "viewer"); - root.shared_by = "alice@proton.me".into(); - assert_eq!(shared_row_subtitle(&root), "alice@proton.me"); - root.shared_at = 1_700_000_000; - let subtitle = shared_row_subtitle(&root); - assert!(subtitle.starts_with("alice@proton.me · "), "{subtitle}"); - } - - #[test] - fn a_share_with_no_local_path_says_nothing_about_one() { - // Before the synthetic directory has been listed, the daemon cannot - // resolve a path — pointing at one that isn't there would be worse than - // silence. - assert_eq!(shared_row_subtitle(&entry(true, "", "viewer")), ""); - assert_eq!( - shared_row_subtitle(&entry(false, "", "viewer")), - human_bytes(2048) - ); - } - - #[test] - fn a_resident_share_keeps_its_path_out_of_the_subtitle() { - let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor")); - assert_eq!(subtitle, ""); - } - - #[test] - fn a_role_pill_says_what_it_allows() { + fn a_role_says_what_it_allows() { assert_eq!(role_access("viewer").as_deref(), Some("Can view")); assert_eq!(role_access("editor").as_deref(), Some("Can edit")); assert_eq!(role_access("admin").as_deref(), Some("Can manage")); assert_eq!(role_access(""), None); } - #[test] - fn the_invitations_heading_counts_only_several() { - assert_eq!(invitations_title(1), "Invitations"); - assert_eq!(invitations_title(4), "Invitations (4)"); - } - #[test] fn only_a_known_role_becomes_a_badge() { // "" is owned content (no role applies) and an unrecognised mask is @@ -912,14 +1126,4 @@ mod tests { assert!(role_label("").is_none()); assert!(role_label("inherited").is_none()); } - - #[test] - fn the_header_path_reads_from_the_share_root_down() { - assert_eq!(shared_path(&[]), ""); - let nav = vec![ - ("u1".to_string(), "Team".to_string()), - ("u2".to_string(), "Reports".to_string()), - ]; - assert_eq!(shared_path(&nav), "Team / Reports"); - } } 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 95aa724..1f3b42b 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -1,39 +1,100 @@ use crate::*; pub(crate) struct SharedByMeState { - // Shared (by me) page: one section listing the items I've shared, each with a - // copy-link / manage affordance. Rebuilt wholesale on each load. + // Shared by me page: the items I have shared, as a file list whose columns + // say where each lives, who has it and what its public link is doing. + pub(crate) files: FileList, + /// "list", or "status" while the page loads, is empty 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, - pub(crate) rows: RefCell>, + /// "12 items" under the page title. + pub(crate) subtitle: adw::WindowTitle, + /// Which items to show, indexed like [`SHARE_FILTERS`]. + pub(crate) filter: gtk4::DropDown, + /// The last listing, unfiltered. The list holds [`DirEntry`]s; the columns + /// and menus look a row's people and link up here by uid. + pub(crate) items: Rc>>, /// Runs the loads; the rows stay up while one runs. pub(crate) loader: Rc, + /// When the page last painted good data. `None` = never / invalidated, + /// forcing a fetch on next visit. See [`PAGE_TTL`]. pub(crate) loaded_at: Cell>, } +impl SharedByMeState { + pub(crate) fn new(widgets: &SharedByMeWidgets) -> Self { + SharedByMeState { + files: widgets.files.clone(), + content: widgets.content.clone(), + status: widgets.status.clone(), + retry: widgets.retry.clone(), + subtitle: widgets.subtitle.clone(), + filter: widgets.filter.clone(), + items: Rc::new(RefCell::new(Vec::new())), + loader: Loader::new(&widgets.content), + loaded_at: Cell::new(None), + } + } +} + /// Widgets the Shared (by-me) page's load/repaint touch. pub(crate) struct SharedByMeWidgets { + pub(crate) files: FileList, pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, - pub(crate) group: adw::PreferencesGroup, pub(crate) retry: gtk4::Button, pub(crate) refresh: gtk4::Button, + pub(crate) subtitle: adw::WindowTitle, + pub(crate) filter: gtk4::DropDown, } -/// The Shared page: one section listing the items I have shared with others — -/// each row summarizing who has access, opening the item when activated, with -/// Copy link and a menu for the link and the per-node Share dialog. +/// Which shared items the page shows. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum ShareFilter { + All, + /// Items with a public link. + Links, + /// Items shared with people, invited or already in. + People, + /// Items whose public link has expired. + Expired, +} + +/// The filters in the order the header's drop-down lists them. +const SHARE_FILTERS: [ShareFilter; 4] = [ + ShareFilter::All, + ShareFilter::Links, + ShareFilter::People, + ShareFilter::Expired, +]; + +fn share_filter_label(filter: ShareFilter) -> String { + match filter { + ShareFilter::All => gettext("All Shared Items"), + ShareFilter::Links => gettext("Links Only"), + ShareFilter::People => gettext("People Only"), + ShareFilter::Expired => gettext("Expired Links"), + } +} + +/// The Shared by me page: every item I have shared with people or by link. +/// Activating one opens its Share dialog, since managing access is what this +/// page is for; opening the item itself is in the menu. pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) { let refresh = refresh_button(); - - let group = adw::PreferencesGroup::new(); - let clamp = adw::Clamp::builder().child(&group).build(); - let scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&clamp) - .build(); + let files = FileList::new(); + files.show_list(true); + let layout = layout_button(&files); + let labels: Vec = SHARE_FILTERS + .iter() + .copied() + .map(share_filter_label) + .collect(); + let filter = + gtk4::DropDown::from_strings(&labels.iter().map(String::as_str).collect::>()); + filter.set_valign(gtk4::Align::Center); + filter.set_tooltip_text(Some(&gettext("Show only some shared items"))); let retry = gtk4::Button::builder() .label(gettext("Retry")) @@ -52,34 +113,218 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) { 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(&files.views, Some("list")); content.add_named(&status, Some("status")); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(18); - inner.set_margin_bottom(18); - inner.set_margin_start(18); - inner.set_margin_end(18); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); inner.append(&content); - let (frame, header, _) = page_frame(&gettext("Shared by me"), &inner); + + let (frame, header, subtitle) = page_frame(&gettext("Shared by me"), &inner); + header.pack_start(&filter); header.pack_end(&refresh); + header.pack_end(&layout); ( frame.upcast(), SharedByMeWidgets { + files, content, status, - group, retry, refresh, + subtitle, + filter, }, ) } -/// Install the Shared (by-me) page's retry button. -pub(crate) fn wire_shared_by_me(ui: &Rc, retry: >k4::Button) { +/// Install the file list, its columns, the filter and the retry button. +pub(crate) fn wire_shared_by_me(ui: &Rc, widgets: &SharedByMeWidgets) { + let files = &ui.shared_by_me.files; + files.wire( + ui, + FileListBehavior { + activate: open_share_dialog, + entry_menu: shared_by_me_menu, + bulk_menu: shared_by_me_bulk_menu, + background_menu: shared_by_me_background_menu, + badges: false, + drag_and_drop: false, + }, + ); + let items = &ui.shared_by_me.items; + files + .column_view + .append_column(&text_column(&gettext("Location"), |e| { + location_label(&e.path) + })); + let people = items.clone(); + files + .column_view + .append_column(&text_column(&pgettext("column", "People"), move |e| { + find_item(&people, &e.uid) + .map(|item| people_label(&item)) + .unwrap_or_else(|| "—".to_string()) + })); + files.column_view.append_column(&link_column(ui)); + let ui_retry = ui.clone(); - retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared_by_me)); + widgets + .retry + .connect_clicked(move |_| restart_service_then(&ui_retry, load_shared_by_me)); + let ui_filter = ui.clone(); + widgets.filter.connect_selected_notify(move |_| { + let items = ui_filter.shared_by_me.items.borrow().clone(); + repaint_shared_by_me(&ui_filter, &items); + }); +} + +/// The shared item a row stands for. +fn find_item(items: &RefCell>, uid: &str) -> Option { + items.borrow().iter().find(|item| item.uid == uid).cloned() +} + +/// The Link column: what the public link is doing, and a button that copies +/// it while it works. +fn link_column(ui: &Rc) -> gtk4::ColumnViewColumn { + let factory = gtk4::SignalListItemFactory::new(); + let ui_setup = ui.clone(); + factory.connect_setup(move |_, item| { + let item = item.downcast_ref::().unwrap(); + let label = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .hexpand(true) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + label.add_css_class("dim-label"); + let copy = gtk4::Button::builder() + .icon_name("pdfs-link-symbolic") + .tooltip_text(gettext("Copy link")) + .valign(gtk4::Align::Center) + .build(); + copy.add_css_class("flat"); + // Read at click time: the view recycles this cell for other rows. + let (ui, item_c) = (ui_setup.clone(), item.clone()); + copy.connect_clicked(move |btn| { + let Some(obj) = item_c.item().and_downcast::() else { + return; + }; + let uid = obj.borrow::().uid.clone(); + if let Some(url) = find_item(&ui.shared_by_me.items, &uid) + .and_then(|item| item.link) + .and_then(|link| link.url) + { + btn.clipboard().set_text(&url); + flash_copied(btn, "pdfs-link-symbolic"); + toast(&ui, &gettext("Link copied")); + } + }); + let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + cell.append(&label); + cell.append(©); + item.set_child(Some(&cell)); + }); + let items = ui.shared_by_me.items.clone(); + factory.connect_bind(move |_, item| { + let item = item.downcast_ref::().unwrap(); + let cell = item.child().and_downcast::().unwrap(); + let label = cell.first_child().and_downcast::().unwrap(); + let copy = cell.last_child().unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let uid = obj.borrow::().uid.clone(); + let link = find_item(&items, &uid).and_then(|item| item.link); + let now = glib::real_time() / 1_000_000; + let text = link_label(link.as_ref(), now); + label.set_label(&text); + label.set_tooltip_text(Some(&text)); + copy.set_visible( + link.as_ref() + .is_some_and(|l| l.url.is_some() && !link_expired(l, now)), + ); + }); + let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Link")), Some(factory)); + column.set_resizable(true); + column +} + +/// Where a shared item lives in My files: its folder, "My files" at the top, +/// or a dash when the daemon can't place it. +pub(crate) fn location_label(path: &str) -> String { + match path.rsplit_once('/') { + Some((parent, _)) => parent.to_string(), + None if path.is_empty() => "—".to_string(), + None => gettext("My files"), + } +} + +/// Who can reach a shared item through an invitation. +pub(crate) fn people_label(item: &SharedItem) -> String { + let mut parts = Vec::new(); + if item.member_count > 0 { + // Translators: how many people have access to a shared item. + parts.push(ngettext_f( + "{n} person", + "{n} people", + item.member_count as u64, + &[], + )); + } + if item.invite_count > 0 { + // Translators: how many invitations to a shared item are not accepted yet. + parts.push(ngettext_f( + "{n} pending", + "{n} pending", + item.invite_count as u64, + &[], + )); + } + if parts.is_empty() { + "—".to_string() + } else { + parts.join(" · ") + } +} + +fn link_expired(link: &PublicLinkInfo, now: i64) -> bool { + link.expires.is_some_and(|at| at <= now) +} + +/// What a public link is doing at `now` (epoch seconds): working, working +/// until a date, or expired, and whether a password guards it. +pub(crate) fn link_label(link: Option<&PublicLinkInfo>, now: i64) -> String { + let Some(link) = link else { + return "—".to_string(); + }; + let state = match link.expires { + Some(at) if at <= now => pgettext("link", "Expired"), + // Translators: {date} is when a public link stops working. + Some(at) => gettext_f("Expires {date}", &[("date", &dates::short_date(at))]), + None if link.created > 0 => gettext_f( + // Translators: {date} is when the public link was created. + "Active since {date}", + &[("date", &dates::short_date(link.created))], + ), + None => pgettext("link", "Active"), + }; + if link.has_password { + [state, gettext("Password protected")].join(" · ") + } else { + state + } +} + +/// Whether `item` passes `filter` at `now` (epoch seconds). +pub(crate) fn matches_filter(item: &SharedItem, filter: ShareFilter, now: i64) -> bool { + match filter { + ShareFilter::All => true, + ShareFilter::Links => item.link.is_some(), + ShareFilter::People => item.member_count + item.invite_count > 0, + ShareFilter::Expired => item.link.as_ref().is_some_and(|l| link_expired(l, now)), + } } /// Show a status page in place of the Shared (by-me) list. @@ -95,6 +340,7 @@ pub(crate) fn shared_by_me_status( ui.shared_by_me.status.set_description(Some(description)); ui.shared_by_me.retry.set_visible(retry); ui.shared_by_me.content.set_visible_child_name("status"); + ui.shared_by_me.subtitle.set_subtitle(""); } /// Fetch the shared-by-me listing and repaint the page. @@ -102,6 +348,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc) { cancel_file_thumbnails(ui); let ui_p = ui.clone(); let ticket = ui.shared_by_me.loader.refresh(move || { + ui_p.shared_by_me.files.model.remove_all(); shared_by_me_status( &ui_p, "pdfs-share-symbolic", @@ -119,9 +366,15 @@ pub(crate) fn load_shared_by_me(ui: &Rc) { if !ticket.is_current() { return; } + if !matches!(result, Ok(Ok(Response::SharedByMe { .. }))) { + ui.shared_by_me.files.model.remove_all(); + } match result { Ok(Ok(Response::SharedByMe { items })) => { + let changed = changed_items(&ui.shared_by_me.items.borrow(), &items); + *ui.shared_by_me.items.borrow_mut() = items.clone(); repaint_shared_by_me(&ui, &items); + rebind_rows(&ui, &changed); ui.shared_by_me.loaded_at.set(Some(Instant::now())); } Ok(Ok(Response::Error { message, .. })) => shared_by_me_status( @@ -151,11 +404,8 @@ pub(crate) fn shared_by_me_unreachable(ui: &Rc) { service_unreachable(ui, "sharedbyme", shared_by_me_status, load_shared_by_me); } -/// Rebuild the Shared (by-me) section from a fresh listing. +/// Show the items that pass the header's filter. pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { - for row in ui.shared_by_me.rows.borrow_mut().drain(..) { - ui.shared_by_me.group.remove(&row); - } if items.is_empty() { shared_by_me_status( ui, @@ -164,96 +414,147 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { &gettext("Items you share with people or by link show up here."), false, ); + ui.shared_by_me.files.model.remove_all(); + return; + } + let filter = SHARE_FILTERS + .get(ui.shared_by_me.filter.selected() as usize) + .copied() + .unwrap_or(ShareFilter::All); + let now = glib::real_time() / 1_000_000; + let shown: Vec = items + .iter() + .filter(|item| matches_filter(item, filter, now)) + .map(shared_item_as_entry) + .collect(); + if shown.is_empty() { + shared_by_me_status( + ui, + "pdfs-share-symbolic", + &gettext("No Matching Items"), + &gettext("None of your shared items match this filter."), + false, + ); + ui.shared_by_me.files.model.remove_all(); return; } ui.shared_by_me.content.set_visible_child_name("list"); - let mut rows: Vec = Vec::new(); - for item in items { - let entry = shared_item_as_entry(item); - let row = adw::ActionRow::builder() - .title(&item.name) - .subtitle(shared_item_summary(item)) - .build(); - row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true)); - - // A node the daemon can place in my tree opens like it would in My Files. - if !item.path.is_empty() { - row.set_activatable(true); - let ui_open = ui.clone(); - let entry_open = entry.clone(); - row.connect_activated(move |_| open_shared_by_me(&ui_open, &entry_open)); - } - - let url = item.link.as_ref().and_then(|l| l.url.clone()); - // The one action people come here for most gets its own button. - if let Some(url) = url.clone() { - let copy = gtk4::Button::builder() - .icon_name("pdfs-link-symbolic") - .tooltip_text(gettext("Copy link")) - .valign(gtk4::Align::Center) - .build(); - copy.add_css_class("flat"); - let ui_copy = ui.clone(); - copy.connect_clicked(move |btn| { - btn.clipboard().set_text(&url); - flash_copied(btn, "pdfs-link-symbolic"); - toast(&ui_copy, &gettext("Link copied")); - }); - row.add_suffix(©); - } - row.add_suffix(&shared_by_me_menu(ui, &entry, url)); - - ui.shared_by_me.group.add(&row); - rows.push(row.upcast()); - } - *ui.shared_by_me.rows.borrow_mut() = rows; + ui.shared_by_me.subtitle.set_subtitle(&ngettext_f( + "{n} item", + "{n} items", + shown.len() as u64, + &[], + )); + // Only the rows that changed are swapped, so a refresh keeps the selection + // and the scroll position. + replace_items(&ui.shared_by_me.files.model, &shown); } -/// The ⋮ menu on a shared item: open it, the link, the Share dialog, and -/// Stop Sharing. -/// -/// Manage access opens the per-node Share dialog, which addresses a pathless +/// The uids in `new` whose people or link differ from `old`. Their rows show +/// the same [`DirEntry`] as before, so the list would not repaint them. +fn changed_items(old: &[SharedItem], new: &[SharedItem]) -> HashSet { + new.iter() + .filter(|item| old.iter().find(|o| o.uid == item.uid) != Some(*item)) + .map(|item| item.uid.clone()) + .collect() +} + +/// Have the list bind the rows of `uids` again, to repaint their columns. +fn rebind_rows(ui: &Rc, uids: &HashSet) { + let model = &ui.shared_by_me.files.model; + for pos in 0..model.n_items() { + if entry_at(Some(model), pos).is_some_and(|e| uids.contains(&e.uid)) { + model.items_changed(pos, 1, 1); + } + } +} + +/// A shared item's right-click menu: manage it, open it, its link, and Stop +/// Sharing. Manage Access opens the Share dialog, which addresses a pathless /// node by uid, so every shared item can be managed from here. -fn shared_by_me_menu(ui: &Rc, entry: &DirEntry, url: Option) -> gtk4::MenuButton { - let mut items: Vec<(String, MenuAction)> = Vec::new(); +fn shared_by_me_menu(ui: &Rc, entry: &DirEntry) -> ActionMenu { + let mut menu = ActionMenu::new(); + let (ui_c, entry_c) = (ui.clone(), entry.clone()); + menu.item(&gettext("Manage Access…"), move || { + open_share_dialog(&ui_c, &entry_c) + }); + // A node the daemon can place in my tree opens like it would in My Files. if !entry.path.is_empty() { let (ui_c, entry_c) = (ui.clone(), entry.clone()); - items.push(( - pgettext("verb", "Open"), - Box::new(move || open_shared_by_me(&ui_c, &entry_c)), - )); + menu.item(&pgettext("verb", "Open"), move || { + open_shared_by_me(&ui_c, &entry_c) + }); let (ui_c, entry_c) = (ui.clone(), entry.clone()); - items.push(( - gettext("Show in My Files"), - Box::new(move || show_in_my_files(&ui_c, &entry_c)), - )); + menu.item(&gettext("Show in My Files"), move || { + show_in_my_files(&ui_c, &entry_c) + }); } + let now = glib::real_time() / 1_000_000; + let url = find_item(&ui.shared_by_me.items, &entry.uid) + .and_then(|item| item.link) + .filter(|link| !link_expired(link, now)) + .and_then(|link| link.url); if let Some(url) = url { - items.push((gettext("Open Link"), Box::new(move || open_uri(&url)))); + menu.section(); + let (ui_c, url_c) = (ui.clone(), url.clone()); + menu.item(&gettext("Copy Link"), move || { + ui_c.stack.clipboard().set_text(&url_c); + toast(&ui_c, &gettext("Link copied")); + }); + menu.item(&gettext("Open Link"), move || open_uri(&url)); } + menu.section(); let (ui_c, entry_c) = (ui.clone(), entry.clone()); - items.push(( - gettext("Manage Access…"), - Box::new(move || open_share_dialog(&ui_c, &entry_c)), + menu.item(&gettext("Stop Sharing…"), move || { + prompt_stop_sharing(&ui_c, std::slice::from_ref(&entry_c)) + }); + menu +} + +fn shared_by_me_bulk_menu(ui: &Rc, entries: Vec) -> ActionMenu { + let mut menu = ActionMenu::new(); + menu.labelled_section(&ngettext_f( + "{n} selected", + "{n} selected", + entries.len() as u64, + &[], )); - let (ui_c, entry_c) = (ui.clone(), entry.clone()); - items.push(( - gettext("Stop Sharing…"), - Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)), - )); - let (labels, actions): (Vec, Vec) = items.into_iter().unzip(); - more_menu_button(labels.iter().map(String::as_str).zip(actions).collect()) + let ui_c = ui.clone(); + menu.item(&gettext("Stop Sharing…"), move || { + prompt_stop_sharing(&ui_c, &entries) + }); + menu +} + +fn shared_by_me_background_menu(ui: &Rc) -> ActionMenu { + let mut menu = ActionMenu::new(); + let ui_c = ui.clone(); + menu.item(&gettext("Select All"), move || { + ui_c.shared_by_me.files.selection.select_all(); + }); + let ui_c = ui.clone(); + menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c)); + menu } /// Confirm, then remove every member, invitation and the public link. -pub(crate) fn prompt_stop_sharing(ui: &Rc, entry: &DirEntry) { +pub(crate) fn prompt_stop_sharing(ui: &Rc, entries: &[DirEntry]) { + let body = match entries { + // Translators: {name} is the shared item's name. + [one] => gettext_f( + "Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.", + &[("name", &one.name)], + ), + _ => ngettext_f( + "Everyone you invited loses access to {n} item, pending invitations are withdrawn and its public link stops working.", + "Everyone you invited loses access to {n} items, pending invitations are withdrawn and their public links stop working.", + entries.len() as u64, + &[], + ), + }; let dialog = adw::AlertDialog::builder() .heading(gettext("Stop Sharing?")) - // Translators: {name} is the shared item's name. - .body(gettext_f( - "Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.", - &[("name", &entry.name)], - )) + .body(body) .build(); dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("stop", &gettext("Stop Sharing")); @@ -261,15 +562,30 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc, entry: &DirEntry) { dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); let ui_c = ui.clone(); - let uid = entry.uid.clone(); - let name = entry.name.clone(); + let done = match entries { + // Translators: {name} is the item's name. + [one] => gettext_f("Stopped sharing {name}", &[("name", &one.name)]), + _ => ngettext_f( + "Stopped sharing {n} item", + "Stopped sharing {n} items", + entries.len() as u64, + &[], + ), + }; + let uids: Vec = entries.iter().map(|e| e.uid.clone()).collect(); dialog.connect_response(Some("stop"), move |_, _| { - run_mutation( + let done = done.clone(); + run_requests( &ui_c, - Request::StopSharingByUid { uid: uid.clone() }, - // Translators: {name} is the item's name. - gettext_f("Stopped sharing {name}", &[("name", &name)]), + uids.iter() + .map(|uid| Request::StopSharingByUid { uid: uid.clone() }) + .collect(), gettext_noop("Couldn't stop sharing"), + move |ui| { + ui.shared_by_me.loaded_at.set(None); + reload_listing(ui); + toast(ui, &done); + }, ); }); dialog.present(ui_window(ui).as_ref()); @@ -278,9 +594,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc, entry: &DirEntry) { /// Open a shared item: a folder in My Files, a file the way My Files would. fn open_shared_by_me(ui: &Rc, entry: &DirEntry) { if entry.is_dir { - ui.stack.set_visible_child_name("browser"); - ui.browser.search.set_text(""); - browse_to(ui, entry.path.clone()); + open_in_my_files(ui, entry.path.clone()); } else { activate_entry(ui, entry); } @@ -293,45 +607,11 @@ pub(crate) fn show_in_my_files(ui: &Rc, entry: &DirEntry) { .rsplit_once('/') .map(|(parent, _)| parent.to_string()) .unwrap_or_default(); - ui.stack.set_visible_child_name("browser"); - ui.browser.search.set_text(""); - browse_to(ui, parent); + open_in_my_files(ui, parent); } -/// A one-line summary of who can reach a shared item, for its row subtitle. -pub(crate) fn shared_item_summary(item: &SharedItem) -> String { - let mut parts = Vec::new(); - if item.member_count > 0 { - // Translators: how many people have access to a shared item. - parts.push(ngettext_f( - "{n} person", - "{n} people", - item.member_count as u64, - &[], - )); - } - if item.invite_count > 0 { - // Translators: how many invitations to a shared item are not accepted yet. - parts.push(ngettext_f( - "{n} pending", - "{n} pending", - item.invite_count as u64, - &[], - )); - } - if item.link.is_some() { - parts.push(gettext("Public link")); - } - if parts.is_empty() { - // Translators: a shared item's summary when no people or link can be counted. - pgettext("state", "Shared") - } else { - parts.join(" · ") - } -} - -/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can -/// open on it. Only used when the item's path is known. +/// Build a [`DirEntry`] from a [`SharedItem`], for the file list and the +/// Share dialog, which addresses a pathless node by uid. pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry { DirEntry { name: item.name.clone(), @@ -347,5 +627,80 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry { shared_by: String::new(), shared_at: 0, shared_by_unverified: false, + trashed_at: 0, + trashed_from: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(members: usize, invites: usize, link: Option) -> SharedItem { + SharedItem { + uid: "vol~link".into(), + name: "Budget.ods".into(), + is_dir: false, + modified: 0, + path: "Work/Budget.ods".into(), + member_count: members, + invite_count: invites, + link, + } + } + + fn link(expires: Option, has_password: bool) -> PublicLinkInfo { + PublicLinkInfo { + id: "l".into(), + url: Some("https://drive.proton.me/urls/X#y".into()), + role: "viewer".into(), + expires, + has_password, + created: 0, + } + } + + #[test] + fn a_link_says_whether_it_still_works() { + assert_eq!(link_label(None, 100), "—"); + assert_eq!(link_label(Some(&link(None, false)), 100), "Active"); + assert_eq!(link_label(Some(&link(Some(100), false)), 100), "Expired"); + assert!(link_label(Some(&link(Some(200), false)), 100).starts_with("Expires ")); + assert_eq!( + link_label(Some(&link(None, true)), 100), + "Active · Password protected" + ); + } + + #[test] + fn the_filters_pick_links_people_and_expired_links() { + let people = item(2, 0, None); + let invited = item(0, 1, None); + let live = item(0, 0, Some(link(None, false))); + let expired = item(0, 0, Some(link(Some(50), false))); + let pick = |filter| { + [&people, &invited, &live, &expired] + .iter() + .map(|i| matches_filter(i, filter, 100)) + .collect::>() + }; + assert_eq!(pick(ShareFilter::All), [true, true, true, true]); + assert_eq!(pick(ShareFilter::Links), [false, false, true, true]); + assert_eq!(pick(ShareFilter::People), [true, true, false, false]); + assert_eq!(pick(ShareFilter::Expired), [false, false, false, true]); + } + + #[test] + fn the_location_is_the_folder_in_my_files() { + assert_eq!(location_label("Work/Budget.ods"), "Work"); + assert_eq!(location_label("Budget.ods"), "My files"); + assert_eq!(location_label(""), "—"); + } + + #[test] + fn people_counts_members_and_pending_invitations() { + assert_eq!(people_label(&item(0, 0, None)), "—"); + assert_eq!(people_label(&item(1, 0, None)), "1 person"); + assert_eq!(people_label(&item(2, 3, None)), "2 people · 3 pending"); } } diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index ecb92f9..eebe224 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -642,8 +642,10 @@ pub(crate) fn wire_settings( let on = row.is_active(); set_proton_theme(on); // Folders swap their icon with the theme; have every row bound again. - let rows = ui_theme.browser.model.n_items(); - ui_theme.browser.model.items_changed(0, rows, rows); + for list in file_lists(&ui_theme) { + let rows = list.model.n_items(); + list.model.items_changed(0, rows, rows); + } let mut config = ui_theme.dirs.load_config(); config.proton_theme = Some(on); if let Err(e) = ui_theme.dirs.save_config(&config) { diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs index a8c5be3..f83bdaa 100644 --- a/crates/pdfs-gui/src/app/pages/trash.rs +++ b/crates/pdfs-gui/src/app/pages/trash.rs @@ -3,15 +3,14 @@ use crate::*; pub(crate) struct TrashState { // Trash page. Trashed nodes are addressed by uid, not by path, so this page // keeps no current-directory state — it re-lists from the daemon on show. - pub(crate) model: gio::ListStore, + pub(crate) files: FileList, pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, pub(crate) retry: gtk4::Button, - /// Empties the trash; insensitive while it is empty (or unread). + /// Empties the trash; hidden while it is empty (or unread). pub(crate) empty: gtk4::Button, /// "12 items" under the page title. pub(crate) subtitle: adw::WindowTitle, - pub(crate) selection: gtk4::MultiSelection, /// The bottom bar acting on the selection; revealed while anything is selected. pub(crate) selection_bar: gtk4::Revealer, pub(crate) selection_label: gtk4::Label, @@ -21,33 +20,30 @@ pub(crate) struct TrashState { /// The widgets of the Trash page that a load repaints. pub(crate) struct TrashWidgets { - pub(crate) model: gio::ListStore, - pub(crate) list: gtk4::ListView, + pub(crate) files: FileList, pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, pub(crate) retry: gtk4::Button, pub(crate) empty: gtk4::Button, pub(crate) refresh: gtk4::Button, pub(crate) subtitle: adw::WindowTitle, - pub(crate) selection: gtk4::MultiSelection, pub(crate) selection_bar: gtk4::Revealer, pub(crate) selection_label: gtk4::Label, pub(crate) restore_selected: gtk4::Button, pub(crate) delete_selected: gtk4::Button, } -/// The Trash page: a flat list of everything Drive is holding in the trash, each -/// row offering Restore and Delete Forever, with Empty Trash in the header. A -/// selection (click, Shift/Ctrl+click, Ctrl+A) gets the same two actions in a -/// bottom bar. +/// The Trash page: everything Drive is holding in the trash, as a list with +/// the folder each item came from and when it was deleted, or as a grid, where +/// photos are easier to recognise. Restore and Delete Permanently sit in the +/// right-click menu and, for a selection, in a bottom bar; Empty Trash is in +/// the header while there is something to empty. /// -/// A trashed node has no path inside the mount — the daemon forgets it when it is -/// trashed — so unlike the Files page this one addresses entries by uid and always -/// re-lists from the server rather than from a cached listing. Row rendering needs -/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`]. +/// A trashed node has no path inside the mount — the daemon forgets it when it +/// is trashed — so unlike the Files page this one addresses entries by uid. The +/// list's factories need the [`Ui`] handle, so they are installed in +/// [`wire_trash`]. pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { - let model = gio::ListStore::new::(); - let empty = gtk4::Button::builder() .label(gettext("Empty Trash…")) .tooltip_text(gettext("Permanently delete everything in the Trash")) @@ -69,28 +65,27 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { .build(); status.add_css_class("compact"); - // Bulk Delete Forever always confirms with the count, so a stale selection - // can't delete anything silently. - let selection = gtk4::MultiSelection::new(Some(model.clone())); - let list = gtk4::ListView::builder().model(&selection).build(); - let scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&list) - .build(); + // The list first: where an item came from and when it went are what tell + // two copies of a file apart here. + let files = FileList::new(); + files.show_list(true); + let layout = layout_button(&files); 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(&files.views, Some("list")); content.add_named(&status, Some("status")); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(18); - inner.set_margin_bottom(18); - inner.set_margin_start(18); - inner.set_margin_end(18); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); inner.append(&content); + // Bulk Delete Permanently always confirms with the count, so a stale + // selection can't delete anything silently. let selection_label = gtk4::Label::new(None); let restore_selected = gtk4::Button::with_label(&pgettext("verb", "Restore")); let delete_selected = gtk4::Button::with_label(&gettext("Delete Permanently…")); @@ -108,19 +103,18 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner); header.pack_start(&empty); header.pack_end(&refresh); + header.pack_end(&layout); ( frame.upcast(), TrashWidgets { - model, - list, + files, content, status, retry, empty, refresh, subtitle, - selection, selection_bar, selection_label, restore_selected, @@ -129,142 +123,144 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { ) } -/// Install the row factory and the Empty Trash button. The row's two buttons read -/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a -/// captured copy, so a recycled row always acts on the item it currently shows. +/// Install the list's factories and columns, the Empty Trash button and the +/// selection bar. pub(crate) fn wire_trash(ui: &Rc, widgets: &TrashWidgets) { - let list = &widgets.list; - let empty = &widgets.empty; - let factory = gtk4::SignalListItemFactory::new(); - let ui_setup = ui.clone(); - factory.connect_setup(move |_, item| { - let item = item.downcast_ref::().unwrap(); - - let thumbnail = file_thumbnail_widget(40, 32); - let name = gtk4::Label::builder() - .halign(gtk4::Align::Start) - .ellipsize(gtk4::pango::EllipsizeMode::Middle) - .build(); - let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build(); - meta.add_css_class("dim-label"); - meta.add_css_class("caption"); - let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2); - text.set_hexpand(true); - text.set_valign(gtk4::Align::Center); - text.append(&name); - text.append(&meta); - - let restore = gtk4::Button::builder() - .icon_name("edit-undo-symbolic") - .tooltip_text(gettext("Restore to its original folder")) - .valign(gtk4::Align::Center) - .build(); - restore.add_css_class("flat"); - let purge = gtk4::Button::builder() - .icon_name("edit-delete-symbolic") - .tooltip_text(gettext("Delete permanently")) - .valign(gtk4::Align::Center) - .build(); - purge.add_css_class("flat"); - - let ui_restore = ui_setup.clone(); - let item_restore = item.clone(); - restore.connect_clicked(move |_| { - if let Some(entry) = bound_entry(&item_restore) { - restore_entry(&ui_restore, &entry); + let files = &ui.trash.files; + files.wire( + ui, + FileListBehavior { + activate: activate_trashed, + entry_menu: trash_entry_menu, + bulk_menu: trash_bulk_menu, + background_menu: trash_background_menu, + badges: false, + drag_and_drop: false, + }, + ); + files + .column_view + .append_column(&text_column(&gettext("Original Location"), |e| { + trashed_from_label(e) + })); + files + .column_view + .append_column(&text_column(&pgettext("column", "Deleted"), |e| { + if e.trashed_at > 0 { + dates::relative(e.trashed_at) + } else { + "—".to_string() } - }); - let ui_purge = ui_setup.clone(); - let item_purge = item.clone(); - purge.connect_clicked(move |_| { - if let Some(entry) = bound_entry(&item_purge) { - prompt_delete_forever(&ui_purge, std::slice::from_ref(&entry)); + })); + files + .column_view + .append_column(&text_column(&pgettext("column", "Size"), |e| { + if e.is_dir { + "—".to_string() + } else { + human_bytes(e.size) } - }); - - let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - row.set_margin_top(6); - row.set_margin_bottom(6); - row.set_margin_start(6); - row.set_margin_end(6); - row.append(&thumbnail); - row.append(&text); - row.append(&restore); - row.append(&purge); - item.set_child(Some(&row)); - }); - factory.connect_bind({ - let ui = ui.clone(); - move |_, item| { - let item = item.downcast_ref::().unwrap(); - let Some(entry) = bound_entry(item) else { - return; - }; - let row = item.child().and_downcast::().unwrap(); - let Some(thumbnail) = row.first_child().and_downcast::() else { - return; - }; - bind_file_thumbnail(&ui, &thumbnail, &entry, true); - let Some(text) = thumbnail.next_sibling().and_downcast::() else { - return; - }; - if let Some(name) = text.first_child().and_downcast::() { - name.set_label(&entry.name); - } - if let Some(meta) = text.last_child().and_downcast::() { - let kind = if entry.is_dir { - gettext("Folder") - } else { - human_bytes(entry.size) - }; - let modified = dates::short_date(entry.modified); - // Translators: {kind} is "Folder" or a file size; {modified} is a date. - let text = gettext_f( - "{kind} · {modified}", - &[("kind", &kind), ("modified", &modified)], - ); - meta.set_label(&text); - } - } - }); - list.set_factory(Some(&factory)); + })); let ui_empty = ui.clone(); - empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty)); + widgets + .empty + .connect_clicked(move |_| prompt_empty_trash(&ui_empty)); let ui_sel = ui.clone(); - widgets + files .selection .connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel)); let ui_restore = ui.clone(); widgets.restore_selected.connect_clicked(move |_| { - let entries = selected_trash(&ui_restore); + let entries = ui_restore.trash.files.selected(); if !entries.is_empty() { restore_entries(&ui_restore, &entries); } }); let ui_delete = ui.clone(); widgets.delete_selected.connect_clicked(move |_| { - let entries = selected_trash(&ui_delete); + let entries = ui_delete.trash.files.selected(); if !entries.is_empty() { prompt_delete_forever(&ui_delete, &entries); } }); } -/// The trashed entries currently selected, in list order. -fn selected_trash(ui: &Rc) -> Vec { - let selected = ui.trash.selection.selection(); - (0..selected.size()) - .filter_map(|i| ui.trash.model.item(selected.nth(i as u32))) - .filter_map(|obj| obj.downcast::().ok()) - .map(|obj| obj.borrow::().clone()) - .collect() +/// Where a trashed entry goes back to on restore, for the Original Location +/// column. +fn trashed_from_label(entry: &DirEntry) -> String { + match entry.trashed_from.as_deref() { + None => "—".to_string(), + Some("") => gettext("My files"), + Some(path) => path.to_string(), + } +} + +/// A trashed file can't be opened; say what would let it be. +fn activate_trashed(ui: &Rc, entry: &DirEntry) { + toast( + ui, + // Translators: {name} is a file or folder name. + &gettext_f("Restore “{name}” to open it", &[("name", &entry.name)]), + ); +} + +fn trash_entry_menu(ui: &Rc, entry: &DirEntry) -> ActionMenu { + let mut menu = ActionMenu::new(); + let (ui_c, entry_c) = (ui.clone(), entry.clone()); + menu.item(&pgettext("verb", "Restore"), move || { + restore_entries(&ui_c, std::slice::from_ref(&entry_c)) + }); + menu.section(); + let (ui_c, entry_c) = (ui.clone(), entry.clone()); + menu.item(&gettext("Delete Permanently…"), move || { + prompt_delete_forever(&ui_c, std::slice::from_ref(&entry_c)) + }); + menu +} + +fn trash_bulk_menu(ui: &Rc, entries: Vec) -> ActionMenu { + let mut menu = ActionMenu::new(); + menu.labelled_section(&ngettext_f( + "{n} selected", + "{n} selected", + entries.len() as u64, + &[], + )); + let (ui_c, batch) = (ui.clone(), entries.clone()); + menu.item(&pgettext("verb", "Restore"), move || { + restore_entries(&ui_c, &batch) + }); + menu.section(); + let ui_c = ui.clone(); + menu.item(&gettext("Delete Permanently…"), move || { + prompt_delete_forever(&ui_c, &entries) + }); + menu +} + +fn trash_background_menu(ui: &Rc) -> ActionMenu { + let mut menu = ActionMenu::new(); + let ui_c = ui.clone(); + menu.item(&gettext("Select All"), move || { + ui_c.trash.files.selection.select_all(); + }); + let ui_c = ui.clone(); + menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c)); + if ui.trash.files.model.n_items() > 0 { + menu.section(); + let ui_c = ui.clone(); + menu.item(&gettext("Empty Trash…"), move || { + prompt_empty_trash(&ui_c) + }); + } + menu } /// Reveal the selection bar while anything is selected and count what is. fn sync_trash_selection(ui: &Rc) { - let count = ui.trash.selection.selection().size() as usize; + let count = ui.trash.files.selection.selection().size() as usize; ui.trash.selection_bar.set_reveal_child(count > 0); if count > 0 { ui.trash.selection_label.set_label(&ngettext_f( @@ -276,14 +272,6 @@ fn sync_trash_selection(ui: &Rc) { } } -/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound -/// (recycled) row. -pub(crate) fn bound_entry(item: >k4::ListItem) -> Option { - let obj = item.item().and_downcast::()?; - let entry = obj.borrow::().clone(); - Some(entry) -} - /// Fetch the trash listing and repaint the page. pub(crate) fn load_trash(ui: &Rc) { cancel_file_thumbnails(ui); @@ -352,8 +340,8 @@ pub(crate) fn trash_status(ui: &Rc, icon: &str, title: &str, description: &s ui.trash.subtitle.set_subtitle(""); } -/// Repopulate the trash list, most recently modified first — the order in which a -/// user looks for what they just deleted. +/// Repopulate the trash, most recently deleted first: the order in which a user +/// looks for what they just deleted. pub(crate) fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { // An empty Trash has nothing to empty, so the button goes away rather than // sitting there greyed out. @@ -379,45 +367,69 @@ 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)); + sort_trash(&mut sorted); // Only the rows that changed are swapped, so a refresh keeps the // selection and the scroll position. - replace_items(&ui.trash.model, &sorted); + replace_items(&ui.trash.files.model, &sorted); sync_trash_selection(ui); } +/// Newest deletion first. Entries with no known deletion time go last, newest +/// modification first among themselves. +fn sort_trash(entries: &mut [DirEntry]) { + entries.sort_by_key(|e| { + ( + e.trashed_at == 0, + std::cmp::Reverse(e.trashed_at), + std::cmp::Reverse(e.modified), + ) + }); +} + /// Drop every row, and with them the selection and its bar. fn clear_trash(ui: &Rc) { - ui.trash.model.remove_all(); + ui.trash.files.model.remove_all(); sync_trash_selection(ui); } -/// Restore one trashed entry to the folder it was trashed from. -pub(crate) fn restore_entry(ui: &Rc, entry: &DirEntry) { - restore_entries(ui, std::slice::from_ref(entry)); -} - -/// Restore trashed entries to the folders they were trashed from. +/// Restore trashed entries to the folders they were trashed from. When they +/// all came from one known folder, the toast offers to show it. pub(crate) fn restore_entries(ui: &Rc, entries: &[DirEntry]) { - run_mutation( + let message = match entries { + // Translators: {name} is a file or folder name. + [one] => gettext_f("Restored “{name}”", &[("name", &one.name)]), + _ => ngettext_f( + "Restored {n} item", + "Restored {n} items", + entries.len() as u64, + &[], + ), + }; + let folder = common_origin(entries); + run_mutation_then( ui, Request::Restore { uids: entries.iter().map(|e| e.uid.clone()).collect(), }, - match entries { - // Translators: {name} is a file or folder name. - [one] => gettext_f("Restored “{name}”", &[("name", &one.name)]), - _ => ngettext_f( - "Restored {n} item", - "Restored {n} items", - entries.len() as u64, - &[], - ), - }, gettext_noop("Couldn't restore"), + move |ui| match folder { + Some(folder) => toast_action(ui, &message, &gettext("Show"), move |ui| { + open_in_my_files(ui, folder.clone()) + }), + None => toast(ui, &message), + }, ); } +/// The folder every entry goes back to, when that is one known folder. +fn common_origin(entries: &[DirEntry]) -> Option { + let first = entries.first()?.trashed_from.clone()?; + entries + .iter() + .all(|e| e.trashed_from.as_deref() == Some(first.as_str())) + .then_some(first) +} + /// Confirm, then permanently delete trashed entries. Irreversible, so it asks. pub(crate) fn prompt_delete_forever(ui: &Rc, entries: &[DirEntry]) { let win = ui_window(ui); @@ -475,7 +487,7 @@ pub(crate) fn prompt_delete_forever(ui: &Rc, entries: &[DirEntry]) { /// Confirm, then permanently delete everything in the trash. pub(crate) fn prompt_empty_trash(ui: &Rc) { let win = ui_window(ui); - let count = ui.trash.model.n_items(); + let count = ui.trash.files.model.n_items(); let dialog = adw::AlertDialog::builder() .heading(gettext("Empty Trash?")) .body(ngettext_f( @@ -504,3 +516,59 @@ pub(crate) fn prompt_empty_trash(ui: &Rc) { }); dialog.present(win.as_ref()); } + +#[cfg(test)] +mod tests { + use super::*; + + fn trashed(name: &str, trashed_at: i64, modified: i64, from: Option<&str>) -> DirEntry { + DirEntry { + name: name.into(), + is_dir: false, + size: 0, + modified, + pinned: false, + cached: false, + uid: name.into(), + path: String::new(), + role: String::new(), + shared_by: String::new(), + shared_at: 0, + shared_by_unverified: false, + trashed_at, + trashed_from: from.map(str::to_string), + } + } + + #[test] + fn trash_lists_newest_deletion_first_and_undated_last() { + let mut entries = vec![ + trashed("undated-old", 0, 1, None), + trashed("older", 10, 50, None), + trashed("undated-new", 0, 9, None), + trashed("newest", 20, 5, None), + ]; + sort_trash(&mut entries); + let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["newest", "older", "undated-new", "undated-old"]); + } + + #[test] + fn restore_offers_a_folder_only_when_all_share_one() { + let docs = trashed("a", 1, 1, Some("Documents")); + let docs2 = trashed("b", 1, 1, Some("Documents")); + let root = trashed("c", 1, 1, Some("")); + let unknown = trashed("d", 1, 1, None); + assert_eq!( + common_origin(&[docs.clone(), docs2]).as_deref(), + Some("Documents") + ); + assert_eq!( + common_origin(std::slice::from_ref(&root)).as_deref(), + Some("") + ); + assert_eq!(common_origin(&[docs.clone(), root]), None); + assert_eq!(common_origin(&[docs, unknown]), None); + assert_eq!(common_origin(&[]), None); + } +} diff --git a/crates/pdfs-gui/src/app/widgets/file_list.rs b/crates/pdfs-gui/src/app/widgets/file_list.rs new file mode 100644 index 0000000..3007ec9 --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/file_list.rs @@ -0,0 +1,522 @@ +//! The file list every file page shows: an icon grid and a column list over one +//! model and one selection, with thumbnails, context menus, rubberband selection +//! and click-to-deselect. My files, Trash, Shared with me and Shared by me build +//! one each and differ only in their columns, menus and what activating an entry +//! does (see [`FileListBehavior`]). + +use crate::*; + +/// Where a page's entries go when the user acts on them. Plain function pointers: +/// each page has one of these, fixed for the life of the window. +#[derive(Clone, Copy)] +pub(crate) struct FileListBehavior { + /// Double-click or Enter on an entry. + pub(crate) activate: fn(&Rc, &DirEntry), + /// The right-click menu for one entry. + pub(crate) entry_menu: fn(&Rc, &DirEntry) -> ActionMenu, + /// The right-click menu for a multi-selection the clicked entry is part of. + pub(crate) bulk_menu: fn(&Rc, Vec) -> ActionMenu, + /// The right-click menu on empty space. + pub(crate) background_menu: fn(&Rc) -> ActionMenu, + /// Paint the offline/cached/online-only badge. Only entries in the mounted + /// tree have a local state worth showing. + pub(crate) badges: bool, + /// Entries can be dragged onto folders to move them. + pub(crate) drag_and_drop: bool, +} + +/// A realised grid cell's thumbnail and name label. +type GridTile = (glib::WeakRef, glib::WeakRef); + +/// One page's grid and list. Cheap to clone: every field is a GObject or an +/// `Rc`, so a clone is the same list. +#[derive(Clone)] +pub(crate) struct FileList { + /// The entries, as [`BoxedAnyObject`]-wrapped [`DirEntry`]s. + pub(crate) model: gio::ListStore, + /// One selection for both views: switching layout keeps what is selected. + pub(crate) selection: gtk4::MultiSelection, + pub(crate) grid: gtk4::GridView, + pub(crate) column_view: gtk4::ColumnView, + /// The two views, named "grid" and "list". + pub(crate) views: gtk4::Stack, + thumbnail_size: Rc>, + /// Weak references to realised grid cells. A new thumbnail size resizes only + /// these visible, recycled surfaces instead of invalidating the whole model. + tiles: Rc>>, + behavior: Rc>>, +} + +impl FileList { + /// Assemble the empty views. The factories need the [`Ui`] handle, so they + /// are installed later by [`FileList::wire`]. + pub(crate) fn new() -> Self { + let model = gio::ListStore::new::(); + // Multi-select: acting on a batch is the common case, and doing it one + // confirmation dialog at a time is not a workflow. Rubberband drags a + // selection box from empty space, as a file manager does. + let selection = gtk4::MultiSelection::new(Some(model.clone())); + let grid = gtk4::GridView::builder() + .model(&selection) + .min_columns(2) + .max_columns(32) + .enable_rubberband(true) + .build(); + grid.add_css_class("file-grid"); + let grid_scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&grid) + .build(); + + let column_view = gtk4::ColumnView::builder() + .model(&selection) + .enable_rubberband(true) + .build(); + column_view.add_css_class("data-table"); + let column_scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&column_view) + .build(); + + let views = gtk4::Stack::new(); + views.set_vexpand(true); + views.add_named(&grid_scroll, Some("grid")); + views.add_named(&column_scroll, Some("list")); + + FileList { + model, + selection, + grid, + column_view, + views, + thumbnail_size: Rc::new(Cell::new(GRID_THUMB_DEFAULT)), + tiles: Rc::new(RefCell::new(Vec::new())), + behavior: Rc::new(Cell::new(None)), + } + } + + /// Install the grid tiles, the Name column, activation, the context menus + /// and click-to-deselect. The page appends its other columns afterwards. + pub(crate) fn wire(&self, ui: &Rc, behavior: FileListBehavior) { + self.behavior.set(Some(behavior)); + for view in [ + self.grid.clone().upcast::(), + self.column_view.clone().upcast(), + ] { + self.attach_background_menu(ui, &view, behavior); + self.attach_background_deselect(&view); + } + + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup({ + let (ui, list) = (ui.clone(), self.clone()); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let size = list.thumbnail_size.get(); + let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size)); + // Keep the sync-state badge inside the thumbnail surface. + let badge = gtk4::Image::builder() + .pixel_size(18) + .halign(gtk4::Align::End) + .valign(gtk4::Align::Start) + .margin_top(2) + .margin_end(2) + .visible(false) + .build(); + badge.add_css_class("file-badge"); + thumbnail.add_overlay(&badge); + // `WordChar` rather than the default `Word`: a name with no + // spaces offers no word-break opportunity, so word wrapping + // cannot break it at all and the label asks for its full natural + // width instead — one tile stretches to the width of the window + // and the grid collapses to a single column. Allowing a mid-word + // break is what keeps the two-line-then-ellipsis budget below + // enforceable for *every* name rather than only the ones that + // happen to have spaces. + let label = gtk4::Label::builder() + .ellipsize(gtk4::pango::EllipsizeMode::End) + .justify(gtk4::Justification::Center) + .max_width_chars(13) + .width_chars(13) + .wrap(true) + .wrap_mode(gtk4::pango::WrapMode::WordChar) + .lines(2) + .build(); + list.tiles + .borrow_mut() + .push((thumbnail.downgrade(), label.downgrade())); + let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + tile.add_css_class("file-tile"); + tile.append(&thumbnail); + tile.append(&label); + list.attach_context_menu(&ui, item, &tile, behavior); + if behavior.drag_and_drop { + attach_drag(&ui, item, &tile); + attach_drop(&ui, item, &tile); + } + item.set_child(Some(&tile)); + } + }); + factory.connect_bind({ + let (ui, list) = (ui.clone(), self.clone()); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let tile = item.child().and_downcast::().unwrap(); + let thumbnail = tile.first_child().and_downcast::().unwrap(); + let badge = thumbnail + .last_child() + .and_downcast::() + .unwrap(); + let label = thumbnail + .next_sibling() + .and_downcast::() + .unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + resize_grid_tile(&thumbnail, &label, list.thumbnail_size.get()); + bind_file_thumbnail(&ui, &thumbnail, &entry, false); + label.set_label(&entry.name); + label.set_tooltip_text(Some(&entry.name)); + if behavior.badges { + apply_badge(&badge, &entry); + } + } + }); + self.grid.set_factory(Some(&factory)); + + let ui_grid = ui.clone(); + self.grid.connect_activate(move |grid, pos| { + if let Some(entry) = entry_at(grid.model().as_ref(), pos) { + (behavior.activate)(&ui_grid, &entry); + } + }); + + self.column_view + .append_column(&self.name_column(ui, behavior)); + let ui_col = ui.clone(); + self.column_view.connect_activate(move |view, pos| { + if let Some(entry) = entry_at(view.model().as_ref(), pos) { + (behavior.activate)(&ui_col, &entry); + } + }); + } + + /// Every highlighted entry, in model order. + /// + /// Walks the model rather than the selection bitset: a listing is at most a + /// few thousand rows, and asking each position whether it is selected keeps + /// this free of bitset-iterator lifetimes for no measurable cost. + pub(crate) fn selected(&self) -> Vec { + (0..self.selection.n_items()) + .filter(|i| self.selection.is_selected(*i)) + .filter_map(|i| entry_at(Some(&self.selection), i)) + .collect() + } + + /// Show the grid, or the list. + pub(crate) fn show_list(&self, list: bool) { + self.views + .set_visible_child_name(if list { "list" } else { "grid" }); + } + + /// Whether the list, rather than the grid, is on screen. + pub(crate) fn shows_list(&self) -> bool { + self.views.visible_child_name().as_deref() == Some("list") + } + + /// Resize the grid's thumbnails. Only realised cells are touched; the rest + /// pick the size up when they are bound. + pub(crate) fn set_thumbnail_size(&self, size: i32) { + if self.thumbnail_size.replace(size) == size { + return; + } + self.tiles + .borrow_mut() + .retain(|(thumbnail_ref, label_ref)| { + let (Some(thumbnail), Some(label)) = (thumbnail_ref.upgrade(), label_ref.upgrade()) + else { + return false; + }; + resize_grid_tile(&thumbnail, &label, size); + true + }); + self.grid.queue_resize(); + } + + /// The Menu key or Shift+F10: the menu for the selection, or the background + /// menu when nothing is selected, opened at the focused item. + pub(crate) fn popup_keyboard_menu(&self, ui: &Rc) { + let Some(behavior) = self.behavior.get() else { + return; + }; + let view = self + .views + .visible_child() + .unwrap_or_else(|| self.views.clone().upcast()); + // The focused cell is where the eye is; without one, the middle of the view. + let (x, y) = view + .root() + .and_then(|root| root.focus()) + .filter(|focus| focus.is_ancestor(&view)) + .and_then(|focus| focus.compute_bounds(&view)) + .map(|b| { + ( + (b.x() + b.width() / 2.0) as f64, + (b.y() + b.height() / 2.0) as f64, + ) + }) + .unwrap_or((view.width() as f64 / 2.0, view.height() as f64 / 2.0)); + let selected = self.selected(); + let menu = match selected.len() { + 0 => (behavior.background_menu)(ui), + 1 => (behavior.entry_menu)(ui, &selected[0]), + _ => (behavior.bulk_menu)(ui, selected), + }; + menu.popup_at(&view, x, y); + } + + /// The Name column: a small thumbnail with its local-state badge overlaid, + /// followed by the name and the same right-click menu the grid tiles carry. + fn name_column(&self, ui: &Rc, behavior: FileListBehavior) -> gtk4::ColumnViewColumn { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup({ + let (ui, list) = (ui.clone(), self.clone()); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let thumbnail = file_thumbnail_widget(28, 16); + let badge = gtk4::Image::builder() + .pixel_size(14) + .halign(gtk4::Align::End) + .valign(gtk4::Align::Start) + .visible(false) + .build(); + badge.add_css_class("file-badge"); + thumbnail.add_overlay(&badge); + // Ellipsized so the Name column can be *narrower* than its + // longest name. Without it the label's minimum width is the whole + // string, the column inherits that minimum, and one long name + // pushes the other columns off the right edge of the window for + // every row. + let label = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + cell.append(&thumbnail); + cell.append(&label); + list.attach_context_menu(&ui, item, &cell, behavior); + if behavior.drag_and_drop { + attach_drag(&ui, item, &cell); + attach_drop(&ui, item, &cell); + } + item.set_child(Some(&cell)); + } + }); + factory.connect_bind({ + let ui = ui.clone(); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let cell = item.child().and_downcast::().unwrap(); + let thumbnail = cell.first_child().and_downcast::().unwrap(); + let badge = thumbnail + .last_child() + .and_downcast::() + .unwrap(); + let label = thumbnail + .next_sibling() + .and_downcast::() + .unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + bind_file_thumbnail(&ui, &thumbnail, &entry, true); + label.set_label(&entry.name); + label.set_tooltip_text(Some(&entry.name)); + if behavior.badges { + apply_badge(&badge, &entry); + } + } + }); + let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Name")), Some(factory)); + column.set_expand(true); + column + } + + /// Pop the menu for whatever entry `item` is bound to when a cell is + /// right-clicked. Capturing the [`gtk4::ListItem`] rather than a snapshot of + /// the entry keeps the menu right as the view recycles cells while + /// scrolling. The click is claimed, so the background menu does not open on + /// top of it. + fn attach_context_menu( + &self, + ui: &Rc, + item: >k4::ListItem, + anchor: >k4::Box, + behavior: FileListBehavior, + ) { + let gesture = gtk4::GestureClick::new(); + gesture.set_button(gtk4::gdk::BUTTON_SECONDARY); + let (ui, list, item, target) = (ui.clone(), self.clone(), item.clone(), anchor.clone()); + gesture.connect_pressed(move |gesture, _, x, y| { + gesture.set_state(gtk4::EventSequenceState::Claimed); + if let Some(obj) = item.item().and_downcast::() { + let entry = obj.borrow::().clone(); + // Right-clicking a row that is part of a multi-selection acts on + // the batch; right-clicking outside one acts on the row. + let selected = list.selected(); + if selected.len() > 1 && selected.iter().any(|e| e.uid == entry.uid) { + (behavior.bulk_menu)(&ui, selected).popup_at(&target, x, y); + } else { + (behavior.entry_menu)(&ui, &entry).popup_at(&target, x, y); + } + } + }); + anchor.add_controller(gesture); + } + + /// Right-clicking the empty space of a view offers the page's own actions. + fn attach_background_menu(&self, ui: &Rc, view: >k4::Widget, behavior: FileListBehavior) { + let gesture = gtk4::GestureClick::new(); + gesture.set_button(gtk4::gdk::BUTTON_SECONDARY); + let (ui, target) = (ui.clone(), view.clone()); + gesture.connect_pressed(move |_, _, x, y| { + (behavior.background_menu)(&ui).popup_at(&target, x, y); + }); + view.add_controller(gesture); + } + + /// Clicking the empty space of a view drops the selection, as in a file + /// manager. GTK's list views leave it alone, so a stray highlight otherwise + /// sticks around until another item is clicked. + fn attach_background_deselect(&self, view: >k4::Widget) { + let gesture = gtk4::GestureClick::new(); + gesture.set_button(gtk4::gdk::BUTTON_PRIMARY); + // Capture sees the press before an item's own gesture claims it; this + // only looks, so the item still gets its click. + gesture.set_propagation_phase(gtk4::PropagationPhase::Capture); + let (selection, target) = (self.selection.clone(), view.clone()); + gesture.connect_pressed(move |gesture, _, x, y| { + let modifiers = gesture.current_event_state(); + if modifiers.intersects( + gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::SHIFT_MASK, + ) { + return; + } + if !on_item(&target, x, y) { + selection.unselect_all(); + } + }); + view.add_controller(gesture); + } +} + +/// A header button that switches `list` between grid and list. It offers the +/// other layout, the way a toggle reads. +pub(crate) fn layout_button(list: &FileList) -> gtk4::Button { + let button = gtk4::Button::builder().valign(gtk4::Align::Center).build(); + let sync = |button: >k4::Button, list: bool| { + button.set_icon_name(if list { + "view-grid-symbolic" + } else { + "view-list-symbolic" + }); + button.set_tooltip_text(Some(&if list { + gettext("Show as grid (Ctrl+1)") + } else { + gettext("Show as list (Ctrl+2)") + })); + }; + sync(&button, list.shows_list()); + let weak = button.downgrade(); + list.views.connect_visible_child_name_notify(move |views| { + if let Some(button) = weak.upgrade() { + sync( + &button, + views.visible_child_name().as_deref() == Some("list"), + ); + } + }); + let views = list.views.clone(); + button.connect_clicked(move |_| { + let to_list = views.visible_child_name().as_deref() != Some("list"); + views.set_visible_child_name(if to_list { "list" } else { "grid" }); + }); + button +} + +/// Build a trailing text column whose cell text is derived from each [`DirEntry`] +/// by `render`. +pub(crate) fn text_column( + title: &str, + render: impl Fn(&DirEntry) -> String + 'static, +) -> gtk4::ColumnViewColumn { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let item = item.downcast_ref::().unwrap(); + let label = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + label.add_css_class("dim-label"); + item.set_child(Some(&label)); + }); + factory.connect_bind(move |_, item| { + let item = item.downcast_ref::().unwrap(); + let label = item.child().and_downcast::().unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + let text = render(&entry); + label.set_tooltip_text(Some(&text)); + label.set_label(&text); + }); + gtk4::ColumnViewColumn::new(Some(title), Some(factory)) +} + +fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) { + resize_file_thumbnail(thumbnail, size, grid_fallback_size(size)); + let name_width = (size / 6 + 1).clamp(8, 24); + label.set_width_chars(name_width); + label.set_max_width_chars(name_width); +} + +fn grid_fallback_size(thumbnail_size: i32) -> i32 { + (thumbnail_size * 8 / 9).clamp(24, thumbnail_size) +} + +/// Whether `(x, y)` in `view` lands on an item: a grid tile ("child"), a list +/// row ("row"), or a column header ("header"), which has clicks of its own. +fn on_item(view: >k4::Widget, x: f64, y: f64) -> bool { + let mut widget = view.pick(x, y, gtk4::PickFlags::DEFAULT); + while let Some(w) = widget { + if &w == view { + return false; + } + if matches!(w.css_name().as_str(), "child" | "row" | "header") { + return true; + } + widget = w.parent(); + } + false +} + +/// Every page's file list. +pub(crate) fn file_lists(ui: &Ui) -> [&FileList; 4] { + [ + &ui.browser.files, + &ui.trash.files, + &ui.shared.files, + &ui.shared_by_me.files, + ] +} + +/// The file list of the page on screen, if it has one. +pub(crate) fn visible_file_list(ui: &Ui) -> Option<&FileList> { + match ui.stack.visible_child_name()?.as_str() { + "browser" => Some(&ui.browser.files), + "trash" => Some(&ui.trash.files), + "sharedbyme" => Some(&ui.shared_by_me.files), + "shared" if ui.shared.views.visible_child_name().as_deref() == Some("files") => { + Some(&ui.shared.files) + } + _ => None, + } +} diff --git a/crates/pdfs-gui/src/app/widgets/mod.rs b/crates/pdfs-gui/src/app/widgets/mod.rs index 26f1af1..4d78e5e 100644 --- a/crates/pdfs-gui/src/app/widgets/mod.rs +++ b/crates/pdfs-gui/src/app/widgets/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod details; +pub(crate) mod file_list; pub(crate) mod menu; pub(crate) mod share_dialog; pub(crate) mod thumbnails; diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs index a6d86c8..5903b4e 100644 --- a/crates/pdfs-gui/src/app/widgets/share_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs @@ -407,6 +407,15 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { ); }); + // Shared by me lists what this dialog changes: reload it when it is the + // page underneath, and have the next visit fetch otherwise. + let ui_closed = ui.clone(); + dialog.connect_closed(move |_| { + ui_closed.shared_by_me.loaded_at.set(None); + if ui_closed.stack.visible_child_name().as_deref() == Some("sharedbyme") { + load_shared_by_me(&ui_closed); + } + }); share_dialog_reload(&state); dialog.present(ui_window(ui).as_ref()); } diff --git a/crates/pdfs-gui/src/app/widgets/thumbnails.rs b/crates/pdfs-gui/src/app/widgets/thumbnails.rs index 0ca2701..d874940 100644 --- a/crates/pdfs-gui/src/app/widgets/thumbnails.rs +++ b/crates/pdfs-gui/src/app/widgets/thumbnails.rs @@ -214,19 +214,6 @@ pub(crate) fn bind_file_thumbnail( schedule_file_thumbs(ui); } -/// Create and immediately bind a thumbnail for a non-recycled row. -pub(crate) fn file_thumbnail( - ui: &Rc, - entry: &DirEntry, - size: i32, - fallback_size: i32, - symbolic_fallback: bool, -) -> gtk4::Overlay { - let widget = file_thumbnail_widget(size, fallback_size); - bind_file_thumbnail(ui, &widget, entry, symbolic_fallback); - widget -} - /// Whether the file name denotes an image format for which Proton or the local /// decoder can reasonably provide a thumbnail. pub(crate) fn is_image_name(name: &str) -> bool { diff --git a/po/POTFILES.in b/po/POTFILES.in index 151e146..f13c475 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -19,6 +19,7 @@ crates/pdfs-gui/src/app/pages/takeout.rs crates/pdfs-gui/src/app/pages/trash.rs crates/pdfs-gui/src/app/pages/verify.rs crates/pdfs-gui/src/app/widgets/details.rs +crates/pdfs-gui/src/app/widgets/file_list.rs crates/pdfs-gui/src/app/widgets/menu.rs crates/pdfs-gui/src/app/widgets/share_dialog.rs crates/pdfs-gui/src/app/widgets/thumbnails.rs diff --git a/po/de.po b/po/de.po index fa43a73..5bf0b00 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:42+0200\n" +"POT-Creation-Date: 2026-09-28 14:16+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -57,106 +57,108 @@ 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: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/main.rs:961 +#: crates/pdfs-gui/src/app/pages/browser.rs:457 +#: crates/pdfs-gui/src/app/pages/browser.rs:824 +#: crates/pdfs-gui/src/app/pages/browser.rs:1222 #: crates/pdfs-gui/src/app/pages/locations.rs:1315 -#: crates/pdfs-gui/src/prompt.rs:169 +#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:260 +#: crates/pdfs-gui/src/app/pages/trash.rs:195 crates/pdfs-gui/src/prompt.rs:169 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:991 crates/pdfs-gui/src/app/main.rs:1634 +#: crates/pdfs-gui/src/app/main.rs:964 crates/pdfs-gui/src/app/main.rs:1617 #: 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:996 -#: crates/pdfs-gui/src/app/pages/shared.rs:119 +#: crates/pdfs-gui/src/app/main.rs:969 +#: crates/pdfs-gui/src/app/pages/shared.rs:169 +#: crates/pdfs-gui/src/app/pages/shared.rs:489 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:1001 -#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:64 +#: crates/pdfs-gui/src/app/main.rs:974 +#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:126 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:1004 +#: crates/pdfs-gui/src/app/main.rs:977 #: crates/pdfs-gui/src/app/pages/devices.rs:110 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:1005 -#: crates/pdfs-gui/src/app/pages/trash.rs:108 +#: crates/pdfs-gui/src/app/main.rs:978 +#: crates/pdfs-gui/src/app/pages/trash.rs:103 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:1007 +#: crates/pdfs-gui/src/app/main.rs:980 msgid "Sync" msgstr "Synchronisierung" -#: crates/pdfs-gui/src/app/main.rs:1010 +#: crates/pdfs-gui/src/app/main.rs:983 #: crates/pdfs-gui/src/app/pages/activity.rs:187 msgid "Activity" msgstr "Aktivität" -#: crates/pdfs-gui/src/app/main.rs:1156 +#: crates/pdfs-gui/src/app/main.rs:1139 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:1330 +#: crates/pdfs-gui/src/app/main.rs:1313 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1349 +#: crates/pdfs-gui/src/app/main.rs:1332 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1350 +#: crates/pdfs-gui/src/app/main.rs:1333 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1351 +#: crates/pdfs-gui/src/app/main.rs:1334 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1352 +#: crates/pdfs-gui/src/app/main.rs:1335 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1353 +#: crates/pdfs-gui/src/app/main.rs:1336 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1394 crates/pdfs-gui/src/app/main.rs:1616 +#: crates/pdfs-gui/src/app/main.rs:1377 crates/pdfs-gui/src/app/main.rs:1599 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "Einstellungen" -#: crates/pdfs-gui/src/app/main.rs:1395 crates/pdfs-gui/src/app/main.rs:1673 +#: crates/pdfs-gui/src/app/main.rs:1378 crates/pdfs-gui/src/app/main.rs:1656 msgid "Keyboard Shortcuts" msgstr "Tastenkürzel" -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1380 msgid "About Proton Drive for Linux" msgstr "Info zu Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1402 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "Main menu" msgstr "Hauptmenü" -#: crates/pdfs-gui/src/app/main.rs:1458 +#: crates/pdfs-gui/src/app/main.rs:1441 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1466 +#: crates/pdfs-gui/src/app/main.rs:1449 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -168,23 +170,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1471 +#: crates/pdfs-gui/src/app/main.rs:1454 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1475 +#: crates/pdfs-gui/src/app/main.rs:1458 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1479 +#: crates/pdfs-gui/src/app/main.rs:1462 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1484 +#: crates/pdfs-gui/src/app/main.rs:1467 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1488 +#: crates/pdfs-gui/src/app/main.rs:1471 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -209,7 +211,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1496 +#: crates/pdfs-gui/src/app/main.rs:1479 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1504 +#: crates/pdfs-gui/src/app/main.rs:1487 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -229,7 +231,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1512 +#: crates/pdfs-gui/src/app/main.rs:1495 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:1504 +#: crates/pdfs-gui/src/app/main.rs:1487 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +228,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1512 +#: crates/pdfs-gui/src/app/main.rs:1495 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:1504 +#: crates/pdfs-gui/src/app/main.rs:1487 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -227,7 +229,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1512 +#: crates/pdfs-gui/src/app/main.rs:1495 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:1504 +#: crates/pdfs-gui/src/app/main.rs:1487 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -228,7 +230,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1512 +#: crates/pdfs-gui/src/app/main.rs:1495 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:1504 +#: crates/pdfs-gui/src/app/main.rs:1487 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +228,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1512 +#: crates/pdfs-gui/src/app/main.rs:1495 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:1504 +#: crates/pdfs-gui/src/app/main.rs:1487 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -227,7 +229,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1512 +#: crates/pdfs-gui/src/app/main.rs:1495 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -60,106 +60,108 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: 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/main.rs:961 +#: crates/pdfs-gui/src/app/pages/browser.rs:457 +#: crates/pdfs-gui/src/app/pages/browser.rs:824 +#: crates/pdfs-gui/src/app/pages/browser.rs:1222 #: crates/pdfs-gui/src/app/pages/locations.rs:1315 -#: crates/pdfs-gui/src/prompt.rs:169 +#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:260 +#: crates/pdfs-gui/src/app/pages/trash.rs:195 crates/pdfs-gui/src/prompt.rs:169 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:991 crates/pdfs-gui/src/app/main.rs:1634 +#: crates/pdfs-gui/src/app/main.rs:964 crates/pdfs-gui/src/app/main.rs:1617 #: 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:996 -#: crates/pdfs-gui/src/app/pages/shared.rs:119 +#: crates/pdfs-gui/src/app/main.rs:969 +#: crates/pdfs-gui/src/app/pages/shared.rs:169 +#: crates/pdfs-gui/src/app/pages/shared.rs:489 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1001 -#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:64 +#: crates/pdfs-gui/src/app/main.rs:974 +#: crates/pdfs-gui/src/app/pages/shared_by_me.rs:126 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1004 +#: crates/pdfs-gui/src/app/main.rs:977 #: crates/pdfs-gui/src/app/pages/devices.rs:110 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1005 -#: crates/pdfs-gui/src/app/pages/trash.rs:108 +#: crates/pdfs-gui/src/app/main.rs:978 +#: crates/pdfs-gui/src/app/pages/trash.rs:103 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:1007 +#: crates/pdfs-gui/src/app/main.rs:980 msgid "Sync" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1010 +#: crates/pdfs-gui/src/app/main.rs:983 #: crates/pdfs-gui/src/app/pages/activity.rs:187 msgid "Activity" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1156 +#: crates/pdfs-gui/src/app/main.rs:1139 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:1330 +#: crates/pdfs-gui/src/app/main.rs:1313 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1349 +#: crates/pdfs-gui/src/app/main.rs:1332 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1350 +#: crates/pdfs-gui/src/app/main.rs:1333 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1351 +#: crates/pdfs-gui/src/app/main.rs:1334 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1352 +#: crates/pdfs-gui/src/app/main.rs:1335 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1353 +#: crates/pdfs-gui/src/app/main.rs:1336 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1394 crates/pdfs-gui/src/app/main.rs:1616 +#: crates/pdfs-gui/src/app/main.rs:1377 crates/pdfs-gui/src/app/main.rs:1599 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1395 crates/pdfs-gui/src/app/main.rs:1673 +#: crates/pdfs-gui/src/app/main.rs:1378 crates/pdfs-gui/src/app/main.rs:1656 msgid "Keyboard Shortcuts" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1380 msgid "About Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1402 +#: crates/pdfs-gui/src/app/main.rs:1385 msgid "Main menu" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1458 +#: crates/pdfs-gui/src/app/main.rs:1441 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1466 +#: crates/pdfs-gui/src/app/main.rs:1449 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -167,23 +169,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1471 +#: crates/pdfs-gui/src/app/main.rs:1454 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1475 +#: crates/pdfs-gui/src/app/main.rs:1458 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1479 +#: crates/pdfs-gui/src/app/main.rs:1462 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1484 +#: crates/pdfs-gui/src/app/main.rs:1467 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1488 +#: crates/pdfs-gui/src/app/main.rs:1471 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -196,21 +198,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1496 +#: crates/pdfs-gui/src/app/main.rs:1479 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1504 +#: crates/pdfs-gui/src/app/main.rs:1487 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:1512 +#: crates/pdfs-gui/src/app/main.rs:1495 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:1504 +#: crates/pdfs-gui/src/app/main.rs:1487 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -230,7 +232,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1512 +#: crates/pdfs-gui/src/app/main.rs:1495 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:1504 +#: crates/pdfs-gui/src/app/main.rs:1487 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +228,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1512 +#: crates/pdfs-gui/src/app/main.rs:1495 msgid "" "The street map, when turned on, comes from OpenFreeMap in the