diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 646c6b0..dd6da66 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -588,6 +588,11 @@ fn build_window(app: &adw::Application) { 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(), + banner: shared_widgets.banner.clone(), + scroll: shared_widgets.scroll.clone(), nav: RefCell::new(Vec::new()), rows: RefCell::new(Vec::new()), inflight: Cell::new(false), diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index c637357..ed95c93 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -10,6 +10,14 @@ pub(crate) struct SharedState { 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. + pub(crate) back: gtk4::Button, + pub(crate) add_bookmark: gtk4::Button, + /// "N pending invitations" above the sections at the top level. + pub(crate) banner: adw::Banner, + pub(crate) scroll: gtk4::ScrolledWindow, /// 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. @@ -32,6 +40,10 @@ pub(crate) struct SharedWidgets { 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, + pub(crate) banner: adw::Banner, + pub(crate) scroll: gtk4::ScrolledWindow, } /// The Shared page: three stacked sections — items shared *with* me (each with a @@ -94,7 +106,19 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { inner.set_margin_start(18); inner.set_margin_end(18); inner.append(&content); - let (frame, header, _) = page_frame("Shared with Me", &inner); + + let banner = adw::Banner::builder().button_label("Review").build(); + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + body.append(&banner); + body.append(&inner); + + let back = gtk4::Button::builder() + .icon_name("go-previous-symbolic") + .tooltip_text("Back") + .visible(false) + .build(); + let (frame, header, title) = page_frame("Shared with Me", &body); + header.pack_start(&back); header.pack_start(&add_bookmark); header.pack_end(&refresh); @@ -109,12 +133,37 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { retry, add_bookmark, refresh, + title, + back, + banner, + scroll, }, ) } -/// Install the Shared page's retry and Add-Bookmark buttons. +/// Install the Shared page's retry, back and Add-Bookmark buttons and the +/// invitations banner. pub(crate) fn wire_shared(ui: &Rc, retry: >k4::Button, add_bookmark: >k4::Button) { + let ui_back = ui.clone(); + ui.shared.back.connect_clicked(move |_| { + ui_back.shared.nav.borrow_mut().pop(); + load_shared(&ui_back); + }); + // Review scrolls the Invitations section into view; the page keeps it + // below the shares, where it is easy to miss on a long list. + let ui_review = ui.clone(); + ui.shared.banner.connect_button_clicked(move |_| { + let shared = &ui_review.shared; + let Some(child) = shared.scroll.child() else { + return; + }; + if let Some(bounds) = shared.invitations_group.compute_bounds(&child) { + shared.scroll.vadjustment().set_value(f64::from(bounds.y())); + } + shared + .invitations_group + .child_focus(gtk4::DirectionType::TabForward); + }); let ui_retry = ui.clone(); retry.connect_clicked(move |_| { service::restart(); @@ -237,8 +286,26 @@ fn load_shared_folder(ui: &Rc, uid: String) { }); } -/// Paint the contents of the shared folder at the top of the nav stack: a row to -/// go back up, then the children. Invitations and bookmarks are hidden here — +/// 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 invitations banner's text, or `None` when there are none to review. +pub(crate) fn invitations_banner_title(count: usize) -> Option { + match count { + 0 => None, + 1 => Some("1 pending invitation".to_string()), + n => Some(format!("{n} pending 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(..) { @@ -247,28 +314,17 @@ fn repaint_shared_folder(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.banner.set_revealed(false); + ui.shared.back.set_visible(true); + ui.shared.add_bookmark.set_visible(false); let nav = ui.shared.nav.borrow().clone(); - let title = nav - .iter() - .map(|(_, name)| name.as_str()) - .collect::>() - .join(" / "); - ui.shared.with_me_group.set_title(&title); + ui.shared.title.set_subtitle(&shared_path(&nav)); + ui.shared + .with_me_group + .set_title(nav.last().map_or("", |(_, name)| name.as_str())); let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); - let up = adw::ActionRow::builder() - .title("Back") - .activatable(true) - .build(); - up.add_prefix(>k4::Image::from_icon_name("go-up-symbolic")); - let ui_up = ui.clone(); - up.connect_activated(move |_| { - ui_up.shared.nav.borrow_mut().pop(); - load_shared(&ui_up); - }); - ui.shared.with_me_group.add(&up); - rows.push((ui.shared.with_me_group.clone(), up.upcast())); if entries.is_empty() { let row = dim_row("This folder is empty."); @@ -470,6 +526,16 @@ pub(crate) fn repaint_shared( ui.shared.invitations_group.set_visible(true); ui.shared.bookmarks_group.set_visible(true); ui.shared.with_me_group.set_title("Shared with me"); + ui.shared.title.set_subtitle(""); + ui.shared.back.set_visible(false); + ui.shared.add_bookmark.set_visible(true); + match invitations_banner_title(invitations.len()) { + Some(text) => { + ui.shared.banner.set_title(&text); + ui.shared.banner.set_revealed(true); + } + None => ui.shared.banner.set_revealed(false), + } let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); // Shared with me: name + Leave. @@ -800,4 +866,27 @@ 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"); + } + + #[test] + fn the_invitations_banner_counts_and_hides_at_zero() { + assert_eq!(invitations_banner_title(0), None); + assert_eq!( + invitations_banner_title(1).as_deref(), + Some("1 pending invitation") + ); + assert_eq!( + invitations_banner_title(3).as_deref(), + Some("3 pending invitations") + ); + } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f9d8f16..92a527d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -127,6 +127,9 @@ Schema: **31** (unchanged). - **New app icon.** A white cloud with the download arrow cut out of it, on a rounded Proton purple tile with a darker lower edge, drawn to the GNOME app-icon grid. It stays legible down to 16 px on light and dark panels. +- **Shared with Me.** Inside a shared folder the header shows the path from the share down, and a + back button in the header replaces the Back row. Pending invitations are announced in a banner + at the top of the page; Review scrolls to them. - **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the `pdfs favorite` command.