fix(ui) improved ux

This commit is contained in:
nvrl committed 2026-08-18 12:50:49 +02:00
1 parent b2d5870834
commit 8bd9481902
10 files changed
+733 -78

No files matched your search

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