feat(gui): trash multi-select, shared and computer row menus, sign-in errors (phase 6)

This commit is contained in:
nvrl committed 2026-09-23 02:31:13 +02:00
1 parent fdda35918f
commit a86c3e586d
7 files changed
+375 -135

No files matched your search

+5 -7
View File
@@ -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);
+27
View File
@@ -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<dyn Fn()>;
/// 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<gio::ListModel>>, pos: u32) -> Option<DirEntry> {
let obj = model?.item(pos).and_downcast::<BoxedAnyObject>()?;
+53 -50
View File
@@ -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>) {
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<Ui>, 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<Ui>, devices: &[DeviceInfo]) {
.subtitle(device_subtitle(dev))
.build();
row.add_prefix(&gtk4::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<Ui>, folders: &[SyncFolderInfo]) {
.build();
row.add_prefix(&gtk4::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<Ui>, 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<Ui>, id: i64, path: &str, ondema
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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")]),
+96 -23
View File
@@ -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(&gtk4::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(&gtk4::LinkButton::with_label(SIGNUP_URL, "Create account"));
links.append(&gtk4::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<Ui>) {
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<Ui>) {
.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<Ui>) {
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<Ui>, 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<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
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<Ui>) {
},
);
}
#[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");
}
}
+74 -27
View File
@@ -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<Ui>, 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<Ui>, 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(&copy);
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<Ui>, 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<Ui>, entry: &DirEntry, url: Option<String>) -> 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<Ui>, 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<Ui>, 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();
+109 -28
View File
@@ -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::<BoxedAnyObject>();
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(&gtk4::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<Ui>, list: &gtk4::ListView, empty: &gtk4::Button) {
pub(crate) fn wire_trash(ui: &Rc<Ui>, 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<Ui>, list: &gtk4::ListView, empty: &gtk4::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<Ui>, list: &gtk4::ListView, empty: &gtk4::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<Ui>) -> Vec<DirEntry> {
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::<BoxedAnyObject>().ok())
.map(|obj| obj.borrow::<DirEntry>().clone())
.collect()
}
/// Reveal the selection bar while anything is selected and count what is.
fn sync_trash_selection(ui: &Rc<Ui>) {
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<Ui>) {
// 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<Ui>, icon: &str, title: &str, description: &s
/// user looks for what they just deleted.
pub(crate) fn repaint_trash(ui: &Rc<Ui>, 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<Ui>, 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<Ui>, entries: &[DirEntry]) {
/// Restore one trashed entry to the folder it was trashed from.
pub(crate) fn restore_entry(ui: &Rc<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, entries: &[DirEntry]) {
let win = ui_window(ui);
let uid = entry.uid.clone();
let name = entry.name.clone();
let uids: Vec<String> = 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<Ui>, 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",
);
}
+11
View File
@@ -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