diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs
index a79d08f..75e4129 100644
--- a/crates/pdfs-core/src/control.rs
+++ b/crates/pdfs-core/src/control.rs
@@ -976,6 +976,10 @@ pub struct SearchHit {
pub size: u64,
pub modified: i64,
pub pinned: bool,
+ /// Whether a valid copy of the content is in the local cache. Legacy
+ /// daemons omit it; clients then treat the file as online-only.
+ #[serde(default)]
+ pub cached: bool,
/// Node uid in `volume~link` form.
pub uid: String,
/// Absolute path through the most specific active on-demand mount, when the
@@ -2133,6 +2137,7 @@ mod tests {
size: 12,
modified: 123,
pinned: true,
+ cached: false,
uid: "vol~link".into(),
mounted_path: Some("/home/me/Videos/report.pdf".into()),
score: 985,
diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs
index 8aa28fd..92fed0f 100644
--- a/crates/pdfs-fuse/src/control.rs
+++ b/crates/pdfs-fuse/src/control.rs
@@ -810,9 +810,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
Ok(n) => {
- core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true);
+ let items = if n == 1 {
+ "1 item".to_string()
+ } else {
+ format!("{n} items")
+ };
+ core.log_activity(ActivityKind::EmptyTrash, items.clone(), "", true);
CtlResponse::Ok {
- message: format!("emptied trash ({n} item(s))"),
+ message: format!("emptied trash ({items})"),
}
}
Err(e) => CtlResponse::error(e),
diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs
index dfaf9a8..86727e9 100644
--- a/crates/pdfs-fuse/src/lib.rs
+++ b/crates/pdfs-fuse/src/lib.rs
@@ -3865,6 +3865,12 @@ impl Core {
size: node_size(&h.node),
modified: h.node.modification_time,
pinned: pinned.contains(&h.node.uid.to_string()),
+ cached: !h.node.is_folder()
+ && self.cache.is_cached(
+ &h.node.uid,
+ h.node.modification_time,
+ node_size(&h.node),
+ ),
uid: h.node.uid.to_string(),
mounted_path: roots.resolve(&h.path),
path: h.path,
@@ -3932,6 +3938,12 @@ impl Core {
size: node_size(&hit.node),
modified: hit.node.modification_time,
pinned: is_pinned,
+ cached: !hit.node.is_folder()
+ && self.cache.is_cached(
+ &hit.node.uid,
+ hit.node.modification_time,
+ node_size(&hit.node),
+ ),
uid,
mounted_path: roots.resolve(&hit.path),
path: hit.path,
diff --git a/crates/pdfs-gui/build.rs b/crates/pdfs-gui/build.rs
index 58a5198..59e8691 100644
--- a/crates/pdfs-gui/build.rs
+++ b/crates/pdfs-gui/build.rs
@@ -22,10 +22,5 @@ fn main() {
}
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
- println!(
- "cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
- );
- println!(
- "cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
- );
+ println!("cargo:rerun-if-changed=resources/icons");
}
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-offline-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-offline-symbolic.svg
new file mode 100644
index 0000000..fab47ec
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-offline-symbolic.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-online-only-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-online-only-symbolic.svg
new file mode 100644
index 0000000..a6e3082
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-online-only-symbolic.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-upload-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-upload-symbolic.svg
new file mode 100644
index 0000000..0938c05
--- /dev/null
+++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-upload-symbolic.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/pdfs-gui/resources/pdfs.gresource.xml b/crates/pdfs-gui/resources/pdfs.gresource.xml
index cc21020..e893c8a 100644
--- a/crates/pdfs-gui/resources/pdfs.gresource.xml
+++ b/crates/pdfs-gui/resources/pdfs.gresource.xml
@@ -6,5 +6,8 @@
scalable/actions/pdfs-build-thumbnails-symbolic.svgscalable/actions/pdfs-folder-upload-symbolic.svg
+ scalable/actions/pdfs-upload-symbolic.svg
+ scalable/actions/pdfs-offline-symbolic.svg
+ scalable/actions/pdfs-online-only-symbolic.svg
diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs
index 391fee7..3f029f2 100644
--- a/crates/pdfs-gui/src/app/main.rs
+++ b/crates/pdfs-gui/src/app/main.rs
@@ -603,7 +603,6 @@ fn build_window(app: &adw::Application) {
&ui,
&main_widgets.purge_button,
&main_widgets.mountpoint_button,
- &main_widgets.import_row,
);
wire_sidebar(&ui);
wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view);
@@ -773,12 +772,12 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
}
/// The sidebar destination a page belongs under. Most pages are their own
-/// destination; a sub-page reached from one (Import, off Settings) has no row of
+/// destination; a sub-page reached from one (Import, off Photos) has no row of
/// its own and answers with its parent, so the sidebar highlights where the user
/// came from rather than clearing — which would read as "nowhere".
fn destination_of(page: &str) -> &str {
match page {
- "takeout" => "main",
+ "takeout" => "gallery",
other => other,
}
}
@@ -956,6 +955,12 @@ fn sync_sidebar(ui: &Rc) {
}
}
+/// "1 item" / "5 items": a count with the noun form that agrees with it, so
+/// no user-facing string has to fall back to "item(s)".
+fn count_noun(n: usize, one: &str, many: &str) -> String {
+ format!("{n} {}", if n == 1 { one } else { many })
+}
+
/// Show a transient toast. Non-blocking by design: an action's outcome is
/// reported without stealing focus or forcing a click, so the user can keep
/// working while a slow upload lands.
@@ -1134,7 +1139,11 @@ fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) {
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
gtk4::gdk::Key::F2 if on_browser => {
- if let Some(entry) = selected_entry(&ui) {
+ // Renaming is one name at a time; saying so beats a key that
+ // silently does nothing with several items highlighted.
+ if selected_entries(&ui).len() > 1 {
+ toast(&ui, "Select a single item to rename it");
+ } else if let Some(entry) = selected_entry(&ui) {
prompt_rename(&ui, &entry);
}
}
@@ -1144,7 +1153,7 @@ fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) {
gtk4::gdk::Key::Delete if on_browser => {
let entries = selected_entries(&ui);
if !entries.is_empty() {
- prompt_delete_many(&ui, entries);
+ trash_entries(&ui, entries);
}
}
gtk4::gdk::Key::Escape
@@ -1196,6 +1205,28 @@ fn ui_window(ui: &Rc) -> Option {
ui.stack.root().and_downcast::()
}
+/// Ask before an action that has no Undo. `on_confirm` runs only when the user
+/// picks `action`; Cancel is the default, so Enter never destroys anything.
+fn confirm_destructive(
+ parent: &impl IsA,
+ heading: &str,
+ body: &str,
+ action: &str,
+ on_confirm: impl Fn() + 'static,
+) {
+ let dialog = adw::AlertDialog::builder()
+ .heading(heading)
+ .body(body)
+ .build();
+ dialog.add_response("cancel", "Cancel");
+ dialog.add_response("confirm", action);
+ dialog.set_response_appearance("confirm", adw::ResponseAppearance::Destructive);
+ dialog.set_default_response(Some("cancel"));
+ dialog.set_close_response("cancel");
+ dialog.connect_response(Some("confirm"), move |_, _| on_confirm());
+ dialog.present(Some(parent));
+}
+
/// A dim, non-interactive placeholder row for an empty section.
fn dim_row(text: &str) -> adw::ActionRow {
let row = adw::ActionRow::builder().title(text).build();
diff --git a/crates/pdfs-gui/src/app/pages/activity.rs b/crates/pdfs-gui/src/app/pages/activity.rs
index 8d5a2ec..d04c31a 100644
--- a/crates/pdfs-gui/src/app/pages/activity.rs
+++ b/crates/pdfs-gui/src/app/pages/activity.rs
@@ -248,16 +248,28 @@ pub(crate) fn repaint_activity(ui: &Rc, items: &[ActivityEntry]) {
if !a.detail.is_empty() {
subtitle = format!("{subtitle} · {}", a.detail);
}
+ let title = match a.kind {
+ // The target of an empty is a count, not a name to lead with a verb.
+ ActivityKind::EmptyTrash => format!("Emptied Trash ({})", a.target),
+ kind => format!("{} {}", activity_verb(kind), a.target),
+ };
let row = adw::ActionRow::builder()
- .title(format!("{} {}", activity_verb(a.kind), a.target))
+ .title(title)
.subtitle(subtitle)
.build();
- let icon = if a.ok {
+ // A failure and a conflict are different news: a conflict kept both
+ // copies, a failure lost the action. Each gets its own icon and colour.
+ let icon = gtk4::Image::from_icon_name(if a.ok {
activity_icon(a.kind)
} else {
- "dialog-warning-symbolic"
- };
- row.add_prefix(>k4::Image::from_icon_name(icon));
+ "dialog-error-symbolic"
+ });
+ if !a.ok {
+ icon.add_css_class("error");
+ } else if a.kind == ActivityKind::Conflict {
+ icon.add_css_class("warning");
+ }
+ row.add_prefix(&icon);
row.set_activatable(false);
ui.activity.group.add(&row);
rows.push(row.upcast());
@@ -277,9 +289,9 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
ActivityKind::Trash => "Trashed",
ActivityKind::Restore => "Restored",
ActivityKind::DeleteForever => "Deleted",
- ActivityKind::EmptyTrash => "Emptied trash —",
+ ActivityKind::EmptyTrash => "Emptied Trash",
ActivityKind::Share => "Shared",
- ActivityKind::PublicLink => "Linked",
+ ActivityKind::PublicLink => "Created a link to",
ActivityKind::Unshare => "Unshared",
ActivityKind::Conflict => "Conflict",
}
@@ -288,15 +300,14 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
/// A themed icon for an activity kind.
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
match kind {
- ActivityKind::Upload => "document-send-symbolic",
+ ActivityKind::Upload => "pdfs-upload-symbolic",
ActivityKind::Download => "document-save-symbolic",
ActivityKind::Sync => "emblem-synchronizing-symbolic",
ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic",
- ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => {
- "user-trash-symbolic"
- }
+ ActivityKind::Trash => "user-trash-symbolic",
+ ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
ActivityKind::Restore => "edit-undo-symbolic",
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
ActivityKind::Unshare => "action-unavailable-symbolic",
diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs
index 83c3a41..98a8b46 100644
--- a/crates/pdfs-gui/src/app/pages/browser.rs
+++ b/crates/pdfs-gui/src/app/pages/browser.rs
@@ -177,7 +177,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.build();
new_folder.add_css_class("flat");
let upload = gtk4::Button::builder()
- .icon_name("document-send-symbolic")
+ .icon_name("pdfs-upload-symbolic")
.tooltip_text("Upload files")
.valign(gtk4::Align::Center)
.build();
@@ -337,12 +337,12 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
// instead of making the whole view jump when a second entry is selected.
let bulk_label = gtk4::Label::builder().hexpand(true).xalign(0.0).build();
let bulk_pin = gtk4::Button::builder()
- .label("Keep offline")
+ .label("Make available offline")
.valign(gtk4::Align::Center)
.build();
bulk_pin.add_css_class("flat");
let bulk_unpin = gtk4::Button::builder()
- .label("Remove offline copy")
+ .label("Make online only")
.valign(gtk4::Align::Center)
.build();
bulk_unpin.add_css_class("flat");
@@ -573,39 +573,15 @@ pub(crate) fn sync_bulk_bar(ui: &Rc) {
ui.browser.bulk.set_reveal_child(true);
}
-/// Confirm and move every selected entry to Trash.
+/// Move every selected entry to Trash.
///
-/// One dialog for the batch, one pass over the daemon, one toast — and one Undo
-/// that restores the whole batch, because a mis-aimed bulk delete is exactly the
-/// action a user most needs to take back.
-pub(crate) fn prompt_delete_many(ui: &Rc, entries: Vec) {
- if entries.len() < 2 {
- if let Some(entry) = entries.first() {
- prompt_delete(ui, entry);
- }
- return;
+/// No confirmation: Trash is itself the safety net, and the toast carries one
+/// Undo that restores the whole batch. Asking first *and* offering Undo after
+/// would put two hurdles in front of a reversible action.
+pub(crate) fn trash_entries(ui: &Rc, entries: Vec) {
+ if !entries.is_empty() {
+ run_bulk_delete(ui, entries);
}
- let win = ui_window(ui);
- let dialog = adw::AlertDialog::builder()
- .heading("Move to Trash")
- .body(format!(
- "Move {} items to Trash? Anything inside a selected folder goes with it.",
- entries.len()
- ))
- .build();
- dialog.add_response("cancel", "Cancel");
- dialog.add_response("trash", "Move to Trash");
- dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive);
- dialog.set_default_response(Some("cancel"));
- dialog.set_close_response("cancel");
-
- let ui = ui.clone();
- dialog.connect_response(None, move |_, resp| {
- if resp == "trash" {
- run_bulk_delete(&ui, entries.clone());
- }
- });
- dialog.present(win.as_ref());
}
/// Trash a batch, one request at a time.
@@ -729,7 +705,7 @@ pub(crate) fn run_bulk_pin(ui: &Rc, entries: Vec, pin: bool) {
&if pin {
format!("{n} files are now available offline")
} else {
- format!("{n} files are no longer kept offline")
+ format!("{n} files are now online only")
},
),
}
@@ -746,7 +722,7 @@ pub(crate) fn wire_bulk(
let ui_trash = ui.clone();
ui.browser
.bulk_trash
- .connect_clicked(move |_| prompt_delete_many(&ui_trash, selected_entries(&ui_trash)));
+ .connect_clicked(move |_| trash_entries(&ui_trash, selected_entries(&ui_trash)));
let ui_pin = ui.clone();
ui.browser
.bulk_pin
@@ -1089,9 +1065,9 @@ pub(crate) fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Bo
if !entry.is_dir {
let (label, icon) = if entry.pinned {
- ("Unpin", "non-starred-symbolic")
+ ("Make online only", "pdfs-online-only-symbolic")
} else {
- ("Keep offline", "starred-symbolic")
+ ("Make available offline", "pdfs-offline-symbolic")
};
let pin = menu_item(label, icon);
let ui_pin = ui.clone();
@@ -1144,7 +1120,7 @@ pub(crate) fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Bo
let pop = popover.clone();
trash.connect_clicked(move |_| {
pop.popdown();
- prompt_delete(&ui_tr, &entry_tr);
+ trash_entry(&ui_tr, &entry_tr);
});
menu.append(&trash);
@@ -1184,7 +1160,7 @@ pub(crate) fn show_bulk_context_menu(
let files_only = entries.iter().all(|e| !e.is_dir);
if files_only && entries.iter().any(|e| !e.pinned) {
- let pin = menu_item("Keep offline", "starred-symbolic");
+ let pin = menu_item("Make available offline", "pdfs-offline-symbolic");
let ui_pin = ui.clone();
let batch = entries.clone();
let pop = popover.clone();
@@ -1195,7 +1171,7 @@ pub(crate) fn show_bulk_context_menu(
menu.append(&pin);
}
if files_only && entries.iter().any(|e| e.pinned) {
- let unpin = menu_item("Remove offline copy", "non-starred-symbolic");
+ let unpin = menu_item("Make online only", "pdfs-online-only-symbolic");
let ui_unpin = ui.clone();
let batch = entries.clone();
let pop = popover.clone();
@@ -1206,14 +1182,18 @@ pub(crate) fn show_bulk_context_menu(
menu.append(&unpin);
}
- menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
+ // Offline items only exist for all-file selections; without them the
+ // separator would sit directly under the header.
+ if files_only {
+ menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
+ }
let trash = menu_item("Move to Trash", "user-trash-symbolic");
let ui_tr = ui.clone();
let pop = popover.clone();
trash.connect_clicked(move |_| {
pop.popdown();
- prompt_delete_many(&ui_tr, entries.clone());
+ trash_entries(&ui_tr, entries.clone());
});
menu.append(&trash);
@@ -1367,7 +1347,7 @@ pub(crate) fn toggle_pin(ui: &Rc, entry: &DirEntry) {
toast(
&ui,
&if pinned {
- format!("“{name}” is no longer kept offline")
+ format!("“{name}” is now online only")
} else {
format!("“{name}” is now available offline")
},
@@ -1651,6 +1631,12 @@ fn repaint_thumbnail_build(ui: &Rc, status: &ThumbnailBuildStatus) {
repaint_thumbnail_build_action(ui, true);
schedule_thumbnail_build_poll(ui);
} else {
+ // The progress row leaves with the run, so the outcome is handed to a
+ // toast instead — otherwise the build ends without a word. Only on the
+ // running → finished edge: a status read at startup is not news.
+ if ui.browser.thumbnail_build_running.get() {
+ toast(ui, &text);
+ }
ui.browser.thumbnail_build_running.set(false);
ui.browser.thumbnail_cancel_pending.set(false);
repaint_thumbnail_build_action(ui, false);
@@ -1829,29 +1815,9 @@ pub(crate) fn prompt_move(ui: &Rc, entry: &DirEntry) {
dialog.present(parent.as_ref());
}
-/// Confirm and move the entry to Trash through the daemon.
-pub(crate) fn prompt_delete(ui: &Rc, entry: &DirEntry) {
- let win = ui_window(ui);
- let dialog = adw::AlertDialog::builder()
- .heading("Move to Trash")
- .body(format!("Move “{}” to Trash?", entry.name))
- .build();
- dialog.add_response("cancel", "Cancel");
- dialog.add_response("trash", "Move to Trash");
- dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive);
- dialog.set_default_response(Some("cancel"));
- dialog.set_close_response("cancel");
-
- // Routed through the batch path so a single trash gets the same Undo: one
- // mis-click on one file is no less worth taking back than fifty.
- let entry = entry.clone();
- let ui = ui.clone();
- dialog.connect_response(None, move |_, resp| {
- if resp == "trash" {
- run_bulk_delete(&ui, vec![entry.clone()]);
- }
- });
- dialog.present(win.as_ref());
+/// Move one entry to Trash, through the batch path so it gets the same Undo.
+pub(crate) fn trash_entry(ui: &Rc, entry: &DirEntry) {
+ run_bulk_delete(ui, vec![entry.clone()]);
}
/// Prompt for a folder name and create it under the current browser directory.
@@ -1978,11 +1944,11 @@ pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)
return None;
}
Some(if entry.pinned {
- ("starred-symbolic", "badge-pinned")
+ ("pdfs-offline-symbolic", "badge-pinned")
} else if entry.cached {
("emblem-ok-symbolic", "badge-cached")
} else {
- ("weather-overcast-symbolic", "badge-cloud")
+ ("pdfs-online-only-symbolic", "badge-cloud")
})
}
@@ -2340,8 +2306,7 @@ pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) {
size: h.size,
modified: h.modified,
pinned: h.pinned,
- // Search hits don't carry cache state; the badge shows in listings.
- cached: false,
+ cached: h.cached,
uid: h.uid.clone(),
path: h.path.clone(),
role: String::new(),
diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs
index 54cd90a..d5aea76 100644
--- a/crates/pdfs-gui/src/app/pages/devices.rs
+++ b/crates/pdfs-gui/src/app/pages/devices.rs
@@ -43,9 +43,10 @@ pub(crate) struct DevicesWidgets {
pub(crate) refresh: gtk4::Button,
}
-/// The Computers page: a "This computer" section listing the local folders synced
-/// to this machine's device (each row offering a mode toggle and Remove), plus an
-/// "Other devices" section listing the account's other registered devices.
+/// The Computers page: a "This computer" section naming the device this machine
+/// backs up to (renamable from its header), plus an "Other computers" section
+/// listing the account's other registered devices. The synced folders
+/// themselves live on the Locations page.
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
let title = gtk4::Label::builder()
.label("Computers")
@@ -588,7 +589,7 @@ fn show_restore_picker(ui: &Rc, items: Vec) {
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
/// row's subtitle and switch reflect the daemon's real state (the request may be
/// rejected, e.g. switching to on-demand while a folder is mid-sync).
-pub(crate) fn set_sync_folder_mode(ui: &Rc, id: i64, mode: &str) {
+pub(crate) fn set_sync_folder_mode(ui: &Rc, id: i64, mode: &'static str) {
let rx = spawn_request(
ui.dirs.control_socket(),
Request::SetSyncFolderMode {
@@ -599,7 +600,17 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc, id: i64, mode: &str) {
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
- Ok(Ok(Response::Ok { message })) => toast(&ui, &message),
+ // The daemon's own text is written for the log; the toast says what
+ // the person will see happen. A switch may be queued behind a running
+ // pass, hence "will".
+ Ok(Ok(Response::Ok { .. })) => toast(
+ &ui,
+ if mode == "ondemand" {
+ "Folder will switch to on-demand"
+ } else {
+ "Folder will download and stay synced"
+ },
+ ),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't change mode", &message, kind)
}
@@ -609,12 +620,22 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc, id: i64, mode: &str) {
"The mount service didn't respond.",
),
}
- if ui.stack.visible_child_name().as_deref() == Some("devices") {
- load_devices(&ui);
- }
+ reload_sync_pages(&ui);
});
}
+/// Repaint whichever of the two pages that show sync folders is on screen. Sync
+/// folder changes are raised from Locations as well as Computers, and both must
+/// show the daemon's answer at once rather than on the next tick — a rejected
+/// mode switch would otherwise stay visibly flipped.
+pub(crate) fn reload_sync_pages(ui: &Rc) {
+ match ui.stack.visible_child_name().as_deref() {
+ Some("devices") => load_devices(ui),
+ Some("locations") => refresh_locations(ui),
+ _ => {}
+ }
+}
+
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
pub(crate) fn prompt_remove_sync_folder(ui: &Rc, id: i64, path: &str, ondemand: bool) {
let win = ui_window(ui);
@@ -790,7 +811,7 @@ pub(crate) fn prompt_adopt_device(ui: &Rc, uid: &str, name: &str) {
dialog.present(win.as_ref());
}
-/// Run a mutation raised from the Devices page and reload the page on success.
+/// Run a mutation raised from Computers or Locations and reload the page on success.
pub(crate) fn run_devices_mutation(
ui: &Rc,
req: Request,
@@ -806,7 +827,7 @@ pub(crate) fn run_devices_mutation(
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => {
- load_devices(&ui);
+ reload_sync_pages(&ui);
toast(&ui, &done);
}
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs
index 6294932..e47844f 100644
--- a/crates/pdfs-gui/src/app/pages/locations.rs
+++ b/crates/pdfs-gui/src/app/pages/locations.rs
@@ -327,8 +327,8 @@ pub(crate) fn repaint_locations(ui: &Rc, items: &[MountSpec]) {
}
/// The primary mount's only control: where it lives. Changing it rewrites config
-/// and offers a service restart, which is why it stays a single prompt shared
-/// with the (now removed) Settings row.
+/// and offers a service restart, which is why it goes through the shared
+/// [`prompt_mountpoint`] dialog.
fn add_my_files_controls(ui: &Rc, row: &adw::ActionRow) {
let change = gtk4::Button::builder()
.label("Change")
@@ -341,8 +341,8 @@ fn add_my_files_controls(ui: &Rc, row: &adw::ActionRow) {
row.add_suffix(&change);
}
-/// Sync now (mirror only), the on-demand switch, and Remove — the same three
-/// controls, and the same handlers, the Computers page used to carry.
+/// Sync now (mirror only), the on-demand switch, and Stop syncing for one
+/// synced folder.
fn add_device_controls(ui: &Rc, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
if spec.mode != MountMode::OnDemand {
let sync_now = gtk4::Button::builder()
@@ -371,14 +371,23 @@ fn add_device_controls(ui: &Rc, row: &adw::ActionRow, spec: &MountSpec, id:
.active(target == MountMode::OnDemand)
.build();
let ui_mode = ui.clone();
+ let mode_path = spec.local_path.clone();
ondemand.connect_state_set(move |_, on| {
- set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
- glib::Propagation::Proceed
+ if on {
+ // Going on-demand deletes the local copies, so it is asked for rather
+ // than done on a flick of the switch. Stop leaves the switch's state
+ // alone; the next repaint paints whatever the daemon then reports.
+ confirm_ondemand(&ui_mode, id, &mode_path);
+ glib::Propagation::Stop
+ } else {
+ set_sync_folder_mode(&ui_mode, id, "mirror");
+ glib::Propagation::Proceed
+ }
});
row.add_suffix(&ondemand);
let remove = gtk4::Button::builder()
- .icon_name("user-trash-symbolic")
+ .icon_name("media-playback-stop-symbolic")
.tooltip_text("Stop syncing this folder")
.valign(gtk4::Align::Center)
.build();
@@ -392,6 +401,33 @@ fn add_device_controls(ui: &Rc, row: &adw::ActionRow, spec: &MountSpec, id:
row.add_suffix(&remove);
}
+/// Ask before switching a synced folder to on-demand: the switch frees disk
+/// space by removing the local copies, which is not something to do by accident.
+fn confirm_ondemand(ui: &Rc, id: i64, path: &str) {
+ let dialog = adw::AlertDialog::builder()
+ .heading("Make Folder On-Demand?")
+ .body(format!(
+ "Files in {path} will be removed from this computer and kept in Proton \
+ Drive only. Each file downloads again when you open it."
+ ))
+ .build();
+ dialog.add_response("cancel", "Cancel");
+ dialog.add_response("ondemand", "Make On-Demand");
+ dialog.set_response_appearance("ondemand", adw::ResponseAppearance::Destructive);
+ dialog.set_default_response(Some("cancel"));
+ dialog.set_close_response("cancel");
+ let win = ui_window(ui);
+ let ui = ui.clone();
+ dialog.connect_response(None, move |_, response| {
+ if response == "ondemand" {
+ set_sync_folder_mode(&ui, id, "ondemand");
+ } else {
+ refresh_locations(&ui);
+ }
+ });
+ dialog.present(win.as_ref());
+}
+
/// Ask the daemon for an immediate pass over one folder.
fn sync_folder_now(ui: &Rc, id: i64) {
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs
index 6f42328..2221af7 100644
--- a/crates/pdfs-gui/src/app/pages/login.rs
+++ b/crates/pdfs-gui/src/app/pages/login.rs
@@ -268,17 +268,27 @@ pub(crate) fn prompt_2fa(ui: &Rc, code_tx: std::sync::mpsc::Sender)
dialog.present(parent.as_ref());
}
-/// Connect the sign-out button: disable+stop the mount service (so the daemon
-/// isn't left running without credentials), forget the stored session, and drop
-/// back to the login page.
+/// Connect the sign-out button: after a confirmation, disable+stop the mount
+/// service (so the daemon isn't left running without credentials), forget the
+/// stored session, and drop back to the login page.
pub(crate) fn wire_logout(ui: &Rc, button: >k4::Button) {
let ui = ui.clone();
- button.connect_clicked(move |_| {
- service::disable_stop();
- if let Err(e) = auth::logout() {
- tracing::error!("logout failed: {e}");
- }
- *ui.session.borrow_mut() = None;
- refresh(&ui);
+ button.connect_clicked(move |btn| {
+ let ui = ui.clone();
+ confirm_destructive(
+ btn,
+ "Sign Out?",
+ "Proton Drive will disconnect and stop syncing until you sign in again. \
+ Files already on this computer stay where they are.",
+ "Sign Out",
+ move || {
+ service::disable_stop();
+ if let Err(e) = auth::logout() {
+ tracing::error!("logout failed: {e}");
+ }
+ *ui.session.borrow_mut() = None;
+ refresh(&ui);
+ },
+ );
});
}
diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs
index b61efe0..8ceca95 100644
--- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs
+++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs
@@ -592,8 +592,12 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) {
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
let info_map = gtk4::Button::builder()
- .label("Show on map")
- .icon_name("map-symbolic")
+ .child(
+ &adw::ButtonContent::builder()
+ .label("Show on map")
+ .icon_name("map-symbolic")
+ .build(),
+ )
.halign(gtk4::Align::Start)
.build();
info_map.add_css_class("pill");
diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs
index 540744a..875627e 100644
--- a/crates/pdfs-gui/src/app/pages/photos.rs
+++ b/crates/pdfs-gui/src/app/pages/photos.rs
@@ -423,9 +423,16 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
back.add_css_class("flat");
back.add_css_class("circular");
+ // `label` and `icon_name` both replace a button's child, so the pair needs
+ // an `adw::ButtonContent` to show together.
let upload = gtk4::Button::builder()
- .label("Upload")
- .icon_name("list-add-symbolic")
+ .child(
+ &adw::ButtonContent::builder()
+ .label("Upload")
+ .icon_name("list-add-symbolic")
+ .build(),
+ )
+ .tooltip_text("Upload photos")
.valign(gtk4::Align::Center)
.build();
upload.add_css_class("pill");
diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs
index 55d288c..e21ce1c 100644
--- a/crates/pdfs-gui/src/app/pages/shared.rs
+++ b/crates/pdfs-gui/src/app/pages/shared.rs
@@ -541,8 +541,17 @@ pub(crate) fn repaint_shared(
});
let ui_rej = ui.clone();
let id_rej = inv.id.clone();
- reject.connect_clicked(move |_| {
- respond_invitation(&ui_rej, &id_rej, false);
+ let name_rej = inv.name.clone().unwrap_or_else(|| "this item".into());
+ reject.connect_clicked(move |btn| {
+ let ui = ui_rej.clone();
+ let id = id_rej.clone();
+ confirm_destructive(
+ btn,
+ "Reject Invitation?",
+ &format!("You won't have access to {name_rej} unless it is shared again."),
+ "Reject",
+ move || respond_invitation(&ui, &id, false),
+ );
});
row.add_suffix(&accept);
row.add_suffix(&reject);
diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs
index 2f32734..231e8f5 100644
--- a/crates/pdfs-gui/src/app/pages/status.rs
+++ b/crates/pdfs-gui/src/app/pages/status.rs
@@ -113,8 +113,6 @@ pub(crate) struct MainWidgets {
/// which is where the folder is changed.
pub(crate) mountpoint_row: adw::ActionRow,
pub(crate) mountpoint_button: gtk4::Button,
- /// Opens the Google Photos Takeout import page.
- pub(crate) import_row: adw::ActionRow,
}
/// The main (logged-in) page: a libadwaita settings surface — account header,
@@ -244,22 +242,6 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
mountpoint_row.add_suffix(&mountpoint_button);
system_group.add(&mountpoint_row);
- // Import: a one-off migration rather than a setting, so it is a doorway to
- // its own page rather than controls inlined here — staging a set of archives
- // and watching an hours-long upload needs the room.
- let import_group = adw::PreferencesGroup::builder()
- .title("Import")
- .description("Bring a photo library from another service into Proton Photos.")
- .build();
- let import_row = adw::ActionRow::builder()
- .title("Import from Google Photos")
- .subtitle("Add a Google Takeout export to your timeline.")
- .activatable(true)
- .build();
- import_row.add_prefix(>k4::Image::from_icon_name("folder-download-symbolic"));
- import_row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
- import_group.add(&import_row);
-
// Pins group: filled in by refresh.
let pins_group = adw::PreferencesGroup::builder()
.title("Pinned files")
@@ -292,7 +274,6 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
inner.append(&transfers_group);
inner.append(&storage_group);
inner.append(&system_group);
- inner.append(&import_group);
inner.append(&pins_group);
inner.append(&dev_group);
@@ -320,7 +301,6 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
purge_button,
mountpoint_row,
mountpoint_button,
- import_row,
},
)
}
@@ -346,7 +326,6 @@ pub(crate) fn wire_settings(
ui: &Rc,
purge_button: >k4::Button,
mountpoint_button: >k4::Button,
- import_row: &adw::ActionRow,
) {
let config = ui.dirs.load_config();
@@ -430,10 +409,6 @@ pub(crate) fn wire_settings(
// local path; this button is the way there.
let ui_mp = ui.clone();
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
-
- // Import: a doorway, not a setting — everything about it lives on its page.
- let ui_import = ui.clone();
- import_row.connect_activated(move |_| ui_import.stack.set_visible_child_name("takeout"));
}
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
@@ -693,6 +668,7 @@ pub(crate) fn set_mounted(ui: &Rc, mounted: bool) {
ui.details.details.rename_button.set_sensitive(mounted);
ui.details.details.trash_button.set_sensitive(mounted);
ui.details.details.open_button.set_sensitive(mounted);
+ ui.details.details.versions_button.set_sensitive(mounted);
// Only notify on a real edge, and never for the first reading: at startup the
// service is usually still coming up, and "disconnected" would be a lie.
@@ -1000,7 +976,7 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted:
let unpin = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.valign(gtk4::Align::Center)
- .tooltip_text("Unpin (remove offline copy)")
+ .tooltip_text("Make online only")
.sensitive(mounted)
.build();
unpin.add_css_class("flat");
diff --git a/crates/pdfs-gui/src/app/pages/takeout.rs b/crates/pdfs-gui/src/app/pages/takeout.rs
index d11d6b9..24b6353 100644
--- a/crates/pdfs-gui/src/app/pages/takeout.rs
+++ b/crates/pdfs-gui/src/app/pages/takeout.rs
@@ -1,8 +1,8 @@
//! The Import page: bringing a Google Photos **Takeout** export into Proton
//! Photos.
//!
-//! Reached from Settings → *Import from Google Photos* (and from the Gallery's
-//! import button), because it is a one-off migration rather than something the
+//! Reached from the Photos page's import button (and its empty timeline),
+//! because it is a one-off migration rather than something the
//! photo timeline does every day. It gets a page of its own instead of a dialog
//! for two reasons: an export is a *set* of archives that has to be staged and
//! checked before anything is sent, and the import that follows runs for hours,
@@ -126,11 +126,11 @@ pub(crate) struct TakeoutWidgets {
}
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
- // Header: a back button out to Settings, since this page is reached from
+ // Header: a back button out to Photos, since this page is reached from
// there and has no sidebar row of its own to navigate back with.
let back_button = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
- .tooltip_text("Back to Settings")
+ .tooltip_text("Back to Photos")
.valign(gtk4::Align::Center)
.build();
back_button.add_css_class("flat");
@@ -386,9 +386,10 @@ pub(crate) fn wire_takeout(ui: &Rc, widgets: &TakeoutWidgets) {
let dialog = adw::AlertDialog::builder()
.heading("Import this export?")
.body(format!(
- "{count} archive(s) will be uploaded to Proton Photos, creating albums as they \
- appear in the export. This can take hours; you can stop it at any point and \
- what has been uploaded stays. Photos you already have are skipped.",
+ "{} will be uploaded to Proton Photos, creating albums as they appear in \
+ the export. This can take hours; you can stop it at any point and what has \
+ been uploaded stays. Photos you already have are skipped.",
+ count_noun(count, "archive", "archives")
))
.build();
dialog.add_response("cancel", "Cancel");
@@ -429,7 +430,7 @@ pub(crate) fn wire_takeout(ui: &Rc, widgets: &TakeoutWidgets) {
let ui_back = ui.clone();
widgets
.back_button
- .connect_clicked(move |_| ui_back.stack.set_visible_child_name("main"));
+ .connect_clicked(move |_| ui_back.stack.set_visible_child_name("gallery"));
// Restore whatever was staged when the window last closed, before the first
// paint, so the page comes up as the user left it.
@@ -473,7 +474,10 @@ pub(crate) fn add_archives(ui: &Rc, paths: Vec) {
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
);
} else if added > 0 {
- toast(ui, &format!("Added {added} archive(s)"));
+ toast(
+ ui,
+ &format!("Added {}", count_noun(added, "archive", "archives")),
+ );
}
}
@@ -526,8 +530,8 @@ pub(crate) fn repaint_archives(ui: &Rc) {
rows.push(row.upcast());
}
ui.takeout.list_group.set_description(Some(&format!(
- "{} archive(s), {} in total. {EMPTY_LIST_DESCRIPTION}",
- staged.len(),
+ "{}, {} in total. {EMPTY_LIST_DESCRIPTION}",
+ count_noun(staged.len(), "archive", "archives"),
human_bytes(total)
)));
}
diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs
index 2fb53ce..5fa74d8 100644
--- a/crates/pdfs-gui/src/app/pages/trash.rs
+++ b/crates/pdfs-gui/src/app/pages/trash.rs
@@ -378,7 +378,8 @@ pub(crate) fn prompt_empty_trash(ui: &Rc) {
let dialog = adw::AlertDialog::builder()
.heading("Empty Trash")
.body(format!(
- "Permanently delete all {count} item(s) in the trash? This cannot be undone."
+ "Permanently delete all {} in the trash? This cannot be undone.",
+ count_noun(count as usize, "item", "items")
))
.build();
dialog.add_response("cancel", "Cancel");
diff --git a/crates/pdfs-gui/src/app/widgets/details.rs b/crates/pdfs-gui/src/app/widgets/details.rs
index 4768d9a..84684fe 100644
--- a/crates/pdfs-gui/src/app/widgets/details.rs
+++ b/crates/pdfs-gui/src/app/widgets/details.rs
@@ -89,7 +89,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let offline = adw::PreferencesGroup::new();
let pin_row = adw::SwitchRow::builder()
.title("Available offline")
- .subtitle("Keep a local copy on this device.")
+ .subtitle("Keep a copy on this computer, even without a connection.")
.build();
offline.add(&pin_row);
@@ -208,7 +208,7 @@ pub(crate) fn wire_details(ui: &Rc) {
ui.details.details.trash_button.connect_clicked(move |_| {
let entry = ui_trash.details.details_entry.borrow().clone();
if let Some(entry) = entry {
- prompt_delete(&ui_trash, &entry);
+ trash_entry(&ui_trash, &entry);
}
});
let ui_pin = ui.clone();
@@ -256,7 +256,10 @@ pub(crate) fn show_details(ui: &Rc, entry: &DirEntry) {
} else {
parent
});
+ // Pinning is per file: the context menu and the selection bar offer it for
+ // files only, so the pane must not offer it for folders either.
d.pin_row.set_active(entry.pinned);
+ d.pin_row.set_visible(!entry.is_dir);
d.pin_row.set_sensitive(*ui.mounted.borrow());
d.open_button
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs
index fbee202..e66d07f 100644
--- a/crates/pdfs-gui/src/app/widgets/share_dialog.rs
+++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs
@@ -361,13 +361,24 @@ pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntr
let state_rm = state.clone();
let id = entry.id.clone();
let kind = entry.kind;
- remove.connect_clicked(move |_| {
- share_dialog_op(
- &state_rm,
- state_rm.target.remove_entry(id.clone(), kind),
- "Access removed",
- "Couldn't remove access",
- None,
+ let who = entry.email.clone();
+ remove.connect_clicked(move |btn| {
+ let state = state_rm.clone();
+ let id = id.clone();
+ confirm_destructive(
+ btn,
+ "Remove Access?",
+ &format!("{who} will no longer be able to open this item."),
+ "Remove",
+ move || {
+ share_dialog_op(
+ &state,
+ state.target.remove_entry(id.clone(), kind),
+ "Access removed",
+ "Couldn't remove access",
+ None,
+ );
+ },
);
});
row.add_suffix(&remove);
@@ -390,9 +401,12 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi
Some(link) => {
let url = link.url.clone().unwrap_or_default();
let subtitle = if link.has_password {
- format!("Anyone with the link ({}) · password-protected", link.role)
+ format!(
+ "Anyone with the link ({}) · password-protected",
+ capitalize(&link.role)
+ )
} else {
- format!("Anyone with the link ({})", link.role)
+ format!("Anyone with the link ({})", capitalize(&link.role))
};
let row = adw::ActionRow::builder()
.title(if url.is_empty() { "Public link" } else { &url })
@@ -424,13 +438,24 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi
remove.add_css_class("flat");
let state_rm = state.clone();
let id = link.id.clone();
- remove.connect_clicked(move |_| {
- share_dialog_op(
- &state_rm,
- state_rm.target.remove_link(id.clone()),
- "Public link removed",
- "Couldn't remove the link",
- None,
+ remove.connect_clicked(move |btn| {
+ let state = state_rm.clone();
+ let id = id.clone();
+ confirm_destructive(
+ btn,
+ "Remove Public Link?",
+ "The link stops working for everyone who has it. A new link will have \
+ a different address.",
+ "Remove Link",
+ move || {
+ share_dialog_op(
+ &state,
+ state.target.remove_link(id.clone()),
+ "Public link removed",
+ "Couldn't remove the link",
+ None,
+ );
+ },
);
});
row.add_suffix(&remove);
diff --git a/crates/pdfs-gui/src/app/widgets/versions_dialog.rs b/crates/pdfs-gui/src/app/widgets/versions_dialog.rs
index 23f5da5..7a2fdac 100644
--- a/crates/pdfs-gui/src/app/widgets/versions_dialog.rs
+++ b/crates/pdfs-gui/src/app/widgets/versions_dialog.rs
@@ -182,16 +182,20 @@ fn repaint_versions(state: &Rc, items: &[RevisionInfo]) {
.filter(|size| *size >= 0)
.unwrap_or(item.size_on_storage)
.max(0) as u64;
- let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size));
+ // A revision id means nothing to a person; name earlier versions by
+ // when they were saved instead.
+ let (title, mut subtitle) = if item.is_active {
+ (
+ "Current version".to_string(),
+ format!("{} · {}", activity_time(item.created), human_bytes(size)),
+ )
+ } else {
+ (activity_time(item.created), human_bytes(size))
+ };
if let Some(email) = &item.signed_by {
subtitle.push_str(" · ");
subtitle.push_str(email);
}
- let title = if item.is_active {
- "Current version".to_string()
- } else {
- format!("Version {}", item.id)
- };
let row = adw::ActionRow::builder()
.title(title)
.subtitle(subtitle)
diff --git a/crates/pdfs-gui/src/dmenu.rs b/crates/pdfs-gui/src/dmenu.rs
index f7f17e2..3f29615 100644
--- a/crates/pdfs-gui/src/dmenu.rs
+++ b/crates/pdfs-gui/src/dmenu.rs
@@ -142,6 +142,7 @@ mod tests {
size: 0,
modified: 0,
pinned: false,
+ cached: false,
uid: "uid".into(),
mounted_path: None,
score: 0,
diff --git a/crates/pdfs-gui/src/fzf.rs b/crates/pdfs-gui/src/fzf.rs
index 32edf9b..60fd3fc 100644
--- a/crates/pdfs-gui/src/fzf.rs
+++ b/crates/pdfs-gui/src/fzf.rs
@@ -268,6 +268,7 @@ mod tests {
size: 7,
modified: 9,
pinned: false,
+ cached: false,
uid: "uid~1".into(),
mounted_path: None,
score: 42,
diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs
index d87e973..8ee345e 100644
--- a/crates/pdfs-gui/src/prompt.rs
+++ b/crates/pdfs-gui/src/prompt.rs
@@ -908,6 +908,7 @@ impl Ui {
size: 0,
modified: 0,
pinned: true,
+ cached: false,
uid: pin.uid,
mounted_path: None,
score: 0,
@@ -1613,6 +1614,7 @@ mod tests {
size: 1,
modified: 1,
pinned: false,
+ cached: false,
uid: "v~a".into(),
mounted_path: None,
score: 100,
diff --git a/crates/pdfs-gui/src/query.rs b/crates/pdfs-gui/src/query.rs
index 94d27e2..04c89e5 100644
--- a/crates/pdfs-gui/src/query.rs
+++ b/crates/pdfs-gui/src/query.rs
@@ -45,6 +45,7 @@ pub(crate) fn pins(socket: &Path) -> Result, String> {
size: 0,
modified: 0,
pinned: true,
+ cached: false,
uid: pin.uid,
mounted_path: None,
score: 0,
@@ -178,6 +179,7 @@ mod tests {
size: 0,
modified: 0,
pinned: false,
+ cached: false,
uid: "uid".into(),
mounted_path: None,
score: 0,