mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(gui): trash multi-select, shared and computer row menus, sign-in errors (phase 6)
This commit is contained in:
1 parent
fdda35918f
commit
a86c3e586d
7 files changed
+375
-135
No files matched your search
@@ -355,12 +355,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
sidebar: sidebar_list.clone(),
|
sidebar: sidebar_list.clone(),
|
||||||
nav: split.clone(),
|
nav: split.clone(),
|
||||||
file_thumbs: FileThumbnailState::new(),
|
file_thumbs: FileThumbnailState::new(),
|
||||||
login: LoginState {
|
login: login_widgets,
|
||||||
email: login_widgets.0,
|
|
||||||
password: login_widgets.1,
|
|
||||||
login_button: login_widgets.2,
|
|
||||||
login_status: login_widgets.3,
|
|
||||||
},
|
|
||||||
status: StatusState {
|
status: StatusState {
|
||||||
status_inflight: Cell::new(false),
|
status_inflight: Cell::new(false),
|
||||||
transfers_group: locations_widgets.transfers_group.clone(),
|
transfers_group: locations_widgets.transfers_group.clone(),
|
||||||
@@ -446,6 +441,9 @@ fn build_window(app: &adw::Application) {
|
|||||||
retry: trash_widgets.retry.clone(),
|
retry: trash_widgets.retry.clone(),
|
||||||
empty: trash_widgets.empty.clone(),
|
empty: trash_widgets.empty.clone(),
|
||||||
subtitle: trash_widgets.subtitle.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 {
|
gallery: GalleryState {
|
||||||
model: gallery_widgets.model.clone(),
|
model: gallery_widgets.model.clone(),
|
||||||
@@ -628,7 +626,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
&gallery_widgets.empty_import,
|
&gallery_widgets.empty_import,
|
||||||
);
|
);
|
||||||
wire_albums(&ui);
|
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(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||||
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
||||||
|
|||||||
@@ -1243,6 +1243,33 @@ pub(crate) fn menu_item(label: &str, icon: &str) -> gtk4::Button {
|
|||||||
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.
|
/// 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> {
|
pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Option<DirEntry> {
|
||||||
let obj = model?.item(pos).and_downcast::<BoxedAnyObject>()?;
|
let obj = model?.item(pos).and_downcast::<BoxedAnyObject>()?;
|
||||||
|
|||||||
@@ -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.
|
// second copy of that list here would be two places to change the same mode.
|
||||||
let sync_group = adw::PreferencesGroup::builder()
|
let sync_group = adw::PreferencesGroup::builder()
|
||||||
.title("This computer")
|
.title("This computer")
|
||||||
.description("The device this machine backs up to.")
|
.description("The computer this machine backs up as.")
|
||||||
.build();
|
.build();
|
||||||
// Rename control for *this* machine's device, in the section header. The
|
// 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
|
// 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));
|
sync_group.set_header_suffix(Some(&rename_this));
|
||||||
let group = adw::PreferencesGroup::builder()
|
let group = adw::PreferencesGroup::builder()
|
||||||
.title("Other computers")
|
.title("Other computers")
|
||||||
.description("Other devices backing up to this account.")
|
.description("Other computers backing up to this account.")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||||
@@ -170,7 +170,7 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
|||||||
ui,
|
ui,
|
||||||
"computer-symbolic",
|
"computer-symbolic",
|
||||||
"Loading…",
|
"Loading…",
|
||||||
"Reading your devices.",
|
"Reading your computers.",
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
ui.busy_begin();
|
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) {
|
match devices.iter().find(|d| d.this_device) {
|
||||||
Some(me) => {
|
Some(me) => {
|
||||||
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
|
*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.set_sensitive(true);
|
||||||
ui.devices
|
ui.devices
|
||||||
.rename_this
|
.rename_this
|
||||||
@@ -286,43 +289,30 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
|||||||
.subtitle(device_subtitle(dev))
|
.subtitle(device_subtitle(dev))
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
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
|
// Adoption is how a reinstalled or renamed machine re-attaches to the
|
||||||
// device it used to be, instead of registering a duplicate. It only
|
// 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.
|
// loop paints.
|
||||||
let adopt = gtk4::Button::builder()
|
let (ui_ren, uid_ren, name_ren) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||||
.icon_name("insert-object-symbolic")
|
let (ui_ad, uid_ad, name_ad) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||||
.tooltip_text("Use this computer's identity for this machine")
|
let (ui_rm, uid_rm, name_rm) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||||
.valign(gtk4::Align::Center)
|
row.add_suffix(&more_menu_button(vec![
|
||||||
.build();
|
(
|
||||||
adopt.add_css_class("flat");
|
"Rename…",
|
||||||
let ui_ad = ui.clone();
|
"document-edit-symbolic",
|
||||||
let uid_ad = dev.uid.clone();
|
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)),
|
||||||
let name_ad = dev.name.clone();
|
),
|
||||||
adopt.connect_clicked(move |_| prompt_adopt_device(&ui_ad, &uid_ad, &name_ad));
|
(
|
||||||
row.add_suffix(&adopt);
|
"Use This Computer's Identity…",
|
||||||
let ui_ren = ui.clone();
|
"insert-object-symbolic",
|
||||||
let uid_ren = dev.uid.clone();
|
Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)),
|
||||||
let name_ren = dev.name.clone();
|
),
|
||||||
rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren));
|
(
|
||||||
let ui_rm = ui.clone();
|
"Remove Computer…",
|
||||||
let uid_rm = dev.uid.clone();
|
"user-trash-symbolic",
|
||||||
let name_rm = dev.name.clone();
|
Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)),
|
||||||
remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm));
|
),
|
||||||
row.add_suffix(&rename);
|
]));
|
||||||
row.add_suffix(&remove);
|
|
||||||
ui.devices.group.add(&row);
|
ui.devices.group.add(&row);
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
}
|
}
|
||||||
@@ -357,7 +347,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
|||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||||
let manage = gtk4::Button::builder()
|
let manage = gtk4::Button::builder()
|
||||||
.label("Locations")
|
.label("Open Sync")
|
||||||
.tooltip_text("Manage this computer's folders and mountpoint")
|
.tooltip_text("Manage this computer's folders and mountpoint")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.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.
|
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||||
if folders.is_empty() {
|
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() {
|
let count = match folders.len() {
|
||||||
1 => "1 folder backed up".to_string(),
|
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")
|
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||||
.count();
|
.count();
|
||||||
match attention {
|
match attention {
|
||||||
0 => format!("{count} · manage them under Locations"),
|
0 => format!("{count} · manage them on the Sync page"),
|
||||||
1 => format!("{count} · 1 needs attention"),
|
1 => format!("{count} · 1 needs attention"),
|
||||||
n => format!("{count} · {n} need 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) {
|
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Rename device")
|
.heading("Rename Computer")
|
||||||
.body(format!("Rename “{current}”."))
|
.body(format!("Rename “{current}”."))
|
||||||
.build();
|
.build();
|
||||||
let group = adw::PreferencesGroup::new();
|
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();
|
let name = row.text().trim().to_string();
|
||||||
if name.is_empty() {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
run_devices_mutation(
|
run_devices_mutation(
|
||||||
@@ -719,29 +709,42 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
|||||||
uid: uid.clone(),
|
uid: uid.clone(),
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
"Device renamed",
|
"Computer renamed",
|
||||||
"Couldn't rename the device",
|
"Couldn't rename the computer",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
dialog.present(win.as_ref());
|
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) {
|
pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||||
let win = ui_window(ui);
|
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()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Remove computer")
|
.heading("Remove Computer")
|
||||||
.body(format!(
|
.body(format!(
|
||||||
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \
|
"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 \
|
deleted along with it. The files on that computer itself are not touched — but \
|
||||||
this cannot be undone from here."
|
this cannot be undone from here."
|
||||||
))
|
))
|
||||||
|
.extra_child(&group)
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", "Cancel");
|
||||||
dialog.add_response("remove", "Remove");
|
dialog.add_response("remove", "Remove");
|
||||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||||
|
dialog.set_response_enabled("remove", false);
|
||||||
dialog.set_default_response(Some("cancel"));
|
dialog.set_default_response(Some("cancel"));
|
||||||
dialog.set_close_response("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 ui = ui.clone();
|
||||||
let uid = uid.to_string();
|
let uid = uid.to_string();
|
||||||
dialog.connect_response(None, move |_, resp| {
|
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(
|
run_devices_mutation(
|
||||||
&ui,
|
&ui,
|
||||||
Request::DeleteDevice { uid: uid.clone() },
|
Request::DeleteDevice { uid: uid.clone() },
|
||||||
"Device removed",
|
"Computer removed",
|
||||||
"Couldn't remove the device",
|
"Couldn't remove the computer",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -843,7 +846,7 @@ mod tests {
|
|||||||
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
this_computer_subtitle(&[]),
|
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.
|
// the Computers page can surface that something is wrong.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
this_computer_subtitle(&[folder("idle"), folder("idle")]),
|
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!(
|
assert_eq!(
|
||||||
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
||||||
|
|||||||
@@ -6,22 +6,20 @@ pub(crate) struct LoginState {
|
|||||||
pub(crate) email: adw::EntryRow,
|
pub(crate) email: adw::EntryRow,
|
||||||
pub(crate) password: adw::PasswordEntryRow,
|
pub(crate) password: adw::PasswordEntryRow,
|
||||||
pub(crate) login_button: gtk4::Button,
|
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,
|
pub(crate) login_status: gtk4::Label,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The login page: an email row, password row, a primary "Sign in" button and a
|
/// Where the login page's account links go.
|
||||||
/// status label, centred in a clamp. The 2FA code is prompted lazily in a
|
const SIGNUP_URL: &str = "https://account.proton.me/drive/signup";
|
||||||
/// dialog (see [`prompt_2fa`]) only when the account actually requires it.
|
const RESET_PASSWORD_URL: &str = "https://account.proton.me/reset-password";
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub(crate) fn build_login_page() -> (
|
/// The login page: an email row, password row, a primary "Sign in" button, a
|
||||||
gtk4::Widget,
|
/// 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`])
|
||||||
adw::EntryRow,
|
/// only when the account actually requires it.
|
||||||
adw::PasswordEntryRow,
|
pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||||
gtk4::Button,
|
|
||||||
gtk4::Label,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
let group = adw::PreferencesGroup::builder()
|
let group = adw::PreferencesGroup::builder()
|
||||||
.title("Sign in to Proton")
|
.title("Sign in to Proton")
|
||||||
.description("Use your Proton account to connect Drive.")
|
.description("Use your Proton account to connect Drive.")
|
||||||
@@ -32,8 +30,12 @@ pub(crate) fn build_login_page() -> (
|
|||||||
group.add(&email);
|
group.add(&email);
|
||||||
group.add(&password);
|
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()
|
let login_button = gtk4::Button::builder()
|
||||||
.label("Sign in")
|
.child(&button_content)
|
||||||
.halign(gtk4::Align::Center)
|
.halign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
login_button.add_css_class("suggested-action");
|
login_button.add_css_class("suggested-action");
|
||||||
@@ -64,6 +66,14 @@ pub(crate) fn build_login_page() -> (
|
|||||||
inner.append(&group);
|
inner.append(&group);
|
||||||
inner.append(&login_button);
|
inner.append(&login_button);
|
||||||
inner.append(&login_status);
|
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()
|
let clamp = adw::Clamp::builder()
|
||||||
.maximum_size(420)
|
.maximum_size(420)
|
||||||
@@ -73,7 +83,13 @@ pub(crate) fn build_login_page() -> (
|
|||||||
|
|
||||||
(
|
(
|
||||||
scroll.upcast(),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.login.login_button.set_sensitive(false);
|
set_signing_in(&ui, true);
|
||||||
ui.login.login_status.set_text("Signing in…");
|
ui.login.login_status.set_text("Signing in…");
|
||||||
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
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()
|
.recv()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| Err("login cancelled".into()));
|
.unwrap_or_else(|_| Err("login cancelled".into()));
|
||||||
ui.login.login_button.set_sensitive(true);
|
set_signing_in(&ui, false);
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
ui.login.login_status.set_text("");
|
ui.login.login_status.set_text("");
|
||||||
@@ -151,15 +167,53 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
service::enable_start();
|
service::enable_start();
|
||||||
refresh(&ui);
|
refresh(&ui);
|
||||||
}
|
}
|
||||||
Err(e) => ui
|
Err(e) => ui.login.login_status.set_text(&e),
|
||||||
.login
|
|
||||||
.login_status
|
|
||||||
.set_text(&format!("Sign-in failed: {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
|
/// 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;
|
/// 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
|
/// the second fires *only if* the account needs a 2FA code, carrying a
|
||||||
@@ -223,7 +277,7 @@ pub(crate) fn spawn_login(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| login_error_message(&e))
|
||||||
});
|
});
|
||||||
let _ = tx.send_blocking(result);
|
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>) {
|
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Two-factor authentication")
|
.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();
|
.build();
|
||||||
|
|
||||||
let group = adw::PreferencesGroup::new();
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ pub(crate) struct SharedByMeWidgets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The Shared page: one section listing the items I have shared with others —
|
/// 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)
|
/// each row summarizing who has access, opening the item when activated, with
|
||||||
/// and a Manage shortcut into the per-node Share dialog.
|
/// 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) {
|
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||||
let refresh = refresh_button();
|
let refresh = refresh_button();
|
||||||
|
|
||||||
@@ -199,8 +199,17 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
.build();
|
.build();
|
||||||
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
|
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
|
||||||
|
|
||||||
// Copy / open the public link, when there is one with a recovered URL.
|
// A node the daemon can place in my tree opens like it would in My Files.
|
||||||
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
|
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()
|
let copy = gtk4::Button::builder()
|
||||||
.icon_name("edit-copy-symbolic")
|
.icon_name("edit-copy-symbolic")
|
||||||
.tooltip_text("Copy link")
|
.tooltip_text("Copy link")
|
||||||
@@ -208,34 +217,13 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
.build();
|
.build();
|
||||||
copy.add_css_class("flat");
|
copy.add_css_class("flat");
|
||||||
let ui_copy = ui.clone();
|
let ui_copy = ui.clone();
|
||||||
let url_copy = url.clone();
|
|
||||||
copy.connect_clicked(move |btn| {
|
copy.connect_clicked(move |btn| {
|
||||||
btn.clipboard().set_text(&url_copy);
|
btn.clipboard().set_text(&url);
|
||||||
toast(&ui_copy, "Link copied");
|
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(©);
|
||||||
row.add_suffix(&open);
|
|
||||||
}
|
}
|
||||||
|
row.add_suffix(&shared_by_me_menu(ui, &entry, url));
|
||||||
// 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);
|
|
||||||
|
|
||||||
ui.shared_by_me.group.add(&row);
|
ui.shared_by_me.group.add(&row);
|
||||||
rows.push(row.upcast());
|
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;
|
*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.
|
/// A one-line summary of who can reach a shared item, for its row subtitle.
|
||||||
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ pub(crate) struct TrashState {
|
|||||||
pub(crate) empty: gtk4::Button,
|
pub(crate) empty: gtk4::Button,
|
||||||
/// "12 items" under the page title.
|
/// "12 items" under the page title.
|
||||||
pub(crate) subtitle: adw::WindowTitle,
|
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.
|
/// 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) empty: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) refresh: gtk4::Button,
|
||||||
pub(crate) subtitle: adw::WindowTitle,
|
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
|
/// 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
|
/// 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
|
/// 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 model = gio::ListStore::new::<BoxedAnyObject>();
|
||||||
|
|
||||||
let empty = gtk4::Button::builder()
|
let empty = gtk4::Button::builder()
|
||||||
.label("Empty…")
|
.label("Empty Trash…")
|
||||||
.tooltip_text("Permanently delete everything in the Trash")
|
.tooltip_text("Permanently delete everything in the Trash")
|
||||||
.sensitive(false)
|
.sensitive(false)
|
||||||
.build();
|
.build();
|
||||||
@@ -56,11 +67,10 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
status.add_css_class("compact");
|
status.add_css_class("compact");
|
||||||
|
|
||||||
// No selection model: every action lives on the row it acts on, so a
|
// Bulk Delete Forever always confirms with the count, so a stale selection
|
||||||
// mis-aimed Delete Forever isn't one click away from a stale selection.
|
// can't delete anything silently.
|
||||||
let list = gtk4::ListView::builder()
|
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
||||||
.model(>k4::NoSelection::new(Some(model.clone())))
|
let list = gtk4::ListView::builder().model(&selection).build();
|
||||||
.build();
|
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.child(&list)
|
.child(&list)
|
||||||
@@ -78,6 +88,21 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
inner.set_margin_start(18);
|
inner.set_margin_start(18);
|
||||||
inner.set_margin_end(18);
|
inner.set_margin_end(18);
|
||||||
inner.append(&content);
|
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);
|
let (frame, header, subtitle) = page_frame("Trash", &inner);
|
||||||
header.pack_start(&empty);
|
header.pack_start(&empty);
|
||||||
header.pack_end(&refresh);
|
header.pack_end(&refresh);
|
||||||
@@ -93,6 +118,11 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
empty,
|
empty,
|
||||||
refresh,
|
refresh,
|
||||||
subtitle,
|
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
|
/// 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
|
/// 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.
|
/// captured copy, so a recycled row always acts on the item it currently shows.
|
||||||
pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::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 factory = gtk4::SignalListItemFactory::new();
|
||||||
let ui_setup = ui.clone();
|
let ui_setup = ui.clone();
|
||||||
factory.connect_setup(move |_, item| {
|
factory.connect_setup(move |_, item| {
|
||||||
@@ -144,7 +176,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
|||||||
let item_purge = item.clone();
|
let item_purge = item.clone();
|
||||||
purge.connect_clicked(move |_| {
|
purge.connect_clicked(move |_| {
|
||||||
if let Some(entry) = bound_entry(&item_purge) {
|
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: >k4::ListView, empty: >k4::Butto
|
|||||||
|
|
||||||
let ui_empty = ui.clone();
|
let ui_empty = ui.clone();
|
||||||
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
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
|
/// 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.
|
// that may already be gone.
|
||||||
ui.trash.model.remove_all();
|
ui.trash.model.remove_all();
|
||||||
ui.trash.empty.set_sensitive(false);
|
ui.trash.empty.set_sensitive(false);
|
||||||
|
sync_trash_selection(ui);
|
||||||
trash_status(
|
trash_status(
|
||||||
ui,
|
ui,
|
||||||
"user-trash-symbolic",
|
"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.
|
/// user looks for what they just deleted.
|
||||||
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
ui.trash.model.remove_all();
|
ui.trash.model.remove_all();
|
||||||
|
sync_trash_selection(ui);
|
||||||
ui.trash.empty.set_sensitive(!entries.is_empty());
|
ui.trash.empty.set_sensitive(!entries.is_empty());
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
trash_status(
|
trash_status(
|
||||||
@@ -297,10 +371,9 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.trash.content.set_visible_child_name("list");
|
ui.trash.content.set_visible_child_name("list");
|
||||||
ui.trash.subtitle.set_subtitle(&match entries.len() {
|
ui.trash
|
||||||
1 => "1 item".to_string(),
|
.subtitle
|
||||||
n => format!("{n} items"),
|
.set_subtitle(&count_noun(entries.len(), "item", "items"));
|
||||||
});
|
|
||||||
|
|
||||||
let mut sorted = entries.to_vec();
|
let mut sorted = entries.to_vec();
|
||||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
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.
|
/// Restore one trashed entry to the folder it was trashed from.
|
||||||
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
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(
|
run_mutation(
|
||||||
ui,
|
ui,
|
||||||
Request::Restore {
|
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",
|
"Couldn't restore",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks.
|
/// “name” for one entry, “3 items” for several.
|
||||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
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 win = ui_window(ui);
|
||||||
let uid = entry.uid.clone();
|
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||||
let name = entry.name.clone();
|
let name = entries_label(entries);
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Delete permanently")
|
.heading("Delete Permanently")
|
||||||
.body(format!(
|
.body(format!("Permanently delete {name}? This cannot be undone."))
|
||||||
"Permanently delete “{name}”? This cannot be undone."
|
|
||||||
))
|
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", "Cancel");
|
||||||
dialog.add_response("delete", "Delete Permanently");
|
dialog.add_response("delete", "Delete Permanently");
|
||||||
@@ -344,10 +427,8 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
if resp == "delete" {
|
if resp == "delete" {
|
||||||
run_mutation(
|
run_mutation(
|
||||||
&ui,
|
&ui,
|
||||||
Request::DeleteForever {
|
Request::DeleteForever { uids: uids.clone() },
|
||||||
uids: vec![uid.clone()],
|
format!("Deleted {name} permanently"),
|
||||||
},
|
|
||||||
format!("Deleted “{name}” permanently"),
|
|
||||||
"Couldn't delete",
|
"Couldn't delete",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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
|
while a Takeout import runs and links to it. Pages load 200 photos at a time and keep loading
|
||||||
until the window is full.
|
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
|
## [1.11.1] — 2026-09-19
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user