fix(core,fuse,gui): UI/UX quick wins from the audit (phase 0)

- Move Google Takeout import out of Settings; Photos owns it, and the
  Takeout page's back button returns there.
- Trash from the file browser is Undo-only (no confirm); F2 on a
  multi-selection explains why it does nothing.
- Ask before sign-out, rejecting an invitation, removing someone's access,
  removing a public link, and switching a folder to on-demand.
- Clearer offline wording ("Make available offline" / "Make online only")
  with dedicated offline, online-only and upload symbolic icons; the pin row
  is hidden for folders.
- Search hits report whether a file is cached, so search results show the
  offline badge.
- Activity log: readable Empty Trash / link entries, distinct icons, and
  error/warning styling for failures and conflicts.
- Proper singular/plural counts instead of "item(s)" / "archive(s)".
- Name earlier versions by save time instead of revision id; capitalise
  the public link role; stop-syncing uses a stop icon, not a trash can.
- Label the Photos Upload and "Show on map" buttons; toast when a
  thumbnail build finishes; the versions button follows the mount state.
- Mode switches and device mutations reload both sync pages.
This commit is contained in:
nvrl committed 2026-09-23 01:34:35 +02:00
1 parent 690b91deab
commit cc308ac25a
27 files changed
+341 -190

No files matched your search

+5
View File
@@ -976,6 +976,10 @@ pub struct SearchHit {
pub size: u64, pub size: u64,
pub modified: i64, pub modified: i64,
pub pinned: bool, pub pinned: bool,
/// Whether a valid copy of the content is in the local cache. Legacy
/// daemons omit it; clients then treat the file as online-only.
#[serde(default)]
pub cached: bool,
/// Node uid in `volume~link` form. /// Node uid in `volume~link` form.
pub uid: String, pub uid: String,
/// Absolute path through the most specific active on-demand mount, when the /// Absolute path through the most specific active on-demand mount, when the
@@ -2133,6 +2137,7 @@ mod tests {
size: 12, size: 12,
modified: 123, modified: 123,
pinned: true, pinned: true,
cached: false,
uid: "vol~link".into(), uid: "vol~link".into(),
mounted_path: Some("/home/me/Videos/report.pdf".into()), mounted_path: Some("/home/me/Videos/report.pdf".into()),
score: 985, score: 985,
+7 -2
View File
@@ -810,9 +810,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() { Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
Ok(n) => { Ok(n) => {
core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true); let items = if n == 1 {
"1 item".to_string()
} else {
format!("{n} items")
};
core.log_activity(ActivityKind::EmptyTrash, items.clone(), "", true);
CtlResponse::Ok { CtlResponse::Ok {
message: format!("emptied trash ({n} item(s))"), message: format!("emptied trash ({items})"),
} }
} }
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
+12
View File
@@ -3865,6 +3865,12 @@ impl Core {
size: node_size(&h.node), size: node_size(&h.node),
modified: h.node.modification_time, modified: h.node.modification_time,
pinned: pinned.contains(&h.node.uid.to_string()), pinned: pinned.contains(&h.node.uid.to_string()),
cached: !h.node.is_folder()
&& self.cache.is_cached(
&h.node.uid,
h.node.modification_time,
node_size(&h.node),
),
uid: h.node.uid.to_string(), uid: h.node.uid.to_string(),
mounted_path: roots.resolve(&h.path), mounted_path: roots.resolve(&h.path),
path: h.path, path: h.path,
@@ -3932,6 +3938,12 @@ impl Core {
size: node_size(&hit.node), size: node_size(&hit.node),
modified: hit.node.modification_time, modified: hit.node.modification_time,
pinned: is_pinned, pinned: is_pinned,
cached: !hit.node.is_folder()
&& self.cache.is_cached(
&hit.node.uid,
hit.node.modification_time,
node_size(&hit.node),
),
uid, uid,
mounted_path: roots.resolve(&hit.path), mounted_path: roots.resolve(&hit.path),
path: hit.path, path: hit.path,
+1 -6
View File
@@ -22,10 +22,5 @@ fn main() {
} }
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml"); println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!( println!("cargo:rerun-if-changed=resources/icons");
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
);
println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
);
} }
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- "Available offline": a filled disc with a down arrow knocked out of it
(one path, fill-rule evenodd, so symbolic recolouring keeps working).
Replaces the star, which every desktop uses for "favourite". -->
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 7,4 H 9 V 8.6 L 10.8,6.8 L 12.2,8.2 L 8,12.4 L 3.8,8.2 L 5.2,6.8 L 7,8.6 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 485 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- "Online only": a cloud outline. Lighter than the stock weather icon, so a
folder full of cloud-only files does not shout. -->
<path fill="#222222" fill-rule="evenodd" d="M 8.5,3 C 6.6,3 5,4.3 4.6,6.1 C 2.6,6.3 1,8 1,10 C 1,12.2 2.8,14 5,14 H 12 C 13.7,14 15,12.7 15,11 C 15,9.5 13.9,8.2 12.4,8 C 12.1,5.2 10.5,3 8.5,3 Z M 8.5,4.5 C 9.9,4.5 11,5.9 11,8 V 9.4 H 12 C 12.8,9.4 13.5,10.1 13.5,11 C 13.5,11.8 12.8,12.5 12,12.5 H 5 C 3.6,12.5 2.5,11.4 2.5,10 C 2.5,8.6 3.6,7.5 5,7.5 H 5.9 L 6,6.7 C 6.2,5.4 7.2,4.5 8.5,4.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 629 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Upload-files action: an up arrow over a tray, the pair to
pdfs-folder-upload-symbolic. Stock themes only offer `document-send`,
which reads as "send by mail". -->
<path fill="#222222" d="M 8,1 L 3.5,5.5 L 4.9,6.9 L 7,4.8 V 11 H 9 V 4.8 L 11.1,6.9 L 12.5,5.5 Z"/>
<path fill="#222222" d="M 1,10 V 14 C 1,14.6 1.4,15 2,15 H 14 C 14.6,15 15,14.6 15,14 V 10 H 13 V 13 H 3 V 10 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

