feat(gui): docked details pane on wide windows

An adw::Breakpoint at 1000sp uncollapses the details OverlaySplitView, so
the pane sits beside the files instead of over them. The pane gains
Sharing and Versions sections, filled from ListShare and ListRevisions
when an entry is selected, with Share…, Copy Link and Versions… buttons.
This commit is contained in:
nvrl committed 2026-09-23 03:06:32 +02:00
1 parent abf76cd5e8
commit 0fbc6421ba
3 files changed
+204 -12

No files matched your search

+9 -2
View File
@@ -51,8 +51,8 @@ use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo, ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem, DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem,
PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem,
SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase,
ThumbnailBuildStatus, TransferDirection, TransferItem, pending_summary, send, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem, pending_summary, send,
}; };
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
@@ -697,6 +697,13 @@ fn build_window(app: &adw::Application) {
.default_height(680) .default_height(680)
.content(&toasts) .content(&toasts)
.build(); .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_shortcuts(&ui, &window);
install_window_actions(&ui, &window); install_window_actions(&ui, &window);
app.set_accels_for_action("win.preferences", &["<Control>comma"]); app.set_accels_for_action("win.preferences", &["<Control>comma"]);
+191 -10
View File
@@ -25,16 +25,22 @@ pub(crate) struct DetailsWidgets {
pub(crate) pin_row: adw::SwitchRow, pub(crate) pin_row: adw::SwitchRow,
pub(crate) open_button: gtk4::Button, pub(crate) open_button: gtk4::Button,
pub(crate) rename_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. /// 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) versions_button: gtk4::Button,
pub(crate) trash_button: gtk4::Button, pub(crate) trash_button: gtk4::Button,
pub(crate) close_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 /// 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 /// name, then Info, Offline, Sharing and Versions sections and the primary
/// empty; [`repaint_details`] fills it from the selected [`DirEntry`] and /// actions. Built empty; [`show_details`] fills it from the selected
/// [`wire_details`] connects the buttons. /// [`DirEntry`] and [`wire_details`] connects the buttons.
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let close_button = gtk4::Button::builder() let close_button = gtk4::Button::builder()
.icon_name("window-close-symbolic") .icon_name("window-close-symbolic")
@@ -64,7 +70,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
head.append(&name); head.append(&name);
head.append(&kind); head.append(&kind);
let props = adw::PreferencesGroup::new(); let props = adw::PreferencesGroup::builder().title("Info").build();
let size_row = adw::ActionRow::builder() let size_row = adw::ActionRow::builder()
.title("Size") .title("Size")
.subtitle("—") .subtitle("—")
@@ -85,20 +91,50 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
props.add(&modified_row); props.add(&modified_row);
props.add(&path_row); props.add(&path_row);
let offline = adw::PreferencesGroup::new(); let offline = adw::PreferencesGroup::builder().title("Offline").build();
let pin_row = adw::SwitchRow::builder() let pin_row = adw::SwitchRow::builder()
.title("Available offline") .title("Available offline")
.subtitle("Keep a copy on this computer, even without a connection.") .subtitle("Keep a copy on this computer, even without a connection.")
.build(); .build();
offline.add(&pin_row); 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(&copy_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(); let open_button = gtk4::Button::builder().label("Open").build();
open_button.add_css_class("suggested-action"); open_button.add_css_class("suggested-action");
open_button.add_css_class("pill"); open_button.add_css_class("pill");
let rename_button = gtk4::Button::builder().label("Rename").build(); let rename_button = gtk4::Button::builder().label("Rename").build();
rename_button.add_css_class("pill"); 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(); let trash_button = gtk4::Button::builder().label("Move to Trash").build();
trash_button.add_css_class("destructive-action"); trash_button.add_css_class("destructive-action");
trash_button.add_css_class("pill"); 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.set_margin_top(6);
actions.append(&open_button); actions.append(&open_button);
actions.append(&rename_button); actions.append(&rename_button);
actions.append(&versions_button);
actions.append(&trash_button); actions.append(&trash_button);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); 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(&head);
inner.append(&props); inner.append(&props);
inner.append(&offline); inner.append(&offline);
inner.append(&sharing);
inner.append(&versions_group);
inner.append(&actions); inner.append(&actions);
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
@@ -139,6 +176,11 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
modified_row, modified_row,
path_row, path_row,
pin_row, pin_row,
sharing_row,
share_button,
copy_link_button,
versions_group,
versions_row,
open_button, open_button,
rename_button, rename_button,
versions_button, versions_button,
@@ -201,6 +243,23 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
open_versions_dialog(&ui_versions, &entry); 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(); let ui_trash = ui.clone();
ui.details.details.trash_button.connect_clicked(move |_| { ui.details.details.trash_button.connect_clicked(move |_| {
let entry = ui_trash.details.details_entry.borrow().clone(); let entry = ui_trash.details.details_entry.borrow().clone();
@@ -261,11 +320,19 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
d.open_button d.open_button
.set_label(if entry.is_dir { "Open folder" } else { "Open" }); .set_label(if entry.is_dir { "Open folder" } else { "Open" });
// Only files have revisions, and only a connected daemon can fetch them. // Only files have revisions, and only a connected daemon can fetch them.
d.versions_button.set_visible(!entry.is_dir); let mounted = *ui.mounted.borrow();
d.versions_button.set_sensitive(*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_suppress.set(false);
*ui.details.details_entry.borrow_mut() = Some(entry.clone()); *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`, // 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 // 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 // tree here cancels GtkGridView's multi-press tracking, so the second press
@@ -280,6 +347,88 @@ pub(crate) fn show_details(ui: &Rc<Ui>, 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<Ui>, 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<Ui>, 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 /// 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. /// can't be acted on after the listing moves on.
pub(crate) fn hide_details(ui: &Rc<Ui>) { pub(crate) fn hide_details(ui: &Rc<Ui>) {
@@ -293,3 +442,35 @@ pub(crate) fn hide_details(ui: &Rc<Ui>) {
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> { pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
ui.details.details_entry.borrow().clone() 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 "));
}
}
+4
View File
@@ -74,6 +74,10 @@ Schema: **31** (unchanged).
application to use. application to use.
- **One selection in My Files.** The grid and the list share their selection, so switching views - **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. 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 - **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
`pdfs favorite` command. `pdfs favorite` command.