diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 2625d12..6edb6dd 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -51,8 +51,8 @@ use pdfs_core::control::{ ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo, DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem, - SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, - ThumbnailBuildStatus, TransferDirection, TransferItem, pending_summary, send, + RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, + SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem, pending_summary, send, }; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; @@ -697,6 +697,13 @@ fn build_window(app: &adw::Application) { .default_height(680) .content(&toasts) .build(); + // A wide window docks the details pane beside the files instead of laying it + // over them. + let wide = adw::Breakpoint::new( + adw::BreakpointCondition::parse("min-width: 1000sp").expect("static breakpoint"), + ); + wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value())); + window.add_breakpoint(wide); install_shortcuts(&ui, &window); install_window_actions(&ui, &window); app.set_accels_for_action("win.preferences", &["comma"]); diff --git a/crates/pdfs-gui/src/app/widgets/details.rs b/crates/pdfs-gui/src/app/widgets/details.rs index 61d4f59..a89830e 100644 --- a/crates/pdfs-gui/src/app/widgets/details.rs +++ b/crates/pdfs-gui/src/app/widgets/details.rs @@ -25,16 +25,22 @@ pub(crate) struct DetailsWidgets { pub(crate) pin_row: adw::SwitchRow, pub(crate) open_button: gtk4::Button, pub(crate) rename_button: gtk4::Button, + /// Who has access and whether there is a public link; filled on selection. + pub(crate) sharing_row: adw::ActionRow, + pub(crate) share_button: gtk4::Button, + pub(crate) copy_link_button: gtk4::Button, /// Files only — folders have no revision history. + pub(crate) versions_group: adw::PreferencesGroup, + pub(crate) versions_row: adw::ActionRow, pub(crate) versions_button: gtk4::Button, pub(crate) trash_button: gtk4::Button, pub(crate) close_button: gtk4::Button, } /// The details pane shown beside the file views: a big type icon over the entry's -/// name, its properties, an offline (pin) toggle and the primary actions. Built -/// empty; [`repaint_details`] fills it from the selected [`DirEntry`] and -/// [`wire_details`] connects the buttons. +/// name, then Info, Offline, Sharing and Versions sections and the primary +/// actions. Built empty; [`show_details`] fills it from the selected +/// [`DirEntry`] and [`wire_details`] connects the buttons. pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { let close_button = gtk4::Button::builder() .icon_name("window-close-symbolic") @@ -64,7 +70,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { head.append(&name); head.append(&kind); - let props = adw::PreferencesGroup::new(); + let props = adw::PreferencesGroup::builder().title("Info").build(); let size_row = adw::ActionRow::builder() .title("Size") .subtitle("—") @@ -85,20 +91,50 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { props.add(&modified_row); props.add(&path_row); - let offline = adw::PreferencesGroup::new(); + let offline = adw::PreferencesGroup::builder().title("Offline").build(); let pin_row = adw::SwitchRow::builder() .title("Available offline") .subtitle("Keep a copy on this computer, even without a connection.") .build(); offline.add(&pin_row); + let sharing = adw::PreferencesGroup::builder().title("Sharing").build(); + let sharing_row = adw::ActionRow::builder() + .title("Access") + .subtitle("—") + .build(); + sharing_row.add_css_class("property"); + sharing.add(&sharing_row); + let share_button = gtk4::Button::builder().label("Share…").build(); + let copy_link_button = gtk4::Button::builder().label("Copy Link").build(); + let share_actions = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(6) + .homogeneous(true) + .margin_top(6) + .build(); + share_actions.append(&share_button); + share_actions.append(©_link_button); + sharing.add(&share_actions); + + let versions_group = adw::PreferencesGroup::builder().title("Versions").build(); + let versions_row = adw::ActionRow::builder() + .title("History") + .subtitle("—") + .build(); + versions_row.add_css_class("property"); + versions_group.add(&versions_row); + let versions_button = gtk4::Button::builder() + .label("Versions…") + .margin_top(6) + .build(); + versions_group.add(&versions_button); + let open_button = gtk4::Button::builder().label("Open").build(); open_button.add_css_class("suggested-action"); open_button.add_css_class("pill"); let rename_button = gtk4::Button::builder().label("Rename").build(); rename_button.add_css_class("pill"); - let versions_button = gtk4::Button::builder().label("Versions").build(); - versions_button.add_css_class("pill"); let trash_button = gtk4::Button::builder().label("Move to Trash").build(); trash_button.add_css_class("destructive-action"); trash_button.add_css_class("pill"); @@ -107,7 +143,6 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { actions.set_margin_top(6); actions.append(&open_button); actions.append(&rename_button); - actions.append(&versions_button); actions.append(&trash_button); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); @@ -119,6 +154,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { inner.append(&head); inner.append(&props); inner.append(&offline); + inner.append(&sharing); + inner.append(&versions_group); inner.append(&actions); let scroll = gtk4::ScrolledWindow::builder() @@ -139,6 +176,11 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { modified_row, path_row, pin_row, + sharing_row, + share_button, + copy_link_button, + versions_group, + versions_row, open_button, rename_button, versions_button, @@ -201,6 +243,23 @@ pub(crate) fn wire_details(ui: &Rc) { open_versions_dialog(&ui_versions, &entry); } }); + let ui_share = ui.clone(); + ui.details.details.share_button.connect_clicked(move |_| { + let entry = ui_share.details.details_entry.borrow().clone(); + if let Some(entry) = entry { + open_share_dialog(&ui_share, &entry); + } + }); + let ui_link = ui.clone(); + ui.details + .details + .copy_link_button + .connect_clicked(move |_| { + let entry = ui_link.details.details_entry.borrow().clone(); + if let Some(entry) = entry { + copy_entry_link(&ui_link, &entry); + } + }); let ui_trash = ui.clone(); ui.details.details.trash_button.connect_clicked(move |_| { let entry = ui_trash.details.details_entry.borrow().clone(); @@ -261,11 +320,19 @@ pub(crate) fn show_details(ui: &Rc, entry: &DirEntry) { d.open_button .set_label(if entry.is_dir { "Open folder" } else { "Open" }); // Only files have revisions, and only a connected daemon can fetch them. - d.versions_button.set_visible(!entry.is_dir); - d.versions_button.set_sensitive(*ui.mounted.borrow()); + let mounted = *ui.mounted.borrow(); + d.versions_group.set_visible(!entry.is_dir); + d.versions_button.set_sensitive(mounted); + d.share_button.set_sensitive(mounted); + d.copy_link_button.set_sensitive(mounted); + d.sharing_row.set_subtitle("—"); + d.versions_row.set_subtitle("—"); ui.details.details_suppress.set(false); *ui.details.details_entry.borrow_mut() = Some(entry.clone()); + if mounted { + load_details_extras(ui, entry); + } // Reveal the pane on idle, not inline. This runs from `selection_changed`, // which fires on the *first* press of a double-click; mutating the widget // tree here cancels GtkGridView's multi-press tracking, so the second press @@ -280,6 +347,88 @@ pub(crate) fn show_details(ui: &Rc, entry: &DirEntry) { }); } +/// Fill the Sharing and Versions rows, which need the network. The answers +/// land only if the pane still shows the same entry. +fn load_details_extras(ui: &Rc, entry: &DirEntry) { + let by_uid = entry.path.is_empty() && !entry.uid.is_empty(); + let rel = entry_rel(ui, entry); + let share_req = if by_uid { + Request::ListShareByUid { + uid: entry.uid.clone(), + } + } else { + Request::ListShare { path: rel.clone() } + }; + let rx = spawn_request(ui.dirs.control_socket(), share_req); + let (ui_c, uid) = (ui.clone(), entry.uid.clone()); + glib::spawn_future_local(async move { + let text = match rx.recv().await { + Ok(Ok(Response::Share { entries, link })) => { + sharing_summary(entries.len(), link.is_some()) + } + _ => "Couldn't load sharing".to_string(), + }; + if details_showing(&ui_c, &uid) { + ui_c.details.details.sharing_row.set_subtitle(&text); + } + }); + + if entry.is_dir { + return; + } + let req = if by_uid { + Request::ListRevisionsByUid { + uid: entry.uid.clone(), + } + } else { + Request::ListRevisions { path: rel } + }; + let rx = spawn_request(ui.dirs.control_socket(), req); + let (ui_c, uid) = (ui.clone(), entry.uid.clone()); + glib::spawn_future_local(async move { + let text = match rx.recv().await { + Ok(Ok(Response::Revisions { items })) => versions_summary(&items), + _ => "Couldn't load versions".to_string(), + }; + if details_showing(&ui_c, &uid) { + ui_c.details.details.versions_row.set_subtitle(&text); + } + }); +} + +fn details_showing(ui: &Rc, uid: &str) -> bool { + ui.details + .details_entry + .borrow() + .as_ref() + .is_some_and(|e| e.uid == uid) +} + +/// "Only you", "3 people", "Public link" or both. +pub(crate) fn sharing_summary(people: usize, link: bool) -> String { + let people = match people { + 0 => None, + 1 => Some("1 person".to_string()), + n => Some(format!("{n} people")), + }; + match (people, link) { + (None, false) => "Only you".to_string(), + (None, true) => "Anyone with the link".to_string(), + (Some(p), false) => format!("Shared with {p}"), + (Some(p), true) => format!("Shared with {p} · Public link"), + } +} + +/// "3 versions · latest 2 Sep 2026". +pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String { + let latest = items.iter().map(|r| r.created).max(); + match (items.len(), latest) { + (0, _) | (_, None) => "No earlier versions".to_string(), + (1, Some(t)) => format!("1 version · {}", format_modified(t)), + (n, Some(t)) => format!("{n} versions · latest {}", format_modified(t)), + } +} + /// Hide the details pane and forget the entry it was showing, so a stale entry /// can't be acted on after the listing moves on. pub(crate) fn hide_details(ui: &Rc) { @@ -293,3 +442,35 @@ pub(crate) fn hide_details(ui: &Rc) { pub(crate) fn selected_entry(ui: &Rc) -> Option { ui.details.details_entry.borrow().clone() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sharing_summary_names_people_and_link() { + assert_eq!(sharing_summary(0, false), "Only you"); + assert_eq!(sharing_summary(0, true), "Anyone with the link"); + assert_eq!(sharing_summary(1, false), "Shared with 1 person"); + assert_eq!( + sharing_summary(3, true), + "Shared with 3 people · Public link" + ); + } + + #[test] + fn versions_summary_counts_revisions() { + let rev = |created| RevisionInfo { + id: String::new(), + is_active: false, + created, + size_on_storage: 0, + claimed_size: None, + claimed_modified: None, + signed_by: None, + has_thumbnails: false, + }; + assert_eq!(versions_summary(&[]), "No earlier versions"); + assert!(versions_summary(&[rev(10), rev(20)]).starts_with("2 versions · latest ")); + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fde787f..3aaf24c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -74,6 +74,10 @@ Schema: **31** (unchanged). application to use. - **One selection in My Files.** The grid and the list share their selection, so switching views keeps what is selected, and dragging across empty space draws a selection box in both. +- **Details pane.** On a window at least 1000 px wide the details pane docks beside the files + instead of covering them. It has Info, Offline, Sharing and Versions sections: who has access or + whether there is a public link, how many versions there are, and Share…, Copy Link and + Versions… buttons. - **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the `pdfs favorite` command.