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,
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", &["<Control>comma"]);
+191 -10
View File
@@ -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(&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();
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<Ui>) {
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<Ui>, 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<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
/// can't be acted on after the listing moves on.
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> {
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.
- **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.