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