diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 9194ecf..294c0a7 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -355,12 +355,7 @@ fn build_window(app: &adw::Application) { sidebar: sidebar_list.clone(), nav: split.clone(), file_thumbs: FileThumbnailState::new(), - login: LoginState { - email: login_widgets.0, - password: login_widgets.1, - login_button: login_widgets.2, - login_status: login_widgets.3, - }, + login: login_widgets, status: StatusState { status_inflight: Cell::new(false), transfers_group: locations_widgets.transfers_group.clone(), @@ -446,6 +441,9 @@ fn build_window(app: &adw::Application) { retry: trash_widgets.retry.clone(), empty: trash_widgets.empty.clone(), subtitle: trash_widgets.subtitle.clone(), + selection: trash_widgets.selection.clone(), + selection_bar: trash_widgets.selection_bar.clone(), + selection_label: trash_widgets.selection_label.clone(), }, gallery: GalleryState { model: gallery_widgets.model.clone(), @@ -628,7 +626,7 @@ fn build_window(app: &adw::Application) { &gallery_widgets.empty_import, ); wire_albums(&ui); - wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty); + wire_trash(&ui, &trash_widgets); wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); wire_shared_by_me(&ui, &shared_by_me_widgets.retry); wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore); diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 4255823..6aa1a0d 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -1243,6 +1243,33 @@ pub(crate) fn menu_item(label: &str, icon: &str) -> gtk4::Button { button } +/// An action run by a [`more_menu_button`] item. +pub(crate) type MenuAction = Box; + +/// A flat ⋮ button whose popover lists `items` as (label, icon, action). The +/// popover closes before the action runs, so a dialog it opens gets the focus. +pub(crate) fn more_menu_button(items: Vec<(&str, &str, MenuAction)>) -> gtk4::MenuButton { + let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + let popover = gtk4::Popover::builder().child(&menu).build(); + for (label, icon, run) in items { + let button = menu_item(label, icon); + let pop = popover.clone(); + button.connect_clicked(move |_| { + pop.popdown(); + run(); + }); + menu.append(&button); + } + let button = gtk4::MenuButton::builder() + .icon_name("view-more-symbolic") + .tooltip_text("More") + .valign(gtk4::Align::Center) + .popover(&popover) + .build(); + button.add_css_class("flat"); + button +} + /// Fetch the [`DirEntry`] backing the model item at `pos`, if any. pub(crate) fn entry_at(model: Option<&impl IsA>, pos: u32) -> Option { let obj = model?.item(pos).and_downcast::()?; diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index 95f0eb3..6720d0e 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -60,7 +60,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { // second copy of that list here would be two places to change the same mode. let sync_group = adw::PreferencesGroup::builder() .title("This computer") - .description("The device this machine backs up to.") + .description("The computer this machine backs up as.") .build(); // Rename control for *this* machine's device, in the section header. The // current device is filtered out of "Other computers", so this is the only @@ -75,7 +75,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { sync_group.set_header_suffix(Some(&rename_this)); let group = adw::PreferencesGroup::builder() .title("Other computers") - .description("Other devices backing up to this account.") + .description("Other computers backing up to this account.") .build(); let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); @@ -170,7 +170,7 @@ pub(crate) fn load_devices(ui: &Rc) { ui, "computer-symbolic", "Loading…", - "Reading your devices.", + "Reading your computers.", false, ); ui.busy_begin(); @@ -258,6 +258,9 @@ pub(crate) fn repaint_devices(ui: &Rc, devices: &[DeviceInfo]) { match devices.iter().find(|d| d.this_device) { Some(me) => { *ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone())); + ui.devices + .sync_group + .set_description(Some(&format!("Backing up as “{}”.", me.name))); ui.devices.rename_this.set_sensitive(true); ui.devices .rename_this @@ -286,43 +289,30 @@ pub(crate) fn repaint_devices(ui: &Rc, devices: &[DeviceInfo]) { .subtitle(device_subtitle(dev)) .build(); row.add_prefix(>k4::Image::from_icon_name("computer-symbolic")); - let remove = gtk4::Button::builder() - .icon_name("user-trash-symbolic") - .tooltip_text("Remove this computer and everything it backed up") - .valign(gtk4::Align::Center) - .build(); - remove.add_css_class("flat"); - let rename = gtk4::Button::builder() - .icon_name("document-edit-symbolic") - .tooltip_text("Rename computer") - .valign(gtk4::Align::Center) - .build(); - rename.add_css_class("flat"); // Adoption is how a reinstalled or renamed machine re-attaches to the // device it used to be, instead of registering a duplicate. It only - // makes sense on *another* device's row, which is the only place this + // makes sense on *another* computer's row, which is the only place this // loop paints. - let adopt = gtk4::Button::builder() - .icon_name("insert-object-symbolic") - .tooltip_text("Use this computer's identity for this machine") - .valign(gtk4::Align::Center) - .build(); - adopt.add_css_class("flat"); - let ui_ad = ui.clone(); - let uid_ad = dev.uid.clone(); - let name_ad = dev.name.clone(); - adopt.connect_clicked(move |_| prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)); - row.add_suffix(&adopt); - let ui_ren = ui.clone(); - let uid_ren = dev.uid.clone(); - let name_ren = dev.name.clone(); - rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren)); - let ui_rm = ui.clone(); - let uid_rm = dev.uid.clone(); - let name_rm = dev.name.clone(); - remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm)); - row.add_suffix(&rename); - row.add_suffix(&remove); + let (ui_ren, uid_ren, name_ren) = (ui.clone(), dev.uid.clone(), dev.name.clone()); + let (ui_ad, uid_ad, name_ad) = (ui.clone(), dev.uid.clone(), dev.name.clone()); + let (ui_rm, uid_rm, name_rm) = (ui.clone(), dev.uid.clone(), dev.name.clone()); + row.add_suffix(&more_menu_button(vec![ + ( + "Rename…", + "document-edit-symbolic", + Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)), + ), + ( + "Use This Computer's Identity…", + "insert-object-symbolic", + Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)), + ), + ( + "Remove Computer…", + "user-trash-symbolic", + Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)), + ), + ])); ui.devices.group.add(&row); rows.push(row.upcast()); } @@ -357,7 +347,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc, folders: &[SyncFolderInfo]) { .build(); row.add_prefix(>k4::Image::from_icon_name("computer-symbolic")); let manage = gtk4::Button::builder() - .label("Locations") + .label("Open Sync") .tooltip_text("Manage this computer's folders and mountpoint") .valign(gtk4::Align::Center) .build(); @@ -373,7 +363,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc, folders: &[SyncFolderInfo]) { /// attention — the one fact worth surfacing away from the folder list itself. pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String { if folders.is_empty() { - return "No folders backed up yet — add one under Locations.".to_string(); + return "No folders backed up yet — add one on the Sync page.".to_string(); } let count = match folders.len() { 1 => "1 folder backed up".to_string(), @@ -384,7 +374,7 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String { .filter(|f| f.state == "error" || f.state == "conflict") .count(); match attention { - 0 => format!("{count} · manage them under Locations"), + 0 => format!("{count} · manage them on the Sync page"), 1 => format!("{count} · 1 needs attention"), n => format!("{count} · {n} need attention"), } @@ -686,7 +676,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc, id: i64, path: &str, ondema pub(crate) fn prompt_rename_device(ui: &Rc, uid: &str, current: &str) { let win = ui_window(ui); let dialog = adw::AlertDialog::builder() - .heading("Rename device") + .heading("Rename Computer") .body(format!("Rename “{current}”.")) .build(); let group = adw::PreferencesGroup::new(); @@ -710,7 +700,7 @@ pub(crate) fn prompt_rename_device(ui: &Rc, uid: &str, current: &str) { } let name = row.text().trim().to_string(); if name.is_empty() { - toast_error(&ui, "Couldn't rename device", "A name is required."); + toast_error(&ui, "Couldn't rename the computer", "A name is required."); return; } run_devices_mutation( @@ -719,29 +709,42 @@ pub(crate) fn prompt_rename_device(ui: &Rc, uid: &str, current: &str) { uid: uid.clone(), name, }, - "Device renamed", - "Couldn't rename the device", + "Computer renamed", + "Couldn't rename the computer", ); }); dialog.present(win.as_ref()); } -/// Confirm, then remove (deregister) a device. +/// Confirm, then remove (deregister) a computer. Removing deletes everything the +/// computer backed up, so the confirm button stays off until the name is typed. pub(crate) fn prompt_remove_device(ui: &Rc, uid: &str, name: &str) { let win = ui_window(ui); + let confirm = adw::EntryRow::builder() + .title(format!("Type “{name}” to confirm")) + .build(); + let group = adw::PreferencesGroup::new(); + group.add(&confirm); let dialog = adw::AlertDialog::builder() - .heading("Remove computer") + .heading("Remove Computer") .body(format!( "Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \ deleted along with it. The files on that computer itself are not touched — but \ this cannot be undone from here." )) + .extra_child(&group) .build(); dialog.add_response("cancel", "Cancel"); dialog.add_response("remove", "Remove"); dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); + dialog.set_response_enabled("remove", false); dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); + let dialog_typed = dialog.clone(); + let expected = name.to_string(); + confirm.connect_changed(move |row| { + dialog_typed.set_response_enabled("remove", row.text().trim() == expected); + }); let ui = ui.clone(); let uid = uid.to_string(); dialog.connect_response(None, move |_, resp| { @@ -749,8 +752,8 @@ pub(crate) fn prompt_remove_device(ui: &Rc, uid: &str, name: &str) { run_devices_mutation( &ui, Request::DeleteDevice { uid: uid.clone() }, - "Device removed", - "Couldn't remove the device", + "Computer removed", + "Couldn't remove the computer", ); } }); @@ -843,7 +846,7 @@ mod tests { fn a_machine_with_no_folders_is_told_where_to_add_one() { assert_eq!( this_computer_subtitle(&[]), - "No folders backed up yet — add one under Locations." + "No folders backed up yet — add one on the Sync page." ); } @@ -853,7 +856,7 @@ mod tests { // the Computers page can surface that something is wrong. assert_eq!( this_computer_subtitle(&[folder("idle"), folder("idle")]), - "2 folders backed up · manage them under Locations" + "2 folders backed up · manage them on the Sync page" ); assert_eq!( this_computer_subtitle(&[folder("idle"), folder("error")]), diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs index 516909c..7180c0a 100644 --- a/crates/pdfs-gui/src/app/pages/login.rs +++ b/crates/pdfs-gui/src/app/pages/login.rs @@ -6,22 +6,20 @@ pub(crate) struct LoginState { pub(crate) email: adw::EntryRow, pub(crate) password: adw::PasswordEntryRow, pub(crate) login_button: gtk4::Button, + /// Spins inside the Sign in button while a sign-in is running. + pub(crate) login_spinner: gtk4::Spinner, pub(crate) login_status: gtk4::Label, } -/// The login page: an email row, password row, a primary "Sign in" button and a -/// status label, centred in a clamp. The 2FA code is prompted lazily in a -/// dialog (see [`prompt_2fa`]) only when the account actually requires it. -#[allow(clippy::type_complexity)] -pub(crate) fn build_login_page() -> ( - gtk4::Widget, - ( - adw::EntryRow, - adw::PasswordEntryRow, - gtk4::Button, - gtk4::Label, - ), -) { +/// Where the login page's account links go. +const SIGNUP_URL: &str = "https://account.proton.me/drive/signup"; +const RESET_PASSWORD_URL: &str = "https://account.proton.me/reset-password"; + +/// The login page: an email row, password row, a primary "Sign in" button, a +/// status label and links to create an account or reset the password, centred +/// in a clamp. The 2FA code is prompted lazily in a dialog (see [`prompt_2fa`]) +/// only when the account actually requires it. +pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) { let group = adw::PreferencesGroup::builder() .title("Sign in to Proton") .description("Use your Proton account to connect Drive.") @@ -32,8 +30,12 @@ pub(crate) fn build_login_page() -> ( group.add(&email); group.add(&password); + let login_spinner = gtk4::Spinner::builder().visible(false).build(); + let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + button_content.append(&login_spinner); + button_content.append(>k4::Label::new(Some("Sign in"))); let login_button = gtk4::Button::builder() - .label("Sign in") + .child(&button_content) .halign(gtk4::Align::Center) .build(); login_button.add_css_class("suggested-action"); @@ -64,6 +66,14 @@ pub(crate) fn build_login_page() -> ( inner.append(&group); inner.append(&login_button); inner.append(&login_status); + let links = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + links.set_halign(gtk4::Align::Center); + links.append(>k4::LinkButton::with_label(SIGNUP_URL, "Create account")); + links.append(>k4::LinkButton::with_label( + RESET_PASSWORD_URL, + "Forgot password?", + )); + inner.append(&links); let clamp = adw::Clamp::builder() .maximum_size(420) @@ -73,7 +83,13 @@ pub(crate) fn build_login_page() -> ( ( scroll.upcast(), - (email, password, login_button, login_status), + LoginState { + email, + password, + login_button, + login_spinner, + login_status, + }, ) } @@ -103,7 +119,7 @@ pub(crate) fn wire_login(ui: &Rc) { return; } - ui.login.login_button.set_sensitive(false); + set_signing_in(&ui, true); ui.login.login_status.set_text("Signing in…"); let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password); @@ -140,7 +156,7 @@ pub(crate) fn wire_login(ui: &Rc) { .recv() .await .unwrap_or_else(|_| Err("login cancelled".into())); - ui.login.login_button.set_sensitive(true); + set_signing_in(&ui, false); match result { Ok(()) => { ui.login.login_status.set_text(""); @@ -151,15 +167,53 @@ pub(crate) fn wire_login(ui: &Rc) { service::enable_start(); refresh(&ui); } - Err(e) => ui - .login - .login_status - .set_text(&format!("Sign-in failed: {e}")), + Err(e) => ui.login.login_status.set_text(&e), } }); }); } +/// Lock the form and spin the button while a sign-in runs. +fn set_signing_in(ui: &Rc, running: bool) { + ui.login.login_button.set_sensitive(!running); + ui.login.email.set_sensitive(!running); + ui.login.password.set_sensitive(!running); + ui.login.login_spinner.set_visible(running); + ui.login.login_spinner.set_spinning(running); +} + +/// What to tell the person when sign-in fails. Proton's own API messages are +/// written for people ("Incorrect login credentials…"), so they pass through; +/// everything else is mapped from its kind rather than shown as a debug string. +pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String { + use pdfs_core::proton_sdk::ProtonError; + match error { + pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => { + "Too many sign-in attempts. Wait a few minutes and try again.".to_string() + } + pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => { + api.message.clone() + } + pdfs_core::Error::Proton(ProtonError::Transport(_)) => { + "Couldn't reach Proton. Check your internet connection and try again.".to_string() + } + pdfs_core::Error::Keyring(_) => { + "Signed in, but the session couldn't be saved to the system keyring. Make sure a \ + keyring (GNOME Keyring or KWallet) is running and unlocked." + .to_string() + } + pdfs_core::Error::Other(message) if message.contains("two-factor") => { + "Sign-in cancelled: no two-factor code was entered.".to_string() + } + pdfs_core::Error::Other(message) if message.contains("verification") => { + "Sign-in cancelled: the verification wasn't completed. Sign in again to get a new \ + one." + .to_string() + } + other => format!("Sign-in failed: {other}"), + } +} + /// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio /// runtime. Returns two channels: the first yields the final login result once; /// the second fires *only if* the account needs a 2FA code, carrying a @@ -223,7 +277,7 @@ pub(crate) fn spawn_login( }, ) .await - .map_err(|e| e.to_string()) + .map_err(|e| login_error_message(&e)) }); let _ = tx.send_blocking(result); }); @@ -236,7 +290,7 @@ pub(crate) fn spawn_login( pub(crate) fn prompt_2fa(ui: &Rc, code_tx: std::sync::mpsc::Sender) { let dialog = adw::AlertDialog::builder() .heading("Two-factor authentication") - .body("Enter the code from your authenticator app.") + .body("Enter the code from your authenticator app, or one of your recovery codes.") .build(); let group = adw::PreferencesGroup::new(); @@ -290,3 +344,22 @@ pub(crate) fn sign_out(ui: &Rc) { }, ); } + +#[cfg(test)] +mod tests { + use super::login_error_message; + + #[test] + fn a_cancelled_prompt_says_which_step_was_skipped() { + let totp = pdfs_core::Error::Other("two-factor entry cancelled".into()); + assert!(login_error_message(&totp).contains("two-factor code")); + let hv = pdfs_core::Error::Other("verification cancelled".into()); + assert!(login_error_message(&hv).contains("verification wasn't completed")); + } + + #[test] + fn an_unmapped_error_keeps_its_text() { + let error = pdfs_core::Error::Other("something odd".into()); + assert_eq!(login_error_message(&error), "Sign-in failed: something odd"); + } +} diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index b2cec6b..394ac39 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -22,8 +22,8 @@ pub(crate) struct SharedByMeWidgets { } /// The Shared page: one section listing the items I have shared with others — -/// each row summarizing who has access and carrying its public link (copy/open) -/// and a Manage shortcut into the per-node Share dialog. +/// each row summarizing who has access, opening the item when activated, with +/// Copy link and a menu for the link and the per-node Share dialog. pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) { let refresh = refresh_button(); @@ -199,8 +199,17 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { .build(); row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true)); - // Copy / open the public link, when there is one with a recovered URL. - if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) { + // A node the daemon can place in my tree opens like it would in My Files. + if !item.path.is_empty() { + row.set_activatable(true); + let ui_open = ui.clone(); + let entry_open = entry.clone(); + row.connect_activated(move |_| open_shared_by_me(&ui_open, &entry_open)); + } + + let url = item.link.as_ref().and_then(|l| l.url.clone()); + // The one action people come here for most gets its own button. + if let Some(url) = url.clone() { let copy = gtk4::Button::builder() .icon_name("edit-copy-symbolic") .tooltip_text("Copy link") @@ -208,34 +217,13 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { .build(); copy.add_css_class("flat"); let ui_copy = ui.clone(); - let url_copy = url.clone(); copy.connect_clicked(move |btn| { - btn.clipboard().set_text(&url_copy); + btn.clipboard().set_text(&url); toast(&ui_copy, "Link copied"); }); - let open = gtk4::Button::builder() - .icon_name("external-link-symbolic") - .tooltip_text("Open link") - .valign(gtk4::Align::Center) - .build(); - open.add_css_class("flat"); - let url_open = url.clone(); - open.connect_clicked(move |_| open_uri(&url_open)); row.add_suffix(©); - row.add_suffix(&open); } - - // Manage opens the per-node Share dialog. It used to be offered only for - // an item whose path the daemon could resolve; the dialog now addresses a - // pathless node by uid, so every shared item can be managed from here. - let manage = gtk4::Button::builder() - .label("Manage") - .valign(gtk4::Align::Center) - .build(); - manage.add_css_class("flat"); - let ui_manage = ui.clone(); - manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry)); - row.add_suffix(&manage); + row.add_suffix(&shared_by_me_menu(ui, &entry, url)); ui.shared_by_me.group.add(&row); rows.push(row.upcast()); @@ -243,6 +231,65 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { *ui.shared_by_me.rows.borrow_mut() = rows; } +/// The ⋮ menu on a shared item: open it, the link, and the Share dialog. +/// +/// Manage access opens the per-node Share dialog, which addresses a pathless +/// node by uid, so every shared item can be managed from here. +fn shared_by_me_menu(ui: &Rc, entry: &DirEntry, url: Option) -> gtk4::MenuButton { + let mut items: Vec<(&str, &str, MenuAction)> = Vec::new(); + if !entry.path.is_empty() { + let (ui_c, entry_c) = (ui.clone(), entry.clone()); + items.push(( + "Open", + "document-open-symbolic", + Box::new(move || open_shared_by_me(&ui_c, &entry_c)), + )); + let (ui_c, entry_c) = (ui.clone(), entry.clone()); + items.push(( + "Show in My Files", + "folder-symbolic", + Box::new(move || show_in_my_files(&ui_c, &entry_c)), + )); + } + if let Some(url) = url { + items.push(( + "Open Link", + "external-link-symbolic", + Box::new(move || open_uri(&url)), + )); + } + let (ui_c, entry_c) = (ui.clone(), entry.clone()); + items.push(( + "Manage Access…", + "system-users-symbolic", + Box::new(move || open_share_dialog(&ui_c, &entry_c)), + )); + more_menu_button(items) +} + +/// Open a shared item: a folder in My Files, a file the way My Files would. +fn open_shared_by_me(ui: &Rc, entry: &DirEntry) { + if entry.is_dir { + ui.stack.set_visible_child_name("browser"); + ui.browser.search.set_text(""); + browse_to(ui, entry.path.clone()); + } else { + activate_entry(ui, entry); + } +} + +/// Go to the folder holding a shared item in My Files. +fn show_in_my_files(ui: &Rc, entry: &DirEntry) { + let parent = entry + .path + .rsplit_once('/') + .map(|(parent, _)| parent.to_string()) + .unwrap_or_default(); + ui.stack.set_visible_child_name("browser"); + ui.browser.search.set_text(""); + browse_to(ui, parent); +} + /// A one-line summary of who can reach a shared item, for its row subtitle. pub(crate) fn shared_item_summary(item: &SharedItem) -> String { let mut parts = Vec::new(); diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs index 6bfdfca..bb1b564 100644 --- a/crates/pdfs-gui/src/app/pages/trash.rs +++ b/crates/pdfs-gui/src/app/pages/trash.rs @@ -11,6 +11,10 @@ pub(crate) struct TrashState { pub(crate) empty: gtk4::Button, /// "12 items" under the page title. pub(crate) subtitle: adw::WindowTitle, + pub(crate) selection: gtk4::MultiSelection, + /// The bottom bar acting on the selection; revealed while anything is selected. + pub(crate) selection_bar: gtk4::Revealer, + pub(crate) selection_label: gtk4::Label, } /// The widgets of the Trash page that a load repaints. @@ -23,10 +27,17 @@ pub(crate) struct TrashWidgets { pub(crate) empty: gtk4::Button, pub(crate) refresh: gtk4::Button, pub(crate) subtitle: adw::WindowTitle, + pub(crate) selection: gtk4::MultiSelection, + pub(crate) selection_bar: gtk4::Revealer, + pub(crate) selection_label: gtk4::Label, + pub(crate) restore_selected: gtk4::Button, + pub(crate) delete_selected: gtk4::Button, } /// The Trash page: a flat list of everything Drive is holding in the trash, each -/// row offering Restore and Delete Forever, with Empty Trash in the header. +/// row offering Restore and Delete Forever, with Empty Trash in the header. A +/// selection (click, Shift/Ctrl+click, Ctrl+A) gets the same two actions in a +/// bottom bar. /// /// A trashed node has no path inside the mount — the daemon forgets it when it is /// trashed — so unlike the Files page this one addresses entries by uid and always @@ -36,7 +47,7 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { let model = gio::ListStore::new::(); let empty = gtk4::Button::builder() - .label("Empty…") + .label("Empty Trash…") .tooltip_text("Permanently delete everything in the Trash") .sensitive(false) .build(); @@ -56,11 +67,10 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { .build(); status.add_css_class("compact"); - // No selection model: every action lives on the row it acts on, so a - // mis-aimed Delete Forever isn't one click away from a stale selection. - let list = gtk4::ListView::builder() - .model(>k4::NoSelection::new(Some(model.clone()))) - .build(); + // Bulk Delete Forever always confirms with the count, so a stale selection + // can't delete anything silently. + let selection = gtk4::MultiSelection::new(Some(model.clone())); + let list = gtk4::ListView::builder().model(&selection).build(); let scroll = gtk4::ScrolledWindow::builder() .vexpand(true) .child(&list) @@ -78,6 +88,21 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { inner.set_margin_start(18); inner.set_margin_end(18); inner.append(&content); + + let selection_label = gtk4::Label::new(None); + let restore_selected = gtk4::Button::with_label("Restore"); + let delete_selected = gtk4::Button::with_label("Delete Permanently…"); + delete_selected.add_css_class("destructive-action"); + let bar = gtk4::ActionBar::new(); + bar.set_center_widget(Some(&selection_label)); + bar.pack_start(&restore_selected); + bar.pack_end(&delete_selected); + let selection_bar = gtk4::Revealer::builder() + .transition_type(gtk4::RevealerTransitionType::SlideUp) + .child(&bar) + .build(); + inner.append(&selection_bar); + let (frame, header, subtitle) = page_frame("Trash", &inner); header.pack_start(&empty); header.pack_end(&refresh); @@ -93,6 +118,11 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { empty, refresh, subtitle, + selection, + selection_bar, + selection_label, + restore_selected, + delete_selected, }, ) } @@ -100,7 +130,9 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { /// Install the row factory and the Empty Trash button. The row's two buttons read /// the entry off the [`gtk4::ListItem`] they were clicked on rather than a /// captured copy, so a recycled row always acts on the item it currently shows. -pub(crate) fn wire_trash(ui: &Rc, list: >k4::ListView, empty: >k4::Button) { +pub(crate) fn wire_trash(ui: &Rc, widgets: &TrashWidgets) { + let list = &widgets.list; + let empty = &widgets.empty; let factory = gtk4::SignalListItemFactory::new(); let ui_setup = ui.clone(); factory.connect_setup(move |_, item| { @@ -144,7 +176,7 @@ pub(crate) fn wire_trash(ui: &Rc, list: >k4::ListView, empty: >k4::Butto let item_purge = item.clone(); purge.connect_clicked(move |_| { if let Some(entry) = bound_entry(&item_purge) { - prompt_delete_forever(&ui_purge, &entry); + prompt_delete_forever(&ui_purge, std::slice::from_ref(&entry)); } }); @@ -191,6 +223,46 @@ pub(crate) fn wire_trash(ui: &Rc, list: >k4::ListView, empty: >k4::Butto let ui_empty = ui.clone(); empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty)); + + let ui_sel = ui.clone(); + widgets + .selection + .connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel)); + let ui_restore = ui.clone(); + widgets.restore_selected.connect_clicked(move |_| { + let entries = selected_trash(&ui_restore); + if !entries.is_empty() { + restore_entries(&ui_restore, &entries); + } + }); + let ui_delete = ui.clone(); + widgets.delete_selected.connect_clicked(move |_| { + let entries = selected_trash(&ui_delete); + if !entries.is_empty() { + prompt_delete_forever(&ui_delete, &entries); + } + }); +} + +/// The trashed entries currently selected, in list order. +fn selected_trash(ui: &Rc) -> Vec { + let selected = ui.trash.selection.selection(); + (0..selected.size()) + .filter_map(|i| ui.trash.model.item(selected.nth(i as u32))) + .filter_map(|obj| obj.downcast::().ok()) + .map(|obj| obj.borrow::().clone()) + .collect() +} + +/// Reveal the selection bar while anything is selected and count what is. +fn sync_trash_selection(ui: &Rc) { + let count = ui.trash.selection.selection().size() as usize; + ui.trash.selection_bar.set_reveal_child(count > 0); + if count > 0 { + ui.trash + .selection_label + .set_label(&format!("{} selected", count_noun(count, "item", "items"))); + } } /// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound @@ -208,6 +280,7 @@ pub(crate) fn load_trash(ui: &Rc) { // that may already be gone. ui.trash.model.remove_all(); ui.trash.empty.set_sensitive(false); + sync_trash_selection(ui); trash_status( ui, "user-trash-symbolic", @@ -285,6 +358,7 @@ pub(crate) fn trash_status(ui: &Rc, icon: &str, title: &str, description: &s /// user looks for what they just deleted. pub(crate) fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { ui.trash.model.remove_all(); + sync_trash_selection(ui); ui.trash.empty.set_sensitive(!entries.is_empty()); if entries.is_empty() { trash_status( @@ -297,10 +371,9 @@ pub(crate) fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { return; } ui.trash.content.set_visible_child_name("list"); - ui.trash.subtitle.set_subtitle(&match entries.len() { - 1 => "1 item".to_string(), - n => format!("{n} items"), - }); + ui.trash + .subtitle + .set_subtitle(&count_noun(entries.len(), "item", "items")); let mut sorted = entries.to_vec(); sorted.sort_by_key(|e| std::cmp::Reverse(e.modified)); @@ -311,27 +384,37 @@ pub(crate) fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { /// Restore one trashed entry to the folder it was trashed from. pub(crate) fn restore_entry(ui: &Rc, entry: &DirEntry) { - let name = entry.name.clone(); + restore_entries(ui, std::slice::from_ref(entry)); +} + +/// Restore trashed entries to the folders they were trashed from. +pub(crate) fn restore_entries(ui: &Rc, entries: &[DirEntry]) { run_mutation( ui, Request::Restore { - uids: vec![entry.uid.clone()], + uids: entries.iter().map(|e| e.uid.clone()).collect(), }, - format!("Restored “{name}”"), + format!("Restored {}", entries_label(entries)), "Couldn't restore", ); } -/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks. -pub(crate) fn prompt_delete_forever(ui: &Rc, entry: &DirEntry) { +/// “name” for one entry, “3 items” for several. +fn entries_label(entries: &[DirEntry]) -> String { + match entries { + [one] => format!("“{}”", one.name), + _ => count_noun(entries.len(), "item", "items"), + } +} + +/// Confirm, then permanently delete trashed entries. Irreversible, so it asks. +pub(crate) fn prompt_delete_forever(ui: &Rc, entries: &[DirEntry]) { let win = ui_window(ui); - let uid = entry.uid.clone(); - let name = entry.name.clone(); + let uids: Vec = entries.iter().map(|e| e.uid.clone()).collect(); + let name = entries_label(entries); let dialog = adw::AlertDialog::builder() - .heading("Delete permanently") - .body(format!( - "Permanently delete “{name}”? This cannot be undone." - )) + .heading("Delete Permanently") + .body(format!("Permanently delete {name}? This cannot be undone.")) .build(); dialog.add_response("cancel", "Cancel"); dialog.add_response("delete", "Delete Permanently"); @@ -344,10 +427,8 @@ pub(crate) fn prompt_delete_forever(ui: &Rc, entry: &DirEntry) { if resp == "delete" { run_mutation( &ui, - Request::DeleteForever { - uids: vec![uid.clone()], - }, - format!("Deleted “{name}” permanently"), + Request::DeleteForever { uids: uids.clone() }, + format!("Deleted {name} permanently"), "Couldn't delete", ); } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ac28f5c..e685db9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -46,6 +46,17 @@ Schema: **30** (unchanged). says what it filtered ("No videos in June 2024") instead of the first-run hint. A banner shows while a Takeout import runs and links to it. Pages load 200 photos at a time and keep loading until the window is full. +- **Trash.** Select several items (click, Shift/Ctrl+click, Ctrl+A) to restore or permanently + delete them together; the permanent delete still asks first, with the count. +- **Shared by Me.** A row opens the item. Copy link stays one click away; Open, Show in My Files, + Open Link and Manage Access… are in the row's menu. +- **Computers.** Rename, Use This Computer's Identity and Remove are in one menu per computer. + Removing a computer deletes its backups, so the dialog now asks for its name to be typed. The + page says which computer this machine backs up as, and says "computer" throughout. +- **Sign in.** Failures read as sentences — wrong password, too many attempts, no connection, no + keyring, a skipped two-factor or verification step — instead of the raw error. The button spins + while signing in, and the page links to account creation and password reset. The two-factor + dialog mentions recovery codes. ## [1.11.1] — 2026-09-19