From c52d829a43c56d4ec1c61fd67de6440c83d681e5 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 14 Jul 2026 14:55:54 +0200 Subject: [PATCH] added trash --- Cargo.lock | 8 +- Cargo.toml | 2 +- crates/pdfs-cli/src/main.rs | 82 ++++++ crates/pdfs-core/src/control.rs | 46 +++- crates/pdfs-fuse/src/lib.rs | 130 ++++++++++ crates/pdfs-gui/src/app.rs | 429 +++++++++++++++++++++++++++++++- 6 files changed, 683 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4fdceee..fefe2ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2898,7 +2898,7 @@ dependencies = [ [[package]] name = "pdfs-cli" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "clap 4.6.1", @@ -2913,7 +2913,7 @@ dependencies = [ [[package]] name = "pdfs-core" -version = "0.3.0" +version = "0.4.0" dependencies = [ "directories", "ignore", @@ -2931,7 +2931,7 @@ dependencies = [ [[package]] name = "pdfs-fuse" -version = "0.3.0" +version = "0.4.0" dependencies = [ "fuser", "libc", @@ -2944,7 +2944,7 @@ dependencies = [ [[package]] name = "pdfs-gui" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index b0fb87d..4fb6eb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2024" license = "MIT" authors = ["Nils Pukropp "] diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 72bd803..4db566f 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -122,6 +122,23 @@ enum Command { }, /// Show the daemon's in-flight transfers (active uploads/downloads). Transfers, + /// List what is in the account's trash, with the uids the restore and + /// delete-forever commands take. + Trash, + /// Restore trashed items, by uid, to the folders they were trashed from. + Restore { + /// Node uids (`volume~link`), as printed by `pdfs trash`. + #[arg(required = true)] + uids: Vec, + }, + /// Permanently delete trashed items by uid. This cannot be undone. + DeleteForever { + /// Node uids (`volume~link`), as printed by `pdfs trash`. + #[arg(required = true)] + uids: Vec, + }, + /// Permanently delete everything in the trash. This cannot be undone. + EmptyTrash, } fn main() -> Result<()> { @@ -155,6 +172,10 @@ fn main() -> Result<()> { Command::Mkdir { parent, name } => cmd_mkdir(parent, name), Command::Upload { file, parent } => cmd_upload(file, parent), Command::Transfers => cmd_transfers(), + Command::Trash => cmd_trash(), + Command::Restore { uids } => cmd_restore(uids), + Command::DeleteForever { uids } => cmd_delete_forever(uids), + Command::EmptyTrash => cmd_empty_trash(), } } @@ -501,6 +522,67 @@ fn cmd_rm(path: PathBuf) -> Result<()> { Ok(()) } +fn cmd_trash() -> Result<()> { + match control_request(CtlRequest::ListTrash)? { + CtlResponse::Entries { entries } if entries.is_empty() => println!("(trash is empty)"), + CtlResponse::Entries { entries } => { + for e in entries { + let kind = if e.is_dir { "d" } else { "-" }; + println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_restore(uids: Vec) -> Result<()> { + match control_request(CtlRequest::Restore { uids })? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_delete_forever(uids: Vec) -> Result<()> { + confirm(&format!( + "Permanently delete {} item(s)? This cannot be undone.", + uids.len() + ))?; + match control_request(CtlRequest::DeleteForever { uids })? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_empty_trash() -> Result<()> { + confirm("Permanently delete everything in the trash? This cannot be undone.")?; + match control_request(CtlRequest::EmptyTrash)? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +/// Ask for a typed `yes` before an irreversible destroy. Errors (aborting the +/// command) on anything else, including a non-interactive stdin: a piped or +/// scripted invocation must not silently wipe the trash. +fn confirm(prompt: &str) -> Result<()> { + print!("{prompt} [type \"yes\" to confirm] "); + std::io::stdout().flush()?; + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + if answer.trim() != "yes" { + bail!("aborted"); + } + Ok(()) +} + fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> { match control_request(CtlRequest::CreateFolder { parent: path_arg(&parent)?, diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 52de4bf..f3ae755 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -103,6 +103,20 @@ pub enum Request { /// Replies with [`Response::Transfers`]. Cheap to poll: the daemon keeps the /// registry in memory, so a front-end can render a live progress widget. GetQueueStatus, + /// List what is in the account's trash. Replies with [`Response::Entries`]; + /// a trashed node has no path inside the mount, so each entry carries only + /// its `uid` — the handle for [`Request::Restore`] and + /// [`Request::DeleteForever`] — and its `path` is empty. + ListTrash, + /// Restore trashed nodes, by uid, to the folders they were trashed from. + /// Replies with [`Response::Ok`]. + Restore { uids: Vec }, + /// Permanently delete trashed nodes by uid. Irreversible: the content is + /// gone from Proton Drive, not moved. Replies with [`Response::Ok`]. + DeleteForever { uids: Vec }, + /// Permanently delete everything in the trash. Irreversible. + /// Replies with [`Response::Ok`]. + EmptyTrash, } /// Which way an active transfer is moving, in a [`TransferItem`]. @@ -150,8 +164,10 @@ pub struct DirEntry { pub uid: String, /// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing /// (the entry lives in the requested directory, so the caller derives the - /// path by joining its name); populated when an entry can live anywhere in - /// the tree, as for search hits rendered through the browser. + /// path by joining its name) and for a [`Request::ListTrash`] listing (a + /// trashed node has no path inside the mount at all); populated when an entry + /// can live anywhere in the tree, as for search hits rendered through the + /// browser. #[serde(default)] pub path: String, } @@ -229,7 +245,8 @@ pub enum Response { Ok { message: String }, /// The pin registry. Pins { pins: Vec }, - /// A directory listing (reply to [`Request::ListDir`]). + /// A directory listing (reply to [`Request::ListDir`]) or a trash listing + /// (reply to [`Request::ListTrash`]). Entries { entries: Vec }, /// A page of the photos timeline. `available` is false when the account /// has no photos volume. @@ -309,4 +326,27 @@ mod tests { assert_eq!(line, serde_json::to_string(&back).unwrap()); } } + + /// The trash requests carry uids rather than paths; they must survive the same + /// line-delimited round-trip, since a mangled uid would restore or destroy the + /// wrong node. + #[test] + fn trash_requests_roundtrip() { + let reqs = [ + Request::ListTrash, + Request::Restore { + uids: vec!["vol~link".into(), "vol~other".into()], + }, + Request::DeleteForever { + uids: vec!["vol~link".into()], + }, + Request::EmptyTrash, + ]; + for req in reqs { + let line = serde_json::to_string(&req).unwrap(); + assert!(!line.contains('\n'), "wire form must be a single line"); + let back: Request = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&back).unwrap()); + } + } } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 2c878c2..0590c1d 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -1303,6 +1303,114 @@ impl Core { Ok(name) } + /// List the account's trash. Trashed nodes are outside the mounted tree — the + /// FUSE side forgot them when they were trashed — so this always goes to the + /// server rather than to `State` or the DB, and each entry is identified by + /// its uid (its only remaining handle) with an empty path. + fn list_trash(&self) -> Result, String> { + let uids = self + .rt + .block_on(self.client.enumerate_trash_node_uids()) + .map_err(|e| format!("enumerate trash: {e}"))?; + if uids.is_empty() { + return Ok(Vec::new()); + } + let nodes = self + .rt + .block_on(self.client.enumerate_nodes(&uids)) + .map_err(|e| format!("enumerate nodes: {e}"))?; + Ok(nodes + .into_iter() + .map(|node| DirEntry { + name: node.name.clone(), + is_dir: node.is_folder(), + size: node_size(&node), + modified: node.modification_time, + // A trashed node can't be pinned or served from the mount, so its + // content is never current cache: report neither. + pinned: false, + cached: false, + uid: node.uid.to_string(), + path: String::new(), + }) + .collect()) + } + + /// Parse wire uids (`volume~link`) into [`NodeUid`]s, rejecting the whole + /// batch if any is malformed — a partial trash mutation is worse than none. + fn parse_uids(uids: &[String]) -> Result, String> { + if uids.is_empty() { + return Err("no nodes given".to_string()); + } + uids.iter() + .map(|u| parse_uid(u).ok_or_else(|| format!("invalid uid: {u}"))) + .collect() + } + + /// Restore trashed nodes to the folders they were trashed from. The parents + /// are read *before* the restore — a restored node reappears in a listing the + /// daemon may already have cached, so each destination folder is invalidated + /// and re-enumerated on next access. + fn restore(&self, uids: &[String]) -> Result { + let parsed = Self::parse_uids(uids)?; + let parents: Vec = self + .rt + .block_on(self.client.enumerate_nodes(&parsed)) + .map_err(|e| format!("enumerate nodes: {e}"))? + .into_iter() + .filter_map(|n| n.parent_uid) + .collect(); + self.rt + .block_on(self.client.restore_nodes(&parsed)) + .map_err(|e| format!("restore: {e}"))?; + let mut st = self.state.lock().unwrap(); + for parent in parents { + if let Some(&ino) = st.by_uid.get(&parent) { + st.invalidate_listing(ino); + } + } + Ok(parsed.len()) + } + + /// Permanently delete trashed nodes. Irreversible on the server; locally it + /// drops any metadata and cached content the node still owns. + fn delete_forever(&self, uids: &[String]) -> Result { + let parsed = Self::parse_uids(uids)?; + self.rt + .block_on(self.client.delete_nodes(&parsed)) + .map_err(|e| format!("delete: {e}"))?; + self.drop_local(&parsed); + Ok(parsed.len()) + } + + /// Permanently delete everything in the trash. The uids are listed first so + /// the blobs of items trashed by *another* client — which this daemon may + /// still hold in its cache — are reclaimed too, not just the ones it trashed. + fn empty_trash(&self) -> Result { + let uids = self + .rt + .block_on(self.client.enumerate_trash_node_uids()) + .map_err(|e| format!("enumerate trash: {e}"))?; + self.rt + .block_on(self.client.empty_trash()) + .map_err(|e| format!("empty trash: {e}"))?; + self.drop_local(&uids); + Ok(uids.len()) + } + + /// Forget every trace of nodes that no longer exist anywhere: their inode and + /// DB row, and their cached content. + fn drop_local(&self, uids: &[NodeUid]) { + let mut st = self.state.lock().unwrap(); + for uid in uids { + st.forget(uid); + } + drop(st); + for uid in uids { + self.cache.evict(uid); + } + } + /// Create a folder named `name` under the mountpoint-relative `parent_rel`. /// Interns the new node directly so it shows up without a re-enumeration. fn create_folder(&self, parent_rel: &Path, name: &str) -> Result { @@ -2646,6 +2754,28 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::Error { message: e }, }, + Ok(CtlRequest::ListTrash) => match core.list_trash() { + Ok(entries) => CtlResponse::Entries { entries }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::Restore { uids }) => match core.restore(&uids) { + Ok(n) => CtlResponse::Ok { + message: format!("restored {n} item(s)"), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::DeleteForever { uids }) => match core.delete_forever(&uids) { + Ok(n) => CtlResponse::Ok { + message: format!("permanently deleted {n} item(s)"), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::EmptyTrash) => match core.empty_trash() { + Ok(n) => CtlResponse::Ok { + message: format!("emptied trash ({n} item(s))"), + }, + Err(e) => CtlResponse::Error { message: e }, + }, Ok(CtlRequest::PurgeCache) => { let freed = core.cache.clear_unpinned(); CtlResponse::Ok { diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs index 36cf042..d22ac8a 100644 --- a/crates/pdfs-gui/src/app.rs +++ b/crates/pdfs-gui/src/app.rs @@ -223,6 +223,16 @@ struct Ui { /// Pending debounce timer for the search box; replaced on every keystroke so /// only the last pause actually fires a [`Request::Search`]. search_source: RefCell>, + // Trash page. Trashed nodes are addressed by uid, not by path, so this page + // keeps no current-directory state — it re-lists from the daemon on show. + trash_model: gio::ListStore, + trash_content: gtk4::Stack, + trash_status: adw::StatusPage, + trash_retry: gtk4::Button, + /// Empties the trash; insensitive while it is empty (or unread). + trash_empty: gtk4::Button, + /// "12 items" under the page title. + trash_subtitle: gtk4::Label, // Photos (gallery) page. /// Every photo loaded so far, newest first — the order the lightbox's /// prev/next walks. The visible day sections are derived from this. @@ -486,10 +496,12 @@ fn build_window(app: &adw::Application) { let (main_page, main_widgets) = build_main_page(); let (browser_page, browser_widgets) = build_browser_page(); let (gallery_page, gallery_widgets) = build_gallery_page(); + let (trash_page, trash_widgets) = build_trash_page(); stack.add_named(&login_page, Some("login")); stack.add_named(&main_page, Some("main")); stack.add_named(&browser_page, Some("browser")); stack.add_named(&gallery_page, Some("gallery")); + stack.add_named(&trash_page, Some("trash")); // Sidebar: the signed-in destinations. Selecting a row swaps the page stack; // `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g. @@ -571,6 +583,12 @@ fn build_window(app: &adw::Application) { browser_new_folder: browser_widgets.new_folder.clone(), browser_upload: browser_widgets.upload.clone(), search_source: RefCell::new(None), + trash_model: trash_widgets.model.clone(), + trash_content: trash_widgets.content.clone(), + trash_status: trash_widgets.status.clone(), + trash_retry: trash_widgets.retry.clone(), + trash_empty: trash_widgets.empty.clone(), + trash_subtitle: trash_widgets.subtitle.clone(), gallery_model: gallery_widgets.model.clone(), gallery_groups: gallery_widgets.groups.clone(), gallery_tile: Cell::new(TILE_DEFAULT), @@ -611,6 +629,7 @@ fn build_window(app: &adw::Application) { wire_details(&ui); wire_search(&ui); wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll); + wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty); wire_retry(&ui); // Lazily load the Files / Photos pages the first time they're shown, so the @@ -621,6 +640,7 @@ fn build_window(app: &adw::Application) { match st.visible_child_name().as_deref() { Some("browser") => load_browser(&ui_nav), Some("gallery") => load_gallery(&ui_nav, false), + Some("trash") => load_trash(&ui_nav), _ => {} } }); @@ -658,9 +678,10 @@ fn build_window(app: &adw::Application) { /// The sidebar destinations, in order: the row index is the index into this table, /// and each entry is `(stack page name, label, icon)`. -const DESTINATIONS: [(&str, &str, &str); 3] = [ +const DESTINATIONS: [(&str, &str, &str); 4] = [ ("browser", "Files", "folder-symbolic"), ("gallery", "Photos", "image-x-generic-symbolic"), + ("trash", "Trash", "user-trash-symbolic"), ("main", "Settings", "emblem-system-symbolic"), ]; @@ -1425,6 +1446,11 @@ fn wire_retry(ui: &Rc) { service::restart(); load_gallery(&ui_gallery, false); }); + let ui_trash = ui.clone(); + ui.trash_retry.clone().connect_clicked(move |_| { + service::restart(); + load_trash(&ui_trash); + }); } /// Repaint the window from the cached login identity, then kick an async mount- @@ -2670,10 +2696,11 @@ fn wire_browser_actions(ui: &Rc, new_folder: >k4::Button, upload: >k4::B upload.connect_clicked(move |_| prompt_upload(&ui_up)); } -/// Send a mutating browser request (rename / move / delete / mkdir / upload) on a -/// worker thread, then reload the current listing and confirm with a toast, or -/// report the daemon's error in one. `done` is the past-tense confirmation -/// ("Renamed to “x”"); `failed` names the attempt ("Couldn't rename"). +/// Send a mutating request (rename / move / delete / mkdir / upload, or a trash +/// restore / purge) on a worker thread, then reload the listing it changed and +/// confirm with a toast, or report the daemon's error in one. `done` is the +/// past-tense confirmation ("Renamed to “x”"); `failed` names the attempt +/// ("Couldn't rename"). fn run_mutation(ui: &Rc, req: Request, done: String, failed: &'static str) { if !*ui.mounted.borrow() { toast_error(ui, failed, "Proton Drive isn't connected."); @@ -2689,7 +2716,7 @@ fn run_mutation(ui: &Rc, req: Request, done: String, failed: &'static str) { Ok(Ok(Response::Ok { .. })) => { // The listing the mutation changed is stale now; reload it, then // confirm, so the toast lands over the updated view. - load_browser(&ui); + reload_listing(&ui); toast(&ui, &done); } Ok(Ok(Response::Error { message })) => toast_error(&ui, failed, &message), @@ -2698,6 +2725,16 @@ fn run_mutation(ui: &Rc, req: Request, done: String, failed: &'static str) { }); } +/// Reload the listing a completed mutation invalidated: whichever of the two +/// listing pages is on screen, since that is the one the action was raised from. +fn reload_listing(ui: &Rc) { + match ui.stack.visible_child_name().as_deref() { + Some("browser") => load_browser(ui), + Some("trash") => load_trash(ui), + _ => {} + } +} + /// Prompt for a new name and rename the entry through the daemon. fn prompt_rename(ui: &Rc, entry: &DirEntry) { let parent = ui_window(ui); @@ -3229,6 +3266,386 @@ fn repaint_search(ui: &Rc, hits: &[SearchHit]) { } } +/// The widgets of the Trash page that a load repaints. +struct TrashWidgets { + model: gio::ListStore, + list: gtk4::ListView, + content: gtk4::Stack, + status: adw::StatusPage, + retry: gtk4::Button, + empty: gtk4::Button, + subtitle: gtk4::Label, +} + +/// The Trash page: a flat list of everything Drive is holding in the trash, each +/// row offering Restore and Delete Forever, with Empty Trash in the header. +/// +/// A trashed node has no path inside the mount — the daemon forgets it when it is +/// trashed — so unlike the Files page this one addresses entries by uid and always +/// re-lists from the server rather than from a cached listing. Row rendering needs +/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`]. +fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { + let model = gio::ListStore::new::(); + + let title = gtk4::Label::builder() + .label("Trash") + .halign(gtk4::Align::Start) + .build(); + title.add_css_class("title-2"); + let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + subtitle.add_css_class("dim-label"); + let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + titles.set_hexpand(true); + titles.append(&title); + titles.append(&subtitle); + + let empty = gtk4::Button::builder() + .label("Empty Trash") + .valign(gtk4::Align::Center) + .sensitive(false) + .build(); + empty.add_css_class("destructive-action"); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.append(&titles); + header.append(&empty); + + let retry = gtk4::Button::builder() + .label("Retry") + .halign(gtk4::Align::Center) + .build(); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_visible(false); + let status = adw::StatusPage::builder() + .icon_name("user-trash-symbolic") + .vexpand(true) + .child(&retry) + .build(); + status.add_css_class("compact"); + + // No selection model: every action lives on the row it acts on, so a + // mis-aimed Delete Forever isn't one click away from a stale selection. + let list = gtk4::ListView::builder() + .model(>k4::NoSelection::new(Some(model.clone()))) + .build(); + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&list) + .build(); + + let content = gtk4::Stack::new(); + content.set_vexpand(true); + content.set_transition_type(gtk4::StackTransitionType::Crossfade); + content.add_named(&scroll, Some("list")); + content.add_named(&status, Some("status")); + + 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(&content); + + ( + inner.upcast(), + TrashWidgets { + model, + list, + content, + status, + retry, + empty, + subtitle, + }, + ) +} + +/// Install the row factory and the Empty Trash button. The row's two buttons read +/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a +/// captured copy, so a recycled row always acts on the item it currently shows. +fn wire_trash(ui: &Rc, list: >k4::ListView, empty: >k4::Button) { + let factory = gtk4::SignalListItemFactory::new(); + let ui_setup = ui.clone(); + factory.connect_setup(move |_, item| { + let item = item.downcast_ref::().unwrap(); + + let icon = gtk4::Image::builder().pixel_size(32).build(); + let name = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::Middle) + .build(); + let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + meta.add_css_class("dim-label"); + meta.add_css_class("caption"); + let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + text.set_hexpand(true); + text.set_valign(gtk4::Align::Center); + text.append(&name); + text.append(&meta); + + let restore = gtk4::Button::builder() + .icon_name("edit-undo-symbolic") + .tooltip_text("Restore to its original folder") + .valign(gtk4::Align::Center) + .build(); + restore.add_css_class("flat"); + let purge = gtk4::Button::builder() + .icon_name("edit-delete-symbolic") + .tooltip_text("Delete permanently") + .valign(gtk4::Align::Center) + .build(); + purge.add_css_class("flat"); + + let ui_restore = ui_setup.clone(); + let item_restore = item.clone(); + restore.connect_clicked(move |_| { + if let Some(entry) = bound_entry(&item_restore) { + restore_entry(&ui_restore, &entry); + } + }); + let ui_purge = ui_setup.clone(); + let item_purge = item.clone(); + purge.connect_clicked(move |_| { + if let Some(entry) = bound_entry(&item_purge) { + prompt_delete_forever(&ui_purge, &entry); + } + }); + + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + row.set_margin_top(6); + row.set_margin_bottom(6); + row.set_margin_start(6); + row.set_margin_end(6); + row.append(&icon); + row.append(&text); + row.append(&restore); + row.append(&purge); + item.set_child(Some(&row)); + }); + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().unwrap(); + let Some(entry) = bound_entry(item) else { + return; + }; + let row = item.child().and_downcast::().unwrap(); + let Some(icon) = row.first_child().and_downcast::() else { + return; + }; + icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); + let Some(text) = icon.next_sibling().and_downcast::() else { + return; + }; + if let Some(name) = text.first_child().and_downcast::() { + name.set_label(&entry.name); + } + if let Some(meta) = text.last_child().and_downcast::() { + let kind = if entry.is_dir { + "Folder".to_string() + } else { + human_bytes(entry.size) + }; + meta.set_label(&format!("{kind} · {}", format_modified(entry.modified))); + } + }); + list.set_factory(Some(&factory)); + + let ui_empty = ui.clone(); + empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty)); +} + +/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound +/// (recycled) row. +fn bound_entry(item: >k4::ListItem) -> Option { + let obj = item.item().and_downcast::()?; + let entry = obj.borrow::().clone(); + Some(entry) +} + +/// Fetch the trash listing and repaint the page. +fn load_trash(ui: &Rc) { + // Drop the old rows first: a stale row here would offer Restore on something + // that may already be gone. + ui.trash_model.remove_all(); + ui.trash_empty.set_sensitive(false); + trash_status( + ui, + "user-trash-symbolic", + "Loading…", + "Reading the trash.", + false, + ); + + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + match result { + Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries), + Ok(Ok(Response::Error { message })) => trash_status( + &ui, + "dialog-warning-symbolic", + "Couldn't read the trash", + &message, + false, + ), + Ok(Ok(_)) => trash_status( + &ui, + "dialog-warning-symbolic", + "Couldn't read the trash", + "Unexpected reply from the mount service.", + false, + ), + Ok(Err(_)) | Err(_) => trash_unreachable(&ui), + } + }); +} + +/// The daemon didn't answer. Same split as the Files page: still starting (poll +/// again, no button) versus actually down (Retry, which restarts the service). +fn trash_unreachable(ui: &Rc) { + if service::is_failed() || !service::is_active() { + trash_status( + ui, + "network-offline-symbolic", + "Not connected", + "The Proton Drive mount service isn't running.", + true, + ); + return; + } + trash_status( + ui, + "folder-remote-symbolic", + "Connecting…", + "Waiting for the Proton Drive mount service to come up.", + false, + ); + let ui = ui.clone(); + glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { + if ui.stack.visible_child_name().as_deref() == Some("trash") { + load_trash(&ui); + } + }); +} + +/// Show a status page in place of the trash list. +fn trash_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { + ui.trash_status.set_icon_name(Some(icon)); + ui.trash_status.set_title(title); + ui.trash_status.set_description(Some(description)); + ui.trash_retry.set_visible(retry); + ui.trash_content.set_visible_child_name("status"); + ui.trash_subtitle.set_label(""); +} + +/// Repopulate the trash list, most recently modified first — the order in which a +/// user looks for what they just deleted. +fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { + ui.trash_model.remove_all(); + ui.trash_empty.set_sensitive(!entries.is_empty()); + if entries.is_empty() { + trash_status( + ui, + "user-trash-symbolic", + "Trash is empty", + "Items you delete from Proton Drive show up here.", + false, + ); + return; + } + ui.trash_content.set_visible_child_name("list"); + ui.trash_subtitle.set_label(&match entries.len() { + 1 => "1 item".to_string(), + n => format!("{n} items"), + }); + + let mut sorted = entries.to_vec(); + sorted.sort_by_key(|e| std::cmp::Reverse(e.modified)); + for entry in sorted { + ui.trash_model.append(&BoxedAnyObject::new(entry)); + } +} + +/// Restore one trashed entry to the folder it was trashed from. +fn restore_entry(ui: &Rc, entry: &DirEntry) { + let name = entry.name.clone(); + run_mutation( + ui, + Request::Restore { + uids: vec![entry.uid.clone()], + }, + format!("Restored “{name}”"), + "Couldn't restore", + ); +} + +/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks. +fn prompt_delete_forever(ui: &Rc, entry: &DirEntry) { + let win = ui_window(ui); + let uid = entry.uid.clone(); + let name = entry.name.clone(); + let dialog = adw::AlertDialog::builder() + .heading("Delete permanently") + .body(format!( + "Permanently delete “{name}”? This cannot be undone." + )) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("delete", "Delete Permanently"); + dialog.set_response_appearance("delete", 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 == "delete" { + run_mutation( + &ui, + Request::DeleteForever { + uids: vec![uid.clone()], + }, + format!("Deleted “{name}” permanently"), + "Couldn't delete", + ); + } + }); + dialog.present(win.as_ref()); +} + +/// Confirm, then permanently delete everything in the trash. +fn prompt_empty_trash(ui: &Rc) { + let win = ui_window(ui); + let count = ui.trash_model.n_items(); + let dialog = adw::AlertDialog::builder() + .heading("Empty Trash") + .body(format!( + "Permanently delete all {count} item(s) in the trash? This cannot be undone." + )) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("empty", "Empty Trash"); + dialog.set_response_appearance("empty", 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 == "empty" { + run_mutation( + &ui, + Request::EmptyTrash, + "Trash emptied".to_string(), + "Couldn't empty the trash", + ); + } + }); + dialog.present(win.as_ref()); +} + /// One day-section of the photos timeline: a heading plus the photos captured /// that day, in timeline order. Built from the flat [`Ui::gallery_model`] by /// [`group_photos`] and rendered as one [`gtk4::ListView`] row.