mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
added trash
This commit is contained in:
1 parent
30039c43d7
commit
c52d829a43
6 files changed
+683
-14
No files matched your search
Generated
+4
-4
@@ -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",
|
||||
|
||||
+1
-1
@@ -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 <contact@narl.io>"]
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<String>) -> 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<String>) -> 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)?,
|
||||
|
||||
@@ -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<String> },
|
||||
/// Permanently delete trashed nodes by uid. Irreversible: the content is
|
||||
/// gone from Proton Drive, not moved. Replies with [`Response::Ok`].
|
||||
DeleteForever { uids: Vec<String> },
|
||||
/// 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<Pin> },
|
||||
/// 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<DirEntry> },
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<DirEntry>, 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<Vec<NodeUid>, 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<usize, String> {
|
||||
let parsed = Self::parse_uids(uids)?;
|
||||
let parents: Vec<NodeUid> = 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<usize, String> {
|
||||
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<usize, String> {
|
||||
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<String, String> {
|
||||
@@ -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 {
|
||||
|
||||
+423
-6
@@ -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<Option<glib::SourceId>>,
|
||||
// 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<Ui>) {
|
||||
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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>) {
|
||||
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<Ui>, entry: &DirEntry) {
|
||||
let parent = ui_window(ui);
|
||||
@@ -3229,6 +3266,386 @@ fn repaint_search(ui: &Rc<Ui>, 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::<BoxedAnyObject>();
|
||||
|
||||
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<Ui>, 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::<gtk4::ListItem>().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::<gtk4::ListItem>().unwrap();
|
||||
let Some(entry) = bound_entry(item) else {
|
||||
return;
|
||||
};
|
||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let Some(icon) = row.first_child().and_downcast::<gtk4::Image>() else {
|
||||
return;
|
||||
};
|
||||
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
|
||||
let Some(text) = icon.next_sibling().and_downcast::<gtk4::Box>() else {
|
||||
return;
|
||||
};
|
||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
||||
name.set_label(&entry.name);
|
||||
}
|
||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
||||
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<DirEntry> {
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>()?;
|
||||
let entry = obj.borrow::<DirEntry>().clone();
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Fetch the trash listing and repaint the page.
|
||||
fn load_trash(ui: &Rc<Ui>) {
|
||||
// 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<Ui>) {
|
||||
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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>) {
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user