From 8bd9481902b093d6fb56dc653089981461fa9a3f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 18 Aug 2026 12:50:49 +0200 Subject: [PATCH] fix(ui) improved ux --- Cargo.lock | 36 +- Cargo.toml | 13 +- crates/pdfs-gui/src/app/main.rs | 61 ++- crates/pdfs-gui/src/app/pages/browser.rs | 472 +++++++++++++++++++-- crates/pdfs-gui/src/app/pages/photos.rs | 49 ++- crates/pdfs-gui/src/app/pages/status.rs | 82 +++- crates/pdfs-gui/src/app/pages/takeout.rs | 45 ++ crates/pdfs-gui/src/app/widgets/details.rs | 31 +- docs/CHANGELOG.md | 32 +- packaging/PKGBUILD | 2 +- 10 files changed, 739 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b928753..6ef147e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,7 +187,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -198,7 +198,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1568,7 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3223,7 +3223,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3538,7 +3538,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pdfs-cli" -version = "1.8.2" +version = "1.9.0" dependencies = [ "anyhow", "clap 4.6.4", @@ -3553,7 +3553,7 @@ dependencies = [ [[package]] name = "pdfs-core" -version = "1.8.2" +version = "1.9.0" dependencies = [ "async-trait", "directories", @@ -3576,7 +3576,7 @@ dependencies = [ [[package]] name = "pdfs-fuse" -version = "1.8.2" +version = "1.9.0" dependencies = [ "fuser", "futures", @@ -3595,7 +3595,7 @@ dependencies = [ [[package]] name = "pdfs-gui" -version = "1.8.2" +version = "1.9.0" dependencies = [ "anyhow", "async-channel", @@ -3857,6 +3857,8 @@ dependencies = [ [[package]] name = "proton-drive-rs" version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58322795538d66137d2eb071590375c5287e69059c429e2309dc37368ab5b588" dependencies = [ "async-trait", "base64", @@ -3882,6 +3884,8 @@ dependencies = [ [[package]] name = "proton-sdk" version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77482a3160d14926c65537c64bc95cf27d609597a524c97d97d97d1777e9603" dependencies = [ "aes-gcm 0.11.0", "async-trait", @@ -3999,7 +4003,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4424,7 +4428,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4481,7 +4485,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4833,7 +4837,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4982,10 +4986,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5333,7 +5337,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5658,7 +5662,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1713c6e..6da9058 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,15 +3,15 @@ resolver = "2" members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] [workspace.package] -version = "1.8.2" +version = "1.9.0" edition = "2024" license = "MIT" authors = ["Nils Pukropp "] rust-version = "1.96" [workspace.dependencies] -proton-sdk = { version = "0.6.0" } -proton-drive-rs = { version = "0.6.0" } +proton-sdk = { version = "0.6.1" } +proton-drive-rs = { version = "0.6.1" } # Internal crates. pdfs-core = { path = "crates/pdfs-core" } @@ -50,10 +50,3 @@ async-trait = "0.1" futures = "0.3" fuser = "0.17" libc = "0.2" - -# LOCAL ONLY — do not commit. The Takeout import needs album writes and batch -# duplicate detection, which are in the working tree of proton-sdk-rs but not in -# a published 0.6.x. Releases build against the registry versions. -[patch.crates-io] -proton-sdk = { path = "../proton-sdk-rs/crates/proton-sdk" } -proton-drive-rs = { path = "../proton-sdk-rs/crates/proton-drive-rs" } diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 5891044..b2545a9 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -249,6 +249,7 @@ fn load_proton_theme() { .navigation-sidebar row:selected {{ background: alpha({PROTON_PURPLE}, 0.16); color: {PROTON_PURPLE}; font-weight: 600; }}\n\ .navigation-sidebar row:selected image {{ color: {PROTON_PURPLE}; }}\n\ .file-tile:selected, .file-tile:hover:selected {{ background: alpha({PROTON_PURPLE}, 0.20); }}\n\ + .bulk-bar {{ padding: 6px 8px; border-radius: 12px; background: alpha({PROTON_PURPLE}, 0.12); }}\n\ .dropzone {{ border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }}\n\ .dropzone-active {{ border-color: {PROTON_PURPLE}; background: alpha({PROTON_PURPLE}, 0.10); }}\n" ); @@ -368,6 +369,8 @@ fn build_window(app: &adw::Application) { budget_row: main_widgets.budget_row.clone(), mountpoint_row: main_widgets.mountpoint_row.clone(), settings_suppress: Cell::new(false), + budget_source: RefCell::new(None), + pins_expanded: Cell::new(false), pins_group: main_widgets.pins_group.clone(), pin_rows: RefCell::new(Vec::new()), pins_state: RefCell::new(None), @@ -388,6 +391,13 @@ fn build_window(app: &adw::Application) { upload: browser_widgets.upload.clone(), upload_folder: browser_widgets.upload_folder.clone(), search_source: RefCell::new(None), + views: browser_widgets.views.clone(), + bulk: browser_widgets.bulk.clone(), + bulk_label: browser_widgets.bulk_label.clone(), + bulk_trash: browser_widgets.bulk_trash.clone(), + bulk_pin: browser_widgets.bulk_pin.clone(), + bulk_unpin: browser_widgets.bulk_unpin.clone(), + empty_actions: browser_widgets.empty_actions.clone(), }, details: DetailsState { details: browser_widgets.details, @@ -414,6 +424,7 @@ fn build_window(app: &adw::Application) { more: gallery_widgets.more.clone(), upload: gallery_widgets.upload.clone(), import: gallery_widgets.import.clone(), + empty_actions: gallery_widgets.empty_actions.clone(), title: gallery_widgets.title.clone(), subtitle: gallery_widgets.subtitle.clone(), albums: gallery_widgets.albums.clone(), @@ -530,6 +541,12 @@ fn build_window(app: &adw::Application) { ); wire_sidebar(&ui); wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view); + wire_bulk( + &ui, + &browser_widgets.bulk_clear, + &browser_widgets.empty_upload, + &browser_widgets.empty_new_folder, + ); wire_browser_actions( &ui, &browser_widgets.new_folder, @@ -539,6 +556,11 @@ fn build_window(app: &adw::Application) { wire_details(&ui); wire_search(&ui); wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll); + wire_gallery_empty( + &ui, + &gallery_widgets.empty_upload, + &gallery_widgets.empty_import, + ); wire_albums(&ui); wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty); wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); @@ -822,6 +844,22 @@ fn toast(ui: &Rc, message: &str) { ui.toasts.add_toast(adw::Toast::new(message)); } +/// Show a toast that offers one follow-up action — an Undo, or a way to the page +/// the outcome landed on. +/// +/// Given a longer timeout than a plain toast: an action the user has to notice, +/// read and reach is not the same ask as a line of confirmation they can ignore. +fn toast_action(ui: &Rc, message: &str, label: &str, action: impl Fn(&Rc) + 'static) { + let toast = adw::Toast::builder() + .title(message) + .button_label(label) + .timeout(8) + .build(); + let ui_action = ui.clone(); + toast.connect_button_clicked(move |_| action(&ui_action)); + ui.toasts.add_toast(toast); +} + /// Show a toast for a failure. Same surface as [`toast`], but the message is /// prefixed with what was being attempted, since a bare daemon error ("no such /// file") reads as noise without it. @@ -959,7 +997,13 @@ fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) { // browser-only bindings. gtk4::gdk::Key::F5 => reload_current_page(&ui), gtk4::gdk::Key::r | gtk4::gdk::Key::R if ctrl => reload_current_page(&ui), - gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl && on_browser => { + // Search lives on Files, but the shortcut works from anywhere: + // wanting to find a file is not a reason to first have to remember + // which page owns the search box. + gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl => { + if !on_browser { + ui.stack.set_visible_child_name("browser"); + } ui.browser.search.grab_focus(); } gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui), @@ -969,12 +1013,21 @@ fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) { prompt_rename(&ui, &entry); } } + // Acts on the whole selection: Delete with five files highlighted + // means those five, not whichever one the details pane happens to + // be describing. gtk4::gdk::Key::Delete if on_browser => { - if let Some(entry) = selected_entry(&ui) { - prompt_delete(&ui, &entry); + let entries = selected_entries(&ui); + if !entries.is_empty() { + prompt_delete_many(&ui, entries); } } - gtk4::gdk::Key::Escape if on_browser && ui.browser.split.shows_sidebar() => { + gtk4::gdk::Key::Escape + if on_browser + && (ui.browser.split.shows_sidebar() || ui.browser.bulk.reveals_child()) => + { + clear_selection(&ui); + sync_bulk_bar(&ui); hide_details(&ui); } _ => return glib::Propagation::Proceed, diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index a12fe29..e01cecc 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -31,6 +31,21 @@ pub(crate) struct BrowserState { /// Pending debounce timer for the search box; replaced on every keystroke so /// only the last pause actually fires a [`Request::Search`]. pub(crate) search_source: RefCell>, + /// The grid/list view stack, read to find out which of the two selections is + /// the one the user is actually working in. + pub(crate) views: gtk4::Stack, + /// The bulk-action bar, revealed once more than one entry is selected. + pub(crate) bulk: gtk4::Revealer, + pub(crate) bulk_label: gtk4::Label, + /// Bulk buttons, sensitive only where they apply (offline state is a + /// file-only notion, so a folder in the selection disables those two). + pub(crate) bulk_trash: gtk4::Button, + pub(crate) bulk_pin: gtk4::Button, + pub(crate) bulk_unpin: gtk4::Button, + /// Upload / New folder offered on the *empty folder* status page, so that + /// state is a place to act rather than a dead end. Hidden on every other + /// status (a load error is not the moment to offer an upload). + pub(crate) empty_actions: gtk4::Box, } /// Idle pause after the last keystroke before a search query is sent, so typing @@ -76,9 +91,20 @@ pub(crate) struct BrowserWidgets { pub(crate) split: adw::OverlaySplitView, pub(crate) details: DetailsWidgets, /// The two selection models, so a selection change can drive the details pane - /// and so an action can re-read the entry the user has highlighted. - pub(crate) grid_selection: gtk4::SingleSelection, - pub(crate) list_selection: gtk4::SingleSelection, + /// and so an action can re-read the entries the user has highlighted. + pub(crate) grid_selection: gtk4::MultiSelection, + pub(crate) list_selection: gtk4::MultiSelection, + /// The grid/list stack, so the page can tell which selection is live. + pub(crate) views: gtk4::Stack, + pub(crate) bulk: gtk4::Revealer, + pub(crate) bulk_label: gtk4::Label, + pub(crate) bulk_trash: gtk4::Button, + pub(crate) bulk_pin: gtk4::Button, + pub(crate) bulk_unpin: gtk4::Button, + pub(crate) bulk_clear: gtk4::Button, + pub(crate) empty_actions: gtk4::Box, + pub(crate) empty_upload: gtk4::Button, + pub(crate) empty_new_folder: gtk4::Button, } pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { @@ -166,19 +192,34 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { retry.add_css_class("pill"); retry.add_css_class("suggested-action"); retry.set_visible(false); + // The empty-folder state's way out. `StatusPage` takes one child, so Retry + // and these share a box; each is shown only for the state it belongs to. + let empty_upload = gtk4::Button::builder().label("Upload files").build(); + empty_upload.add_css_class("pill"); + empty_upload.add_css_class("suggested-action"); + let empty_new_folder = gtk4::Button::builder().label("New folder").build(); + empty_new_folder.add_css_class("pill"); + let empty_actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + empty_actions.set_halign(gtk4::Align::Center); + empty_actions.set_visible(false); + empty_actions.append(&empty_upload); + empty_actions.append(&empty_new_folder); + + let status_child = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + status_child.append(&retry); + status_child.append(&empty_actions); + let status = adw::StatusPage::builder() .icon_name("folder-symbolic") .vexpand(true) - .child(&retry) + .child(&status_child) .build(); status.add_css_class("compact"); - // Icon grid. - let grid_selection = gtk4::SingleSelection::builder() - .model(&model) - .autoselect(false) - .can_unselect(true) - .build(); + // Icon grid. Multi-select: acting on a batch is the common case for trashing + // and for taking a folder's worth of files offline, and doing it one + // confirmation dialog at a time is not a workflow. + let grid_selection = gtk4::MultiSelection::new(Some(model.clone())); let grid = gtk4::GridView::builder() .model(&grid_selection) .min_columns(2) @@ -190,12 +231,8 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { .child(&grid) .build(); - // Column list. - let list_selection = gtk4::SingleSelection::builder() - .model(&model) - .autoselect(false) - .can_unselect(true) - .build(); + // Column list, with its own selection over the same model. + let list_selection = gtk4::MultiSelection::new(Some(model.clone())); let column_view = gtk4::ColumnView::builder().model(&list_selection).build(); column_view.add_css_class("data-table"); let column_scroll = gtk4::ScrolledWindow::builder() @@ -239,12 +276,51 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { .sidebar(&details_pane) .build(); + // Bulk-action bar. A revealer rather than a hidden box so it slides in + // 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") + .valign(gtk4::Align::Center) + .build(); + bulk_pin.add_css_class("flat"); + let bulk_unpin = gtk4::Button::builder() + .label("Remove offline copy") + .valign(gtk4::Align::Center) + .build(); + bulk_unpin.add_css_class("flat"); + let bulk_trash = gtk4::Button::builder() + .label("Move to Trash") + .valign(gtk4::Align::Center) + .build(); + bulk_trash.add_css_class("destructive-action"); + let bulk_clear = gtk4::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Clear selection (Esc)") + .valign(gtk4::Align::Center) + .build(); + bulk_clear.add_css_class("flat"); + bulk_clear.add_css_class("circular"); + let bulk_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + bulk_box.add_css_class("toolbar"); + bulk_box.add_css_class("bulk-bar"); + bulk_box.append(&bulk_label); + bulk_box.append(&bulk_pin); + bulk_box.append(&bulk_unpin); + bulk_box.append(&bulk_trash); + bulk_box.append(&bulk_clear); + let bulk = gtk4::Revealer::builder() + .transition_type(gtk4::RevealerTransitionType::SlideDown) + .child(&bulk_box) + .build(); + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); inner.set_margin_top(12); inner.set_margin_bottom(12); inner.set_margin_start(12); inner.set_margin_end(12); inner.append(&header); + inner.append(&bulk); inner.append(&split); ( @@ -267,6 +343,16 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { details, grid_selection, list_selection, + views: view_stack, + bulk, + bulk_label, + bulk_trash, + bulk_pin, + bulk_unpin, + bulk_clear, + empty_actions, + empty_upload, + empty_new_folder, }, ) } @@ -278,10 +364,273 @@ pub(crate) fn browser_status(ui: &Rc, icon: &str, title: &str, description: ui.browser.status.set_title(title); ui.browser.status.set_description(Some(description)); ui.browser.retry.set_visible(retry); + // Only the empty-folder state offers a way to fill the folder; every other + // status (loading, offline, error) turns them back off. + ui.browser.empty_actions.set_visible(false); ui.browser.content.set_visible_child_name("status"); hide_details(ui); } +/// The selection model of whichever view is on screen. The grid and the list +/// each own one over the same model, so "what is selected" depends on which the +/// user is looking at. +pub(crate) fn active_selection(ui: &Rc) -> gtk4::MultiSelection { + if ui.browser.views.visible_child_name().as_deref() == Some("list") { + ui.details.list_selection.clone() + } else { + ui.details.grid_selection.clone() + } +} + +/// Every entry highlighted in the view on screen, in model order. +/// +/// Walks the model rather than the selection bitset: a listing is at most a few +/// thousand rows, and asking each position whether it is selected keeps this +/// free of bitset-iterator lifetimes for no measurable cost. +pub(crate) fn selected_entries(ui: &Rc) -> Vec { + let selection = active_selection(ui); + let count = selection.n_items(); + (0..count) + .filter(|i| selection.is_selected(*i)) + .filter_map(|i| entry_at(Some(&selection), i)) + .collect() +} + +/// Drop the selection in both views, which also retracts the bulk bar. +pub(crate) fn clear_selection(ui: &Rc) { + ui.details.grid_selection.unselect_all(); + ui.details.list_selection.unselect_all(); +} + +/// Reflect the current selection in the bulk bar: how many are selected, and +/// which bulk actions apply to that mix. Offline state is a file-only notion, so +/// a folder anywhere in the selection disables the two pin buttons rather than +/// half-failing once pressed. +pub(crate) fn sync_bulk_bar(ui: &Rc) { + let entries = selected_entries(ui); + // One selected entry is the details pane's job; the bar is for a batch. + if entries.len() < 2 { + ui.browser.bulk.set_reveal_child(false); + return; + } + let files_only = entries.iter().all(|e| !e.is_dir); + let any_pinned = entries.iter().any(|e| e.pinned); + let any_unpinned = entries.iter().any(|e| !e.pinned); + let mounted = *ui.mounted.borrow(); + ui.browser + .bulk_label + .set_label(&format!("{} selected", entries.len())); + ui.browser.bulk_trash.set_sensitive(mounted); + ui.browser + .bulk_pin + .set_sensitive(mounted && files_only && any_unpinned); + ui.browser + .bulk_unpin + .set_sensitive(mounted && files_only && any_pinned); + ui.browser.bulk.set_reveal_child(true); +} + +/// Confirm and 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; + } + 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. +/// +/// Sequential rather than parallel: the daemon serialises these anyway, and a +/// burst of threads against one socket buys nothing but a harder failure to +/// report. Partial success is reported as such — the entries that did move are +/// still restorable through the Undo. +pub(crate) fn run_bulk_delete(ui: &Rc, entries: Vec) { + if !*ui.mounted.borrow() { + toast_error( + ui, + "Couldn't move to Trash", + "Proton Drive isn't connected.", + ); + return; + } + let socket = ui.dirs.control_socket(); + // A one-entry batch is still a batch, but it is worth naming in the toast: + // "Moved “notes.txt” to Trash" tells the user which file far better than a + // count of one does. + let single = (entries.len() == 1).then(|| entries[0].name.clone()); + let paths: Vec<(String, String)> = entries + .iter() + .map(|e| (entry_rel(ui, e), e.uid.clone())) + .collect(); + ui.busy_begin(); + clear_selection(ui); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let mut trashed: Vec = Vec::new(); + let mut failure: Option = None; + for (path, uid) in paths { + let rx = spawn_request(socket.clone(), Request::Delete { path }); + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => trashed.push(uid), + Ok(Ok(Response::Error { message, .. })) => { + failure.get_or_insert(message); + } + _ => { + failure.get_or_insert_with(|| "The mount service didn't respond.".to_string()); + } + } + } + ui.busy_end(); + reload_listing(&ui); + match (trashed.len(), failure) { + (0, Some(message)) => toast_error(&ui, "Couldn't move to Trash", &message), + (0, None) => {} + (n, failure) => { + let message = match (failure, &single) { + (Some(_), _) => format!("Moved {n} items to Trash — some couldn't be moved"), + (None, Some(name)) => format!("Moved “{name}” to Trash"), + (None, None) => format!("Moved {n} items to Trash"), + }; + toast_action(&ui, &message, "Undo", move |ui| { + restore_uids(ui, trashed.clone(), n); + }); + } + } + }); +} + +/// Put trashed nodes back where they came from — the Undo behind a trash toast. +pub(crate) fn restore_uids(ui: &Rc, uids: Vec, count: usize) { + run_mutation( + ui, + Request::Restore { uids }, + if count == 1 { + "Restored from Trash".to_string() + } else { + format!("Restored {count} items from Trash") + }, + "Couldn't restore from Trash", + ); +} + +/// Pin or unpin every selected file, one request at a time. +pub(crate) fn run_bulk_pin(ui: &Rc, entries: Vec, pin: bool) { + let socket = ui.dirs.control_socket(); + // Only the entries that need changing: re-pinning an already-pinned file is + // a wasted round-trip, and it would inflate the count the toast reports. + let paths: Vec = entries + .iter() + .filter(|e| !e.is_dir && e.pinned != pin) + .map(|e| entry_rel(ui, e)) + .collect(); + if paths.is_empty() { + return; + } + ui.busy_begin(); + clear_selection(ui); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let mut done = 0usize; + let mut failure: Option = None; + for path in paths { + let req = if pin { + Request::Pin { path } + } else { + Request::Unpin { path } + }; + let rx = spawn_request(socket.clone(), req); + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => done += 1, + Ok(Ok(Response::Error { message, .. })) => { + failure.get_or_insert(message); + } + _ => { + failure.get_or_insert_with(|| "The mount service didn't respond.".to_string()); + } + } + } + ui.busy_end(); + load_browser(&ui); + match (done, failure) { + (0, Some(message)) => toast_error(&ui, "Couldn't change offline state", &message), + (0, None) => {} + (n, _) => toast( + &ui, + &if pin { + format!("{n} files are now available offline") + } else { + format!("{n} files are no longer kept offline") + }, + ), + } + }); +} + +/// Wire the bulk-action bar and the empty-folder state's buttons. +pub(crate) fn wire_bulk( + ui: &Rc, + bulk_clear: >k4::Button, + empty_upload: >k4::Button, + empty_new_folder: >k4::Button, +) { + let ui_trash = ui.clone(); + ui.browser + .bulk_trash + .connect_clicked(move |_| prompt_delete_many(&ui_trash, selected_entries(&ui_trash))); + let ui_pin = ui.clone(); + ui.browser + .bulk_pin + .connect_clicked(move |_| run_bulk_pin(&ui_pin, selected_entries(&ui_pin), true)); + let ui_unpin = ui.clone(); + ui.browser + .bulk_unpin + .connect_clicked(move |_| run_bulk_pin(&ui_unpin, selected_entries(&ui_unpin), false)); + let ui_clear = ui.clone(); + bulk_clear.connect_clicked(move |_| { + clear_selection(&ui_clear); + sync_bulk_bar(&ui_clear); + }); + + // Switching views switches which selection is live, so the bar has to be + // re-derived rather than left showing the other view's count. + let ui_view = ui.clone(); + ui.browser + .views + .connect_visible_child_name_notify(move |_| sync_bulk_bar(&ui_view)); + + let ui_upload = ui.clone(); + empty_upload.connect_clicked(move |_| prompt_upload(&ui_upload)); + let ui_new = ui.clone(); + empty_new_folder.connect_clicked(move |_| prompt_new_folder(&ui_new)); +} + /// Swap the Files content area back to the grid/list views. pub(crate) fn browser_views(ui: &Rc) { ui.browser.content.set_visible_child_name("views"); @@ -474,7 +823,14 @@ pub(crate) fn attach_context_menu(ui: &Rc, item: >k4::ListItem, anchor: &g gesture.connect_pressed(move |_, _, x, y| { if let Some(obj) = item.item().and_downcast::() { let entry = obj.borrow::().clone(); - show_context_menu(&ui, &entry, &target, x, y); + // Right-clicking a row that is part of a multi-selection acts on the + // batch; right-clicking outside one acts on the row, as before. + let selected = selected_entries(&ui); + if selected.len() > 1 && selected.iter().any(|e| e.uid == entry.uid) { + show_bulk_context_menu(&ui, selected, &target, x, y); + } else { + show_context_menu(&ui, &entry, &target, x, y); + } } }); anchor.add_controller(gesture); @@ -588,6 +944,75 @@ pub(crate) fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Bo popover.popup(); } +/// The context menu for a multi-selection: the batch-capable actions only. +/// Rename, Move, Share and the revision history all need a single subject, so +/// they are simply absent here rather than offered and then refused. +pub(crate) fn show_bulk_context_menu( + ui: &Rc, + entries: Vec, + anchor: >k4::Box, + x: f64, + y: f64, +) { + let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + let popover = gtk4::Popover::builder() + .has_arrow(false) + .position(gtk4::PositionType::Bottom) + .pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)) + .child(&menu) + .build(); + popover.set_parent(anchor); + popover.connect_closed(|p| p.unparent()); + + let header = gtk4::Label::builder() + .label(format!("{} selected", entries.len())) + .halign(gtk4::Align::Start) + .build(); + header.add_css_class("dim-label"); + header.add_css_class("caption"); + header.set_margin_top(6); + header.set_margin_bottom(4); + header.set_margin_start(10); + menu.append(&header); + + 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 ui_pin = ui.clone(); + let batch = entries.clone(); + let pop = popover.clone(); + pin.connect_clicked(move |_| { + pop.popdown(); + run_bulk_pin(&ui_pin, batch.clone(), true); + }); + menu.append(&pin); + } + if files_only && entries.iter().any(|e| e.pinned) { + let unpin = menu_item("Remove offline copy", "non-starred-symbolic"); + let ui_unpin = ui.clone(); + let batch = entries.clone(); + let pop = popover.clone(); + unpin.connect_clicked(move |_| { + pop.popdown(); + run_bulk_pin(&ui_unpin, batch.clone(), false); + }); + menu.append(&unpin); + } + + 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()); + }); + menu.append(&trash); + + popover.popup(); +} + /// A left-aligned, flat icon+label button for the context menu. pub(crate) fn menu_item(label: &str, icon: &str) -> gtk4::Button { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); @@ -976,8 +1401,6 @@ pub(crate) fn prompt_move(ui: &Rc, entry: &DirEntry) { /// 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 rel = entry_rel(ui, entry); - let name = entry.name.clone(); let dialog = adw::AlertDialog::builder() .heading("Move to Trash") .body(format!("Move “{}” to Trash?", entry.name)) @@ -988,15 +1411,13 @@ pub(crate) fn prompt_delete(ui: &Rc, entry: &DirEntry) { 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_mutation( - &ui, - Request::Delete { path: rel.clone() }, - format!("Moved “{name}” to Trash"), - "Couldn't move to Trash", - ); + run_bulk_delete(&ui, vec![entry.clone()]); } }); dialog.present(win.as_ref()); @@ -1346,6 +1767,7 @@ pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { "Upload a file or create a folder to get started.", false, ); + ui.browser.empty_actions.set_visible(true); return; } browser_views(ui); diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index b022f58..7824f30 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -21,6 +21,10 @@ pub(crate) struct GalleryState { pub(crate) upload: gtk4::Button, /// Opens the Google Photos Takeout import chooser. pub(crate) import: gtk4::Button, + /// Upload / Import offered on the *empty timeline* status page, so a fresh + /// account is a place to start rather than a dead end. Hidden on every other + /// status: a load error is not the moment to offer an upload. + pub(crate) empty_actions: gtk4::Box, /// "Photos", or the album's name while one is open. pub(crate) title: gtk4::Label, /// "1,204 photos" under the page title. @@ -180,6 +184,9 @@ pub(crate) struct GalleryWidgets { pub(crate) retry: gtk4::Button, pub(crate) upload: gtk4::Button, pub(crate) import: gtk4::Button, + pub(crate) empty_actions: gtk4::Box, + pub(crate) empty_upload: gtk4::Button, + pub(crate) empty_import: gtk4::Button, pub(crate) refresh: gtk4::Button, /// The Albums grid, its own status page and the stack between them, plus the /// Photos/Albums switcher and the back button out of an album. @@ -230,10 +237,29 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { retry.add_css_class("suggested-action"); retry.set_visible(false); + // The empty-timeline state's two ways forward. `StatusPage` takes one child, + // so Retry and these share a box and each is shown only for its own state. + let empty_upload = gtk4::Button::builder().label("Upload photos").build(); + empty_upload.add_css_class("pill"); + empty_upload.add_css_class("suggested-action"); + let empty_import = gtk4::Button::builder() + .label("Import from Google Photos") + .build(); + empty_import.add_css_class("pill"); + let empty_actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + empty_actions.set_halign(gtk4::Align::Center); + empty_actions.set_visible(false); + empty_actions.append(&empty_upload); + empty_actions.append(&empty_import); + + let status_child = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + status_child.append(&retry); + status_child.append(&empty_actions); + let status = adw::StatusPage::builder() .icon_name("image-x-generic-symbolic") .vexpand(true) - .child(&retry) + .child(&status_child) .build(); status.add_css_class("compact"); @@ -443,6 +469,9 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { retry, upload, import, + empty_actions, + empty_upload, + empty_import, refresh, tabs, favorites_btn, @@ -569,6 +598,15 @@ pub(crate) fn thousands(n: usize) -> String { /// Wire the gallery: install the section factory, the zoom gestures, the pager /// and the upload button. Activating a thumbnail downloads the photo and opens it /// in the in-app lightbox. +/// Wire the empty-timeline buttons. Upload re-fires the header's own Upload +/// rather than duplicating its file-chooser, so the two can't drift apart. +pub(crate) fn wire_gallery_empty(ui: &Rc, upload: >k4::Button, import: >k4::Button) { + let ui_upload = ui.clone(); + upload.connect_clicked(move |_| ui_upload.gallery.upload.emit_clicked()); + let ui_import = ui.clone(); + import.connect_clicked(move |_| ui_import.stack.set_visible_child_name("takeout")); +} + pub(crate) fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::ScrolledWindow) { let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup(|_, item| { @@ -1483,6 +1521,12 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { ) }; gallery_status(&ui, "image-x-generic-symbolic", title, description, false); + // Only the whole-timeline empty state: an empty *album* is + // filled by adding existing photos to it, not by uploading + // into it, so the buttons would point the wrong way. + ui.gallery + .empty_actions + .set_visible(ui.gallery.album.borrow().is_none()); return; } ui.gallery.content.set_visible_child_name("timeline"); @@ -1525,6 +1569,9 @@ pub(crate) fn gallery_status(ui: &Rc, icon: &str, title: &str, description: ui.gallery.status.set_title(title); ui.gallery.status.set_description(Some(description)); ui.gallery.retry.set_visible(retry); + // Only the empty timeline offers a way to fill it; every other status turns + // those buttons back off. + ui.gallery.empty_actions.set_visible(false); ui.gallery.more.set_visible(false); ui.gallery.content.set_visible_child_name("status"); } diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index dcb24e3..be0b788 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -37,7 +37,16 @@ pub(crate) struct StatusState { /// Set while a settings widget is being populated programmatically, so its /// change handler skips the IPC/systemd side effect. pub(crate) settings_suppress: Cell, + /// Pending debounce for the cache-budget editor. Each `+` click is a value + /// change, and firing a `SetCacheBudget` (and a toast) per click means a + /// stack of toasts and a series of caps the user never asked to apply — so + /// only the value they settle on is sent. + pub(crate) budget_source: RefCell>, pub(crate) pins_group: adw::PreferencesGroup, + /// Whether the pin list is showing every pin or only the first + /// [`PINS_COLLAPSED`]. A long pin list would otherwise push everything below + /// it — including the Developer group — off the end of the page. + pub(crate) pins_expanded: Cell, /// Rows currently shown under [`Self::pins_group`], retained so a refresh can /// diff against them and only rebuild when the pin set actually changes. pub(crate) pin_rows: RefCell>, @@ -316,6 +325,15 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) { ) } +/// How many pins the Settings list shows before collapsing the rest behind a +/// "Show all" row. Enough to recognise the list at a glance; short enough that +/// the groups below it stay reachable. +pub(crate) const PINS_COLLAPSED: usize = 6; + +/// How long the cache-budget editor waits after the last change before applying +/// it, so a run of `+` clicks is one request rather than one per click. +pub(crate) const BUDGET_DEBOUNCE: Duration = Duration::from_millis(600); + /// Bytes per GiB, for the cache-budget editor's unit conversion. pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0; @@ -350,13 +368,22 @@ pub(crate) fn wire_settings( if ui_budget.status.settings_suppress.get() { return; } + // Replace any pending apply, so only the value the user stops on is sent. + if let Some(src) = ui_budget.status.budget_source.borrow_mut().take() { + src.remove(); + } let bytes = (row.value() * GIB).round() as u64; - settings_request( - &ui_budget, - Request::SetCacheBudget { bytes }, - "Cache budget updated", - "Couldn't set cache budget", - ); + let ui_fire = ui_budget.clone(); + let src = glib::timeout_add_local_once(BUDGET_DEBOUNCE, move || { + ui_fire.status.budget_source.borrow_mut().take(); + settings_request( + &ui_fire, + Request::SetCacheBudget { bytes }, + "Cache budget updated", + "Couldn't set cache budget", + ); + }); + *ui_budget.status.budget_source.borrow_mut() = Some(src); }); // Purge: confirm, then drop all unpinned cached content via the daemon. @@ -903,7 +930,15 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: return; } - for pin in pins { + // Only the first page of pins is rendered; the rest sit behind the row + // below, so a device with hundreds of pins doesn't bury the groups after it. + let expanded = ui.status.pins_expanded.get(); + let shown = if expanded { + pins.len() + } else { + pins.len().min(PINS_COLLAPSED) + }; + for pin in &pins[..shown] { let name = Path::new(&pin.path) .file_name() .and_then(|n| n.to_str()) @@ -941,4 +976,37 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: unpin: Some(unpin), }); } + + if pins.len() > PINS_COLLAPSED { + let row = adw::ActionRow::builder() + .title(if expanded { + "Show fewer".to_string() + } else { + format!("Show all {} pinned files", pins.len()) + }) + .activatable(true) + .build(); + row.add_suffix(>k4::Image::from_icon_name(if expanded { + "go-up-symbolic" + } else { + "go-down-symbolic" + })); + let ui_more = ui.clone(); + row.connect_activated(move |_| { + ui_more + .status + .pins_expanded + .set(!ui_more.status.pins_expanded.get()); + // The diff guard compares pin *paths*; the same paths render + // differently now, so the baseline has to be dropped for the next + // repaint to actually rebuild. + *ui_more.status.pins_state.borrow_mut() = None; + refresh(&ui_more); + }); + ui.status.pins_group.add(&row); + ui.status + .pin_rows + .borrow_mut() + .push(PinRow { row, unpin: None }); + } } diff --git a/crates/pdfs-gui/src/app/pages/takeout.rs b/crates/pdfs-gui/src/app/pages/takeout.rs index 5f2625f..d11d6b9 100644 --- a/crates/pdfs-gui/src/app/pages/takeout.rs +++ b/crates/pdfs-gui/src/app/pages/takeout.rs @@ -29,6 +29,47 @@ const IMPORT_JOB: &str = "Importing Google Photos"; /// What the archive list says before anything is staged. `repaint_archives` /// swaps in the count and total once there is something to report, and swaps /// this back when the list is emptied again. +/// Where the staged set is remembered between runs, under the app's state +/// directory. Staging an export means picking dozens of files out of a file +/// chooser; losing that to a window close (or a crash mid-import) and having to +/// redo it is the kind of small cruelty that makes a migration not get finished. +fn staged_path(ui: &Rc) -> PathBuf { + ui.dirs.state_dir().join("takeout-staged.json") +} + +/// Read the remembered set, dropping any archive that has since been moved or +/// deleted — a stale path would only fail at launch, and silently is worse. +fn load_staged(ui: &Rc) -> Vec { + let Ok(text) = std::fs::read_to_string(staged_path(ui)) else { + return Vec::new(); + }; + let paths: Vec = serde_json::from_str(&text).unwrap_or_default(); + paths + .into_iter() + .map(PathBuf::from) + .filter(|p| p.is_file()) + .collect() +} + +/// Remember the staged set. Best-effort: failing to write it costs the user a +/// re-pick next time, which is not worth interrupting them over now. +fn save_staged(ui: &Rc) { + let paths: Vec = ui + .takeout + .archives + .borrow() + .iter() + .map(|p| p.display().to_string()) + .collect(); + let path = staged_path(ui); + let written = serde_json::to_string(&paths) + .map_err(|e| e.to_string()) + .and_then(|text| std::fs::write(&path, text).map_err(|e| e.to_string())); + if let Err(e) = written { + tracing::warn!("cannot remember staged Takeout archives: {e}"); + } +} + const EMPTY_LIST_DESCRIPTION: &str = "Photos already in Proton Photos are matched by name and content and skipped, so \ re-running an interrupted import is safe."; @@ -390,6 +431,9 @@ pub(crate) fn wire_takeout(ui: &Rc, widgets: &TakeoutWidgets) { .back_button .connect_clicked(move |_| ui_back.stack.set_visible_child_name("main")); + // Restore whatever was staged when the window last closed, before the first + // paint, so the page comes up as the user left it. + *ui.takeout.archives.borrow_mut() = load_staged(ui); repaint_archives(ui); } @@ -489,6 +533,7 @@ pub(crate) fn repaint_archives(ui: &Rc) { } *ui.takeout.rows.borrow_mut() = rows; + save_staged(ui); sync_takeout_actions(ui); } diff --git a/crates/pdfs-gui/src/app/widgets/details.rs b/crates/pdfs-gui/src/app/widgets/details.rs index 40a8f90..4768d9a 100644 --- a/crates/pdfs-gui/src/app/widgets/details.rs +++ b/crates/pdfs-gui/src/app/widgets/details.rs @@ -11,8 +11,8 @@ pub(crate) struct DetailsState { pub(crate) details_suppress: Cell, /// The grid's and list's selection models. Both wrap the one browser model, so /// a selection in either drives the details pane. - pub(crate) grid_selection: gtk4::SingleSelection, - pub(crate) list_selection: gtk4::SingleSelection, + pub(crate) grid_selection: gtk4::MultiSelection, + pub(crate) list_selection: gtk4::MultiSelection, } /// The widgets in the browser's details pane that a selection repaints. @@ -158,24 +158,22 @@ pub(crate) fn wire_details(ui: &Rc) { ui.details.list_selection.clone(), ] { let ui_sel = ui.clone(); - selection.connect_selection_changed(move |sel, _, _| { - match entry_at(sel.model().as_ref(), sel.selected()) { - Some(entry) => show_details(&ui_sel, &entry), - None => hide_details(&ui_sel), + selection.connect_selection_changed(move |_, _, _| { + // The details pane describes *one* entry. A batch is the bulk bar's + // business, so the pane steps aside rather than picking a member of + // the selection to speak for the rest. + let entries = selected_entries(&ui_sel); + match entries.as_slice() { + [entry] => show_details(&ui_sel, entry), + _ => hide_details(&ui_sel), } + sync_bulk_bar(&ui_sel); }); } let ui_close = ui.clone(); ui.details.details.close_button.connect_clicked(move |_| { - ui_close - .details - .grid_selection - .set_selected(gtk4::INVALID_LIST_POSITION); - ui_close - .details - .list_selection - .set_selected(gtk4::INVALID_LIST_POSITION); + clear_selection(&ui_close); hide_details(&ui_close); }); @@ -289,8 +287,9 @@ pub(crate) fn hide_details(ui: &Rc) { *ui.details.details_entry.borrow_mut() = None; } -/// The entry highlighted in whichever browser view is on screen, if any. Backs the -/// F2 / Delete shortcuts. +/// The single entry the details pane is showing, if any. Backs the actions that +/// only make sense on one entry (rename, versions); the batch-capable ones ask +/// [`selected_entries`] instead. pub(crate) fn selected_entry(ui: &Rc) -> Option { ui.details.details_entry.borrow().clone() } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0ff4b5c..6d77b51 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,9 +9,12 @@ release ships. Migrations are forward-only — a database written by a newer cli refuse-to-open, not a downgrade, so rolling back a release means restoring the cache from scratch (user data in `staging/` and `recovery/` is never touched by this). -## [Unreleased] +## [1.9.0] — 2026-08-18 -Google Photos import. No schema change. Schema: **28**. +Google Photos import, and a pass over the desktop app's interaction gaps. No schema change. +Schema: **28**. Builds against the published `proton-sdk` / `proton-drive-rs` **0.6.1** — the album +writes this release needs are in the registry crates, so the local `[patch.crates-io]` shim the +feature was developed against is gone and no local path dependency remains. ### Added - **Import a Google Photos Takeout export into Proton Photos** — `pdfs import-google-photos @@ -38,14 +41,35 @@ Google Photos import. No schema change. Schema: **28**. outlives the page, it is polled wherever the user navigates and its completion is reported as a desktop notification. -- SDK (`proton-sdk-rs`): album **writes** — `ProtonPhotosClient::create_album` and +- SDK (`proton-sdk-rs` **0.6.1**): album **writes** — `ProtonPhotosClient::create_album` and `add_photos_to_album`, ported from the TypeScript SDK (upstream C# has no album write API), plus - batch duplicate detection (`find_duplicates_many`, `name_collisions`). Adding a photo from an + batch duplicate detection (`find_duplicates_many`, `name_collisions`). The workspace deps moved + `"0.6.0"` → `"0.6.1"` and `Cargo.lock` re-resolved to the registry crates. Adding a photo from an album *shared with us* (a different volume) is still unported and fails that photo's outcome. +- **Multi-select in Files.** Both views select a batch (click-drag, Ctrl-click, Shift-click); a bar + slides in reporting the count and offering Move to Trash and offline on/off for the whole + selection. Delete acts on everything selected, Esc clears it, and right-clicking inside a + selection offers the batch actions rather than the one row under the pointer. Actions that need a + single subject — rename, move, share, revisions — stay single-entry, and the details pane steps + aside for a batch instead of speaking for it. +- **Undo on trash.** "Moved *X* to Trash" now carries an Undo that restores the batch, single files + included. +- **The empty states are a place to act**: an empty folder offers Upload files / New folder, and an + empty photo timeline offers Upload photos / Import from Google Photos, instead of describing what + the user could go and do elsewhere. +- **Ctrl+F works from any page**, navigating to Files and focusing the search box, rather than being + silently inert everywhere but Files. +- Staged Takeout archives survive closing the window; archives that have since been moved or deleted + are dropped from the restored set. ### Fixed - Settings showed the local cache as "X of 0 B used" when no cache budget was set. An unset budget means *unlimited*, so it now reads "X cached — no limit set" rather than implying a zero cap. +- The cache-budget editor fired a `SetCacheBudget` round-trip — and a toast — on every step, so a + run of `+` clicks applied a series of caps the user never asked for and stacked a column of + toasts. It now waits for the value to settle. +- A long pin list pushed everything below it off the end of Settings. It shows the first six with a + "Show all N" row for the rest. ## [1.8.2] — 2026-08-17 diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 114a9c0..95d6c0f 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -9,7 +9,7 @@ # Build + install: cd packaging && makepkg -fi pkgname=proton-drive-linux -pkgver=1.8.2 +pkgver=1.9.0 pkgrel=1 pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" arch=('x86_64')