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.svg scalable/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,