@@ -6,5 +6,8 @@
<gresource prefix="/de/nils/protondrivelinux/icons"> <gresource prefix="/de/nils/protondrivelinux/icons">
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
</gresource> </gresource>
</gresources> </gresources>
+36 -5
View File
@@ -603,7 +603,6 @@ fn build_window(app: &adw::Application) {
&ui, &ui,
&main_widgets.purge_button, &main_widgets.purge_button,
&main_widgets.mountpoint_button, &main_widgets.mountpoint_button,
&main_widgets.import_row,
); );
wire_sidebar(&ui); wire_sidebar(&ui);
wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view); wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view);
@@ -773,12 +772,12 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
} }
/// The sidebar destination a page belongs under. Most pages are their own /// The sidebar destination a page belongs under. Most pages are their own
/// destination; a sub-page reached from one (Import, off Settings) has no row of /// destination; a sub-page reached from one (Import, off Photos) has no row of
/// its own and answers with its parent, so the sidebar highlights where the user /// its own and answers with its parent, so the sidebar highlights where the user
/// came from rather than clearing — which would read as "nowhere". /// came from rather than clearing — which would read as "nowhere".
fn destination_of(page: &str) -> &str { fn destination_of(page: &str) -> &str {
match page { match page {
"takeout" => "main", "takeout" => "gallery",
other => other, other => other,
} }
} }
@@ -956,6 +955,12 @@ fn sync_sidebar(ui: &Rc<Ui>) {
} }
} }
/// "1 item" / "5 items": a count with the noun form that agrees with it, so
/// no user-facing string has to fall back to "item(s)".
fn count_noun(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
/// Show a transient toast. Non-blocking by design: an action's outcome is /// Show a transient toast. Non-blocking by design: an action's outcome is
/// reported without stealing focus or forcing a click, so the user can keep /// reported without stealing focus or forcing a click, so the user can keep
/// working while a slow upload lands. /// working while a slow upload lands.
@@ -1134,7 +1139,11 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui), gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui), gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
gtk4::gdk::Key::F2 if on_browser => { gtk4::gdk::Key::F2 if on_browser => {
if let Some(entry) = selected_entry(&ui) { // Renaming is one name at a time; saying so beats a key that
// silently does nothing with several items highlighted.
if selected_entries(&ui).len() > 1 {
toast(&ui, "Select a single item to rename it");
} else if let Some(entry) = selected_entry(&ui) {
prompt_rename(&ui, &entry); prompt_rename(&ui, &entry);
} }
} }
@@ -1144,7 +1153,7 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
gtk4::gdk::Key::Delete if on_browser => { gtk4::gdk::Key::Delete if on_browser => {
let entries = selected_entries(&ui); let entries = selected_entries(&ui);
if !entries.is_empty() { if !entries.is_empty() {
prompt_delete_many(&ui, entries); trash_entries(&ui, entries);
} }
} }
gtk4::gdk::Key::Escape gtk4::gdk::Key::Escape
@@ -1196,6 +1205,28 @@ fn ui_window(ui: &Rc<Ui>) -> Option<gtk4::Window> {
ui.stack.root().and_downcast::<gtk4::Window>() ui.stack.root().and_downcast::<gtk4::Window>()
} }
/// Ask before an action that has no Undo. `on_confirm` runs only when the user
/// picks `action`; Cancel is the default, so Enter never destroys anything.
fn confirm_destructive(
parent: &impl IsA<gtk4::Widget>,
heading: &str,
body: &str,
action: &str,
on_confirm: impl Fn() + 'static,
) {
let dialog = adw::AlertDialog::builder()
.heading(heading)
.body(body)
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("confirm", action);
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
dialog.connect_response(Some("confirm"), move |_, _| on_confirm());
dialog.present(Some(parent));
}
/// A dim, non-interactive placeholder row for an empty section. /// A dim, non-interactive placeholder row for an empty section.
fn dim_row(text: &str) -> adw::ActionRow { fn dim_row(text: &str) -> adw::ActionRow {
let row = adw::ActionRow::builder().title(text).build(); let row = adw::ActionRow::builder().title(text).build();
+22 -11
View File
@@ -248,16 +248,28 @@ pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
if !a.detail.is_empty() { if !a.detail.is_empty() {
subtitle = format!("{subtitle} · {}", a.detail); subtitle = format!("{subtitle} · {}", a.detail);
} }
let title = match a.kind {
// The target of an empty is a count, not a name to lead with a verb.
ActivityKind::EmptyTrash => format!("Emptied Trash ({})", a.target),
kind => format!("{} {}", activity_verb(kind), a.target),
};
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(format!("{} {}", activity_verb(a.kind), a.target)) .title(title)
.subtitle(subtitle) .subtitle(subtitle)
.build(); .build();
let icon = if a.ok { // A failure and a conflict are different news: a conflict kept both
// copies, a failure lost the action. Each gets its own icon and colour.
let icon = gtk4::Image::from_icon_name(if a.ok {
activity_icon(a.kind) activity_icon(a.kind)
} else { } else {
"dialog-warning-symbolic" "dialog-error-symbolic"
}; });
row.add_prefix(&gtk4::Image::from_icon_name(icon)); if !a.ok {
icon.add_css_class("error");
} else if a.kind == ActivityKind::Conflict {
icon.add_css_class("warning");
}
row.add_prefix(&icon);
row.set_activatable(false); row.set_activatable(false);
ui.activity.group.add(&row); ui.activity.group.add(&row);
rows.push(row.upcast()); rows.push(row.upcast());
@@ -277,9 +289,9 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
ActivityKind::Trash => "Trashed", ActivityKind::Trash => "Trashed",
ActivityKind::Restore => "Restored", ActivityKind::Restore => "Restored",
ActivityKind::DeleteForever => "Deleted", ActivityKind::DeleteForever => "Deleted",
ActivityKind::EmptyTrash => "Emptied trash —", ActivityKind::EmptyTrash => "Emptied Trash",
ActivityKind::Share => "Shared", ActivityKind::Share => "Shared",
ActivityKind::PublicLink => "Linked", ActivityKind::PublicLink => "Created a link to",
ActivityKind::Unshare => "Unshared", ActivityKind::Unshare => "Unshared",
ActivityKind::Conflict => "Conflict", ActivityKind::Conflict => "Conflict",
} }
@@ -288,15 +300,14 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
/// A themed icon for an activity kind. /// A themed icon for an activity kind.
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str { pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
match kind { match kind {
ActivityKind::Upload => "document-send-symbolic", ActivityKind::Upload => "pdfs-upload-symbolic",
ActivityKind::Download => "document-save-symbolic", ActivityKind::Download => "document-save-symbolic",
ActivityKind::Sync => "emblem-synchronizing-symbolic", ActivityKind::Sync => "emblem-synchronizing-symbolic",
ActivityKind::Rename => "document-edit-symbolic", ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic", ActivityKind::Move => "go-jump-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic", ActivityKind::CreateFolder => "folder-new-symbolic",
ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => { ActivityKind::Trash => "user-trash-symbolic",
"user-trash-symbolic" ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
}
ActivityKind::Restore => "edit-undo-symbolic", ActivityKind::Restore => "edit-undo-symbolic",
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic", ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
ActivityKind::Unshare => "action-unavailable-symbolic", ActivityKind::Unshare => "action-unavailable-symbolic",
+36 -71
View File
@@ -177,7 +177,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.build(); .build();
new_folder.add_css_class("flat"); new_folder.add_css_class("flat");
let upload = gtk4::Button::builder() let upload = gtk4::Button::builder()
.icon_name("document-send-symbolic") .icon_name("pdfs-upload-symbolic")
.tooltip_text("Upload files") .tooltip_text("Upload files")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
@@ -337,12 +337,12 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
// instead of making the whole view jump when a second entry is selected. // instead of making the whole view jump when a second entry is selected.
let bulk_label = gtk4::Label::builder().hexpand(true).xalign(0.0).build(); let bulk_label = gtk4::Label::builder().hexpand(true).xalign(0.0).build();
let bulk_pin = gtk4::Button::builder() let bulk_pin = gtk4::Button::builder()
.label("Keep offline") .label("Make available offline")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
bulk_pin.add_css_class("flat"); bulk_pin.add_css_class("flat");
let bulk_unpin = gtk4::Button::builder() let bulk_unpin = gtk4::Button::builder()
.label("Remove offline copy") .label("Make online only")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
bulk_unpin.add_css_class("flat"); bulk_unpin.add_css_class("flat");
@@ -573,39 +573,15 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
ui.browser.bulk.set_reveal_child(true); ui.browser.bulk.set_reveal_child(true);
} }
/// Confirm and move every selected entry to Trash. /// Move every selected entry to Trash.
/// ///
/// One dialog for the batch, one pass over the daemon, one toast — and one Undo /// No confirmation: Trash is itself the safety net, and the toast carries one
/// that restores the whole batch, because a mis-aimed bulk delete is exactly the /// Undo that restores the whole batch. Asking first *and* offering Undo after
/// action a user most needs to take back. /// would put two hurdles in front of a reversible action.
pub(crate) fn prompt_delete_many(ui: &Rc<Ui>, entries: Vec<DirEntry>) { pub(crate) fn trash_entries(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
if entries.len() < 2 { if !entries.is_empty() {
if let Some(entry) = entries.first() { run_bulk_delete(ui, entries);
prompt_delete(ui, entry);
}
return;
} }
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Move to Trash")
.body(format!(
"Move {} items to Trash? Anything inside a selected folder goes with it.",
entries.len()
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("trash", "Move to Trash");
dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "trash" {
run_bulk_delete(&ui, entries.clone());
}
});
dialog.present(win.as_ref());
} }
/// Trash a batch, one request at a time. /// Trash a batch, one request at a time.
@@ -729,7 +705,7 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
&if pin { &if pin {
format!("{n} files are now available offline") format!("{n} files are now available offline")
} else { } else {
format!("{n} files are no longer kept offline") format!("{n} files are now online only")
}, },
), ),
} }
@@ -746,7 +722,7 @@ pub(crate) fn wire_bulk(
let ui_trash = ui.clone(); let ui_trash = ui.clone();
ui.browser ui.browser
.bulk_trash .bulk_trash
.connect_clicked(move |_| prompt_delete_many(&ui_trash, selected_entries(&ui_trash))); .connect_clicked(move |_| trash_entries(&ui_trash, selected_entries(&ui_trash)));
let ui_pin = ui.clone(); let ui_pin = ui.clone();
ui.browser ui.browser
.bulk_pin .bulk_pin
@@ -1089,9 +1065,9 @@ pub(crate) fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: &gtk4::Bo
if !entry.is_dir { if !entry.is_dir {
let (label, icon) = if entry.pinned { let (label, icon) = if entry.pinned {
("Unpin", "non-starred-symbolic") ("Make online only", "pdfs-online-only-symbolic")
} else { } else {
("Keep offline", "starred-symbolic") ("Make available offline", "pdfs-offline-symbolic")
}; };
let pin = menu_item(label, icon); let pin = menu_item(label, icon);
let ui_pin = ui.clone(); let ui_pin = ui.clone();
@@ -1144,7 +1120,7 @@ pub(crate) fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: &gtk4::Bo
let pop = popover.clone(); let pop = popover.clone();
trash.connect_clicked(move |_| { trash.connect_clicked(move |_| {
pop.popdown(); pop.popdown();
prompt_delete(&ui_tr, &entry_tr); trash_entry(&ui_tr, &entry_tr);
}); });
menu.append(&trash); menu.append(&trash);
@@ -1184,7 +1160,7 @@ pub(crate) fn show_bulk_context_menu(
let files_only = entries.iter().all(|e| !e.is_dir); let files_only = entries.iter().all(|e| !e.is_dir);
if files_only && entries.iter().any(|e| !e.pinned) { if files_only && entries.iter().any(|e| !e.pinned) {
let pin = menu_item("Keep offline", "starred-symbolic"); let pin = menu_item("Make available offline", "pdfs-offline-symbolic");
let ui_pin = ui.clone(); let ui_pin = ui.clone();
let batch = entries.clone(); let batch = entries.clone();
let pop = popover.clone(); let pop = popover.clone();
@@ -1195,7 +1171,7 @@ pub(crate) fn show_bulk_context_menu(
menu.append(&pin); menu.append(&pin);
} }
if files_only && entries.iter().any(|e| e.pinned) { if files_only && entries.iter().any(|e| e.pinned) {
let unpin = menu_item("Remove offline copy", "non-starred-symbolic"); let unpin = menu_item("Make online only", "pdfs-online-only-symbolic");
let ui_unpin = ui.clone(); let ui_unpin = ui.clone();
let batch = entries.clone(); let batch = entries.clone();
let pop = popover.clone(); let pop = popover.clone();
@@ -1206,14 +1182,18 @@ pub(crate) fn show_bulk_context_menu(
menu.append(&unpin); menu.append(&unpin);
} }
menu.append(&gtk4::Separator::new(gtk4::Orientation::Horizontal)); // Offline items only exist for all-file selections; without them the
// separator would sit directly under the header.
if files_only {
menu.append(&gtk4::Separator::new(gtk4::Orientation::Horizontal));
}
let trash = menu_item("Move to Trash", "user-trash-symbolic"); let trash = menu_item("Move to Trash", "user-trash-symbolic");
let ui_tr = ui.clone(); let ui_tr = ui.clone();
let pop = popover.clone(); let pop = popover.clone();
trash.connect_clicked(move |_| { trash.connect_clicked(move |_| {
pop.popdown(); pop.popdown();
prompt_delete_many(&ui_tr, entries.clone()); trash_entries(&ui_tr, entries.clone());
}); });
menu.append(&trash); menu.append(&trash);
@@ -1367,7 +1347,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
toast( toast(
&ui, &ui,
&if pinned { &if pinned {
format!("“{name}” is no longer kept offline") format!("“{name}” is now online only")
} else { } else {
format!("“{name}” is now available offline") format!("“{name}” is now available offline")
}, },
@@ -1651,6 +1631,12 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
repaint_thumbnail_build_action(ui, true); repaint_thumbnail_build_action(ui, true);
schedule_thumbnail_build_poll(ui); schedule_thumbnail_build_poll(ui);
} else { } else {
// The progress row leaves with the run, so the outcome is handed to a
// toast instead — otherwise the build ends without a word. Only on the
// running → finished edge: a status read at startup is not news.
if ui.browser.thumbnail_build_running.get() {
toast(ui, &text);
}
ui.browser.thumbnail_build_running.set(false); ui.browser.thumbnail_build_running.set(false);
ui.browser.thumbnail_cancel_pending.set(false); ui.browser.thumbnail_cancel_pending.set(false);
repaint_thumbnail_build_action(ui, false); repaint_thumbnail_build_action(ui, false);
@@ -1829,29 +1815,9 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entry: &DirEntry) {
dialog.present(parent.as_ref()); dialog.present(parent.as_ref());
} }
/// Confirm and move the entry to Trash through the daemon. /// Move one entry to Trash, through the batch path so it gets the same Undo.
pub(crate) fn prompt_delete(ui: &Rc<Ui>, entry: &DirEntry) { pub(crate) fn trash_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let win = ui_window(ui); run_bulk_delete(ui, vec![entry.clone()]);
let dialog = adw::AlertDialog::builder()
.heading("Move to Trash")
.body(format!("Move “{}” to Trash?", entry.name))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("trash", "Move to Trash");
dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
// Routed through the batch path so a single trash gets the same Undo: one
// mis-click on one file is no less worth taking back than fifty.
let entry = entry.clone();
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "trash" {
run_bulk_delete(&ui, vec![entry.clone()]);
}
});
dialog.present(win.as_ref());
} }
/// Prompt for a folder name and create it under the current browser directory. /// Prompt for a folder name and create it under the current browser directory.
@@ -1978,11 +1944,11 @@ pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)
return None; return None;
} }
Some(if entry.pinned { Some(if entry.pinned {
("starred-symbolic", "badge-pinned") ("pdfs-offline-symbolic", "badge-pinned")
} else if entry.cached { } else if entry.cached {
("emblem-ok-symbolic", "badge-cached") ("emblem-ok-symbolic", "badge-cached")
} else { } else {
("weather-overcast-symbolic", "badge-cloud") ("pdfs-online-only-symbolic", "badge-cloud")
}) })
} }
@@ -2340,8 +2306,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
size: h.size, size: h.size,
modified: h.modified, modified: h.modified,
pinned: h.pinned, pinned: h.pinned,
// Search hits don't carry cache state; the badge shows in listings. cached: h.cached,
cached: false,
uid: h.uid.clone(), uid: h.uid.clone(),
path: h.path.clone(), path: h.path.clone(),
role: String::new(), role: String::new(),
+31 -10
View File
@@ -43,9 +43,10 @@ pub(crate) struct DevicesWidgets {
pub(crate) refresh: gtk4::Button, pub(crate) refresh: gtk4::Button,
} }
/// The Computers page: a "This computer" section listing the local folders synced /// The Computers page: a "This computer" section naming the device this machine
/// to this machine's device (each row offering a mode toggle and Remove), plus an /// backs up to (renamable from its header), plus an "Other computers" section
/// "Other devices" section listing the account's other registered devices. /// listing the account's other registered devices. The synced folders
/// themselves live on the Locations page.
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
let title = gtk4::Label::builder() let title = gtk4::Label::builder()
.label("Computers") .label("Computers")
@@ -588,7 +589,7 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the /// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
/// row's subtitle and switch reflect the daemon's real state (the request may be /// row's subtitle and switch reflect the daemon's real state (the request may be
/// rejected, e.g. switching to on-demand while a folder is mid-sync). /// rejected, e.g. switching to on-demand while a folder is mid-sync).
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) { pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
let rx = spawn_request( let rx = spawn_request(
ui.dirs.control_socket(), ui.dirs.control_socket(),
Request::SetSyncFolderMode { Request::SetSyncFolderMode {
@@ -599,7 +600,17 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
match rx.recv().await { match rx.recv().await {
Ok(Ok(Response::Ok { message })) => toast(&ui, &message), // The daemon's own text is written for the log; the toast says what
// the person will see happen. A switch may be queued behind a running
// pass, hence "will".
Ok(Ok(Response::Ok { .. })) => toast(
&ui,
if mode == "ondemand" {
"Folder will switch to on-demand"
} else {
"Folder will download and stay synced"
},
),
Ok(Ok(Response::Error { message, kind })) => { Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't change mode", &message, kind) toast_failure(&ui, "Couldn't change mode", &message, kind)
} }
@@ -609,12 +620,22 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
"The mount service didn't respond.", "The mount service didn't respond.",
), ),
} }
if ui.stack.visible_child_name().as_deref() == Some("devices") { reload_sync_pages(&ui);
load_devices(&ui);
}
}); });
} }
/// Repaint whichever of the two pages that show sync folders is on screen. Sync
/// folder changes are raised from Locations as well as Computers, and both must
/// show the daemon's answer at once rather than on the next tick — a rejected
/// mode switch would otherwise stay visibly flipped.
pub(crate) fn reload_sync_pages(ui: &Rc<Ui>) {
match ui.stack.visible_child_name().as_deref() {
Some("devices") => load_devices(ui),
Some("locations") => refresh_locations(ui),
_ => {}
}
}
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy. /// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) { pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
let win = ui_window(ui); let win = ui_window(ui);
@@ -790,7 +811,7 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
dialog.present(win.as_ref()); dialog.present(win.as_ref());
} }
/// Run a mutation raised from the Devices page and reload the page on success. /// Run a mutation raised from Computers or Locations and reload the page on success.
pub(crate) fn run_devices_mutation( pub(crate) fn run_devices_mutation(
ui: &Rc<Ui>, ui: &Rc<Ui>,
req: Request, req: Request,
@@ -806,7 +827,7 @@ pub(crate) fn run_devices_mutation(
ui.busy_end(); ui.busy_end();
match result { match result {
Ok(Ok(Response::Ok { .. })) => { Ok(Ok(Response::Ok { .. })) => {
load_devices(&ui); reload_sync_pages(&ui);
toast(&ui, &done); toast(&ui, &done);
} }
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
+43 -7
View File
@@ -327,8 +327,8 @@ pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
} }
/// The primary mount's only control: where it lives. Changing it rewrites config /// The primary mount's only control: where it lives. Changing it rewrites config
/// and offers a service restart, which is why it stays a single prompt shared /// and offers a service restart, which is why it goes through the shared
/// with the (now removed) Settings row. /// [`prompt_mountpoint`] dialog.
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) { fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
let change = gtk4::Button::builder() let change = gtk4::Button::builder()
.label("Change") .label("Change")
@@ -341,8 +341,8 @@ fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
row.add_suffix(&change); row.add_suffix(&change);
} }
/// Sync now (mirror only), the on-demand switch, and Remove — the same three /// Sync now (mirror only), the on-demand switch, and Stop syncing for one
/// controls, and the same handlers, the Computers page used to carry. /// synced folder.
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) { fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
if spec.mode != MountMode::OnDemand { if spec.mode != MountMode::OnDemand {
let sync_now = gtk4::Button::builder() let sync_now = gtk4::Button::builder()
@@ -371,14 +371,23 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
.active(target == MountMode::OnDemand) .active(target == MountMode::OnDemand)
.build(); .build();
let ui_mode = ui.clone(); let ui_mode = ui.clone();
let mode_path = spec.local_path.clone();
ondemand.connect_state_set(move |_, on| { ondemand.connect_state_set(move |_, on| {
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" }); if on {
glib::Propagation::Proceed // Going on-demand deletes the local copies, so it is asked for rather
// than done on a flick of the switch. Stop leaves the switch's state
// alone; the next repaint paints whatever the daemon then reports.
confirm_ondemand(&ui_mode, id, &mode_path);
glib::Propagation::Stop
} else {
set_sync_folder_mode(&ui_mode, id, "mirror");
glib::Propagation::Proceed
}
}); });
row.add_suffix(&ondemand); row.add_suffix(&ondemand);
let remove = gtk4::Button::builder() let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic") .icon_name("media-playback-stop-symbolic")
.tooltip_text("Stop syncing this folder") .tooltip_text("Stop syncing this folder")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
@@ -392,6 +401,33 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
row.add_suffix(&remove); row.add_suffix(&remove);
} }
/// Ask before switching a synced folder to on-demand: the switch frees disk
/// space by removing the local copies, which is not something to do by accident.
fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) {
let dialog = adw::AlertDialog::builder()
.heading("Make Folder On-Demand?")
.body(format!(
"Files in {path} will be removed from this computer and kept in Proton \
Drive only. Each file downloads again when you open it."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("ondemand", "Make On-Demand");
dialog.set_response_appearance("ondemand", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let win = ui_window(ui);
let ui = ui.clone();
dialog.connect_response(None, move |_, response| {
if response == "ondemand" {
set_sync_folder_mode(&ui, id, "ondemand");
} else {
refresh_locations(&ui);
}
});
dialog.present(win.as_ref());
}
/// Ask the daemon for an immediate pass over one folder. /// Ask the daemon for an immediate pass over one folder.
fn sync_folder_now(ui: &Rc<Ui>, id: i64) { fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) }); let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
+20 -10
View File
@@ -268,17 +268,27 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
dialog.present(parent.as_ref()); dialog.present(parent.as_ref());
} }
/// Connect the sign-out button: disable+stop the mount service (so the daemon /// Connect the sign-out button: after a confirmation, disable+stop the mount
/// isn't left running without credentials), forget the stored session, and drop /// service (so the daemon isn't left running without credentials), forget the
/// back to the login page. /// stored session, and drop back to the login page.
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: &gtk4::Button) { pub(crate) fn wire_logout(ui: &Rc<Ui>, button: &gtk4::Button) {
let ui = ui.clone(); let ui = ui.clone();
button.connect_clicked(move |_| { button.connect_clicked(move |btn| {
service::disable_stop(); let ui = ui.clone();
if let Err(e) = auth::logout() { confirm_destructive(
tracing::error!("logout failed: {e}"); btn,
} "Sign Out?",
*ui.session.borrow_mut() = None; "Proton Drive will disconnect and stop syncing until you sign in again. \
refresh(&ui); Files already on this computer stay where they are.",
"Sign Out",
move || {
service::disable_stop();
if let Err(e) = auth::logout() {
tracing::error!("logout failed: {e}");
}
*ui.session.borrow_mut() = None;
refresh(&ui);
},
);
}); });
} }
@@ -592,8 +592,12 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
let info_map = gtk4::Button::builder() let info_map = gtk4::Button::builder()
.label("Show on map") .child(
.icon_name("map-symbolic") &adw::ButtonContent::builder()
.label("Show on map")
.icon_name("map-symbolic")
.build(),
)
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.build(); .build();
info_map.add_css_class("pill"); info_map.add_css_class("pill");
+9 -2
View File
@@ -423,9 +423,16 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
back.add_css_class("flat"); back.add_css_class("flat");
back.add_css_class("circular"); back.add_css_class("circular");
// `label` and `icon_name` both replace a button's child, so the pair needs
// an `adw::ButtonContent` to show together.
let upload = gtk4::Button::builder() let upload = gtk4::Button::builder()
.label("Upload") .child(
.icon_name("list-add-symbolic") &adw::ButtonContent::builder()
.label("Upload")
.icon_name("list-add-symbolic")
.build(),
)
.tooltip_text("Upload photos")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
upload.add_css_class("pill"); upload.add_css_class("pill");
+11 -2
View File
@@ -541,8 +541,17 @@ pub(crate) fn repaint_shared(
}); });
let ui_rej = ui.clone(); let ui_rej = ui.clone();
let id_rej = inv.id.clone(); let id_rej = inv.id.clone();
reject.connect_clicked(move |_| { let name_rej = inv.name.clone().unwrap_or_else(|| "this item".into());
respond_invitation(&ui_rej, &id_rej, false); reject.connect_clicked(move |btn| {
let ui = ui_rej.clone();
let id = id_rej.clone();
confirm_destructive(
btn,
"Reject Invitation?",
&format!("You won't have access to {name_rej} unless it is shared again."),
"Reject",
move || respond_invitation(&ui, &id, false),
);
}); });
row.add_suffix(&accept); row.add_suffix(&accept);
row.add_suffix(&reject); row.add_suffix(&reject);
+2 -26
View File
@@ -113,8 +113,6 @@ pub(crate) struct MainWidgets {
/// which is where the folder is changed. /// which is where the folder is changed.
pub(crate) mountpoint_row: adw::ActionRow, pub(crate) mountpoint_row: adw::ActionRow,
pub(crate) mountpoint_button: gtk4::Button, pub(crate) mountpoint_button: gtk4::Button,
/// Opens the Google Photos Takeout import page.
pub(crate) import_row: adw::ActionRow,
} }
/// The main (logged-in) page: a libadwaita settings surface — account header, /// The main (logged-in) page: a libadwaita settings surface — account header,
@@ -244,22 +242,6 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
mountpoint_row.add_suffix(&mountpoint_button); mountpoint_row.add_suffix(&mountpoint_button);
system_group.add(&mountpoint_row); system_group.add(&mountpoint_row);
// Import: a one-off migration rather than a setting, so it is a doorway to
// its own page rather than controls inlined here — staging a set of archives
// and watching an hours-long upload needs the room.
let import_group = adw::PreferencesGroup::builder()
.title("Import")
.description("Bring a photo library from another service into Proton Photos.")
.build();
let import_row = adw::ActionRow::builder()
.title("Import from Google Photos")
.subtitle("Add a Google Takeout export to your timeline.")
.activatable(true)
.build();
import_row.add_prefix(&gtk4::Image::from_icon_name("folder-download-symbolic"));
import_row.add_suffix(&gtk4::Image::from_icon_name("go-next-symbolic"));
import_group.add(&import_row);
// Pins group: filled in by refresh. // Pins group: filled in by refresh.
let pins_group = adw::PreferencesGroup::builder() let pins_group = adw::PreferencesGroup::builder()
.title("Pinned files") .title("Pinned files")
@@ -292,7 +274,6 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
inner.append(&transfers_group); inner.append(&transfers_group);
inner.append(&storage_group); inner.append(&storage_group);
inner.append(&system_group); inner.append(&system_group);
inner.append(&import_group);
inner.append(&pins_group); inner.append(&pins_group);
inner.append(&dev_group); inner.append(&dev_group);
@@ -320,7 +301,6 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
purge_button, purge_button,
mountpoint_row, mountpoint_row,
mountpoint_button, mountpoint_button,
import_row,
}, },
) )
} }
@@ -346,7 +326,6 @@ pub(crate) fn wire_settings(
ui: &Rc<Ui>, ui: &Rc<Ui>,
purge_button: &gtk4::Button, purge_button: &gtk4::Button,
mountpoint_button: &gtk4::Button, mountpoint_button: &gtk4::Button,
import_row: &adw::ActionRow,
) { ) {
let config = ui.dirs.load_config(); let config = ui.dirs.load_config();
@@ -430,10 +409,6 @@ pub(crate) fn wire_settings(
// local path; this button is the way there. // local path; this button is the way there.
let ui_mp = ui.clone(); let ui_mp = ui.clone();
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations")); mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
// Import: a doorway, not a setting — everything about it lives on its page.
let ui_import = ui.clone();
import_row.connect_activated(move |_| ui_import.stack.set_visible_child_name("takeout"));
} }
/// Run a settings control-socket round-trip (budget / purge) on a worker thread, /// Run a settings control-socket round-trip (budget / purge) on a worker thread,
@@ -693,6 +668,7 @@ pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
ui.details.details.rename_button.set_sensitive(mounted); ui.details.details.rename_button.set_sensitive(mounted);
ui.details.details.trash_button.set_sensitive(mounted); ui.details.details.trash_button.set_sensitive(mounted);
ui.details.details.open_button.set_sensitive(mounted); ui.details.details.open_button.set_sensitive(mounted);
ui.details.details.versions_button.set_sensitive(mounted);
// Only notify on a real edge, and never for the first reading: at startup the // Only notify on a real edge, and never for the first reading: at startup the
// service is usually still coming up, and "disconnected" would be a lie. // service is usually still coming up, and "disconnected" would be a lie.
@@ -1000,7 +976,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
let unpin = gtk4::Button::builder() let unpin = gtk4::Button::builder()
.icon_name("user-trash-symbolic") .icon_name("user-trash-symbolic")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.tooltip_text("Unpin (remove offline copy)") .tooltip_text("Make online only")
.sensitive(mounted) .sensitive(mounted)
.build(); .build();
unpin.add_css_class("flat"); unpin.add_css_class("flat");
+15 -11
View File
@@ -1,8 +1,8 @@
//! The Import page: bringing a Google Photos **Takeout** export into Proton //! The Import page: bringing a Google Photos **Takeout** export into Proton
//! Photos. //! Photos.
//! //!
//! Reached from Settings → *Import from Google Photos* (and from the Gallery's //! Reached from the Photos page's import button (and its empty timeline),
//! import button), because it is a one-off migration rather than something the //! because it is a one-off migration rather than something the
//! photo timeline does every day. It gets a page of its own instead of a dialog //! photo timeline does every day. It gets a page of its own instead of a dialog
//! for two reasons: an export is a *set* of archives that has to be staged and //! for two reasons: an export is a *set* of archives that has to be staged and
//! checked before anything is sent, and the import that follows runs for hours, //! checked before anything is sent, and the import that follows runs for hours,
@@ -126,11 +126,11 @@ pub(crate) struct TakeoutWidgets {
} }
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) { pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// Header: a back button out to Settings, since this page is reached from // Header: a back button out to Photos, since this page is reached from
// there and has no sidebar row of its own to navigate back with. // there and has no sidebar row of its own to navigate back with.
let back_button = gtk4::Button::builder() let back_button = gtk4::Button::builder()
.icon_name("go-previous-symbolic") .icon_name("go-previous-symbolic")
.tooltip_text("Back to Settings") .tooltip_text("Back to Photos")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
back_button.add_css_class("flat"); back_button.add_css_class("flat");
@@ -386,9 +386,10 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Import this export?") .heading("Import this export?")
.body(format!( .body(format!(
"{count} archive(s) will be uploaded to Proton Photos, creating albums as they \ "{} will be uploaded to Proton Photos, creating albums as they appear in \
appear in the export. This can take hours; you can stop it at any point and \ the export. This can take hours; you can stop it at any point and what has \
what has been uploaded stays. Photos you already have are skipped.", been uploaded stays. Photos you already have are skipped.",
count_noun(count, "archive", "archives")
)) ))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", "Cancel");
@@ -429,7 +430,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
let ui_back = ui.clone(); let ui_back = ui.clone();
widgets widgets
.back_button .back_button
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("main")); .connect_clicked(move |_| ui_back.stack.set_visible_child_name("gallery"));
// Restore whatever was staged when the window last closed, before the first // Restore whatever was staged when the window last closed, before the first
// paint, so the page comes up as the user left it. // paint, so the page comes up as the user left it.
@@ -473,7 +474,10 @@ pub(crate) fn add_archives(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
"Only Takeout .zip files can be imported — extracted folders aren't supported.", "Only Takeout .zip files can be imported — extracted folders aren't supported.",
); );
} else if added > 0 { } else if added > 0 {
toast(ui, &format!("Added {added} archive(s)")); toast(
ui,
&format!("Added {}", count_noun(added, "archive", "archives")),
);
} }
} }
@@ -526,8 +530,8 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
rows.push(row.upcast()); rows.push(row.upcast());
} }
ui.takeout.list_group.set_description(Some(&format!( ui.takeout.list_group.set_description(Some(&format!(
"{} archive(s), {} in total. {EMPTY_LIST_DESCRIPTION}", "{}, {} in total. {EMPTY_LIST_DESCRIPTION}",
staged.len(), count_noun(staged.len(), "archive", "archives"),
human_bytes(total) human_bytes(total)
))); )));
} }
+2 -1
View File
@@ -378,7 +378,8 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Empty Trash") .heading("Empty Trash")
.body(format!( .body(format!(
"Permanently delete all {count} item(s) in the trash? This cannot be undone." "Permanently delete all {} in the trash? This cannot be undone.",
count_noun(count as usize, "item", "items")
)) ))
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", "Cancel");
+5 -2
View File
@@ -89,7 +89,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let offline = adw::PreferencesGroup::new(); let offline = adw::PreferencesGroup::new();
let pin_row = adw::SwitchRow::builder() let pin_row = adw::SwitchRow::builder()
.title("Available offline") .title("Available offline")
.subtitle("Keep a local copy on this device.") .subtitle("Keep a copy on this computer, even without a connection.")
.build(); .build();
offline.add(&pin_row); offline.add(&pin_row);
@@ -208,7 +208,7 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
ui.details.details.trash_button.connect_clicked(move |_| { ui.details.details.trash_button.connect_clicked(move |_| {
let entry = ui_trash.details.details_entry.borrow().clone(); let entry = ui_trash.details.details_entry.borrow().clone();
if let Some(entry) = entry { if let Some(entry) = entry {
prompt_delete(&ui_trash, &entry); trash_entry(&ui_trash, &entry);
} }
}); });
let ui_pin = ui.clone(); let ui_pin = ui.clone();
@@ -256,7 +256,10 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
} else { } else {
parent parent
}); });
// Pinning is per file: the context menu and the selection bar offer it for
// files only, so the pane must not offer it for folders either.
d.pin_row.set_active(entry.pinned); d.pin_row.set_active(entry.pinned);
d.pin_row.set_visible(!entry.is_dir);
d.pin_row.set_sensitive(*ui.mounted.borrow()); d.pin_row.set_sensitive(*ui.mounted.borrow());
d.open_button d.open_button
.set_label(if entry.is_dir { "Open folder" } else { "Open" }); .set_label(if entry.is_dir { "Open folder" } else { "Open" });
+41 -16
View File
@@ -361,13 +361,24 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
let state_rm = state.clone(); let state_rm = state.clone();
let id = entry.id.clone(); let id = entry.id.clone();
let kind = entry.kind; let kind = entry.kind;
remove.connect_clicked(move |_| { let who = entry.email.clone();
share_dialog_op( remove.connect_clicked(move |btn| {
&state_rm, let state = state_rm.clone();
state_rm.target.remove_entry(id.clone(), kind), let id = id.clone();
"Access removed", confirm_destructive(
"Couldn't remove access", btn,
None, "Remove Access?",
&format!("{who} will no longer be able to open this item."),
"Remove",
move || {
share_dialog_op(
&state,
state.target.remove_entry(id.clone(), kind),
"Access removed",
"Couldn't remove access",
None,
);
},
); );
}); });
row.add_suffix(&remove); row.add_suffix(&remove);
@@ -390,9 +401,12 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
Some(link) => { Some(link) => {
let url = link.url.clone().unwrap_or_default(); let url = link.url.clone().unwrap_or_default();
let subtitle = if link.has_password { let subtitle = if link.has_password {
format!("Anyone with the link ({}) · password-protected", link.role) format!(
"Anyone with the link ({}) · password-protected",
capitalize(&link.role)
)
} else { } else {
format!("Anyone with the link ({})", link.role) format!("Anyone with the link ({})", capitalize(&link.role))
}; };
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(if url.is_empty() { "Public link" } else { &url }) .title(if url.is_empty() { "Public link" } else { &url })
@@ -424,13 +438,24 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
remove.add_css_class("flat"); remove.add_css_class("flat");
let state_rm = state.clone(); let state_rm = state.clone();
let id = link.id.clone(); let id = link.id.clone();
remove.connect_clicked(move |_| { remove.connect_clicked(move |btn| {
share_dialog_op( let state = state_rm.clone();
&state_rm, let id = id.clone();
state_rm.target.remove_link(id.clone()), confirm_destructive(
"Public link removed", btn,
"Couldn't remove the link", "Remove Public Link?",
None, "The link stops working for everyone who has it. A new link will have \
a different address.",
"Remove Link",
move || {
share_dialog_op(
&state,
state.target.remove_link(id.clone()),
"Public link removed",
"Couldn't remove the link",
None,
);
},
); );
}); });
row.add_suffix(&remove); row.add_suffix(&remove);
@@ -182,16 +182,20 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
.filter(|size| *size >= 0) .filter(|size| *size >= 0)
.unwrap_or(item.size_on_storage) .unwrap_or(item.size_on_storage)
.max(0) as u64; .max(0) as u64;
let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size)); // A revision id means nothing to a person; name earlier versions by
// when they were saved instead.
let (title, mut subtitle) = if item.is_active {
(
"Current version".to_string(),
format!("{} · {}", activity_time(item.created), human_bytes(size)),
)
} else {
(activity_time(item.created), human_bytes(size))
};
if let Some(email) = &item.signed_by { if let Some(email) = &item.signed_by {
subtitle.push_str(" · "); subtitle.push_str(" · ");
subtitle.push_str(email); subtitle.push_str(email);
} }
let title = if item.is_active {
"Current version".to_string()
} else {
format!("Version {}", item.id)
};
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(title) .title(title)
.subtitle(subtitle) .subtitle(subtitle)
+1
View File
@@ -142,6 +142,7 @@ mod tests {
size: 0, size: 0,
modified: 0, modified: 0,
pinned: false, pinned: false,
cached: false,
uid: "uid".into(), uid: "uid".into(),
mounted_path: None, mounted_path: None,
score: 0, score: 0,
+1
View File
@@ -268,6 +268,7 @@ mod tests {
size: 7, size: 7,
modified: 9, modified: 9,
pinned: false, pinned: false,
cached: false,
uid: "uid~1".into(), uid: "uid~1".into(),
mounted_path: None, mounted_path: None,
score: 42, score: 42,
+2
View File
@@ -908,6 +908,7 @@ impl Ui {
size: 0, size: 0,
modified: 0, modified: 0,
pinned: true, pinned: true,
cached: false,
uid: pin.uid, uid: pin.uid,
mounted_path: None, mounted_path: None,
score: 0, score: 0,
@@ -1613,6 +1614,7 @@ mod tests {
size: 1, size: 1,
modified: 1, modified: 1,
pinned: false, pinned: false,
cached: false,
uid: "v~a".into(), uid: "v~a".into(),
mounted_path: None, mounted_path: None,
score: 100, score: 100,
+2
View File
@@ -45,6 +45,7 @@ pub(crate) fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
size: 0, size: 0,
modified: 0, modified: 0,
pinned: true, pinned: true,
cached: false,
uid: pin.uid, uid: pin.uid,
mounted_path: None, mounted_path: None,
score: 0, score: 0,
@@ -178,6 +179,7 @@ mod tests {
size: 0, size: 0,
modified: 0, modified: 0,
pinned: false, pinned: false,
cached: false,
uid: "uid".into(), uid: "uid".into(),
mounted_path: None, mounted_path: None,
score: 0, score: 0,