file manager
git clone https://git.lucas.co/cce-files.git
feat: freedesktop trash — delete is recoverable, restore/empty from the trash view
services/trash.rs implements the Trash spec v1.0 home trash
(XDG_DATA_HOME/Trash: files/ + info/*.trashinfo, percent-encoded
origins, O_EXCL info-file name reservation, EXDEV copy fallback), unit
tested. Delete now routes to TrashPath; inside Trash/files the row menu
swaps to Restore / Delete Permanently / Empty Trash, and DeleteEntry
degrades to permanent there so a trashed row can't re-trash. Other
rows gain Open Trash for access.
Also resurrects the row context menu itself: rows stopped being
page_buttons when the List became a widget (Phase 6z), so the old
right-click button probe never fired — the menu now hit-tests the
list directly (row_at made public).
Co-Authored-By: Claude Fable 5 <[email protected]>
src/main.rs | 35 +++---
src/pages/browse.rs | 39 ++++++-
src/row_list.rs | 4 +-
src/services/fs.rs | 32 +++++
src/services/mod.rs | 1 +
src/services/trash.rs | 314 ++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 406 insertions(+), 19 deletions(-)
diff --git a/src/main.rs b/src/main.rs
index d641bb0..afab012 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1581,25 +1581,19 @@ impl Application for FilesystemApp {
}
}
- // Check if right-clicked on a list button
- let mut right_clicked_action = None;
- for (btn, action) in &self.page_buttons {
- let base = btn.base();
- if pos.x >= base.x && pos.x <= base.x + base.w && pos.y >= base.y && pos.y <= base.y + base.h {
- right_clicked_action = Some(action.clone());
- break;
- }
- }
-
- if let Some(Message::Browse(browse_action)) = right_clicked_action {
- let entry_idx = match browse_action {
- pages::browse::BrowseMessage::SelectEntry(idx) => Some(idx),
- pages::browse::BrowseMessage::NavigateTo(idx) => Some(idx),
- _ => None,
+ // Row context menu: hit-test the list directly — rows stopped being
+ // page_buttons when the List became a widget (Phase 6z), so the old
+ // button probe never fired.
+ {
+ let entry_idx = if self.current_page == Page::Browse {
+ self.browse.list.row_at(pos.x, pos.y)
+ } else {
+ None
};
if let Some(idx) = entry_idx {
if let Some(entry) = self.browse.entries.get(idx) {
+ let is_trash_dir = services::trash::is_trash_files_dir(&self.browse.current_dir);
let header = if entry.is_dir {
format!("[Directory] {}", entry.name)
} else {
@@ -1623,7 +1617,16 @@ impl Application for FilesystemApp {
options.push(("Open with...".to_string(), Some(Message::PromptOpenWith(entry.path.clone()))));
- options.push(("Delete".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::DeleteEntry(idx)))));
+ if is_trash_dir {
+ options.push(("Restore".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::RestoreEntry(idx)))));
+ options.push(("Delete Permanently".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::DeleteEntryPermanent(idx)))));
+ options.push(("Empty Trash".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::EmptyTrash))));
+ } else {
+ options.push(("Delete".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::DeleteEntry(idx)))));
+ if let Some(trash_files) = services::trash::files_dir() {
+ options.push(("Open Trash".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(trash_files)))));
+ }
+ }
// Calculate width
let (menu_w, menu_h) = context_menu_size(&options);
diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index b3104d1..f6432b8 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -82,8 +82,15 @@ pub enum BrowseMessage {
DirectoryLoaded(PathBuf, Vec<DirEntry>),
DirectoryRefreshed(PathBuf, Vec<DirEntry>),
ToggleHidden,
+ /// Delete = move to trash — except inside the trash itself, where the
+ /// update arm degrades it to a permanent delete (re-trashing a trashed
+ /// row would orphan its .trashinfo).
DeleteEntry(usize),
+ DeleteEntryPermanent(usize),
+ RestoreEntry(usize),
+ EmptyTrash,
Deleted(PathBuf, Result<(), String>),
+ TrashEmptied(Result<(), String>),
LastDirLoaded(Option<PathBuf>),
}
@@ -386,12 +393,37 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::serv
None
}
BrowseMessage::DeleteEntry(idx) => {
+ if let Some(entry) = state.entries.get(idx) {
+ if crate::services::trash::is_trash_files_dir(&state.current_dir) {
+ Some(crate::services::fs::FsRequest::DeletePath(entry.path.clone(), entry.is_dir))
+ } else {
+ Some(crate::services::fs::FsRequest::TrashPath(entry.path.clone()))
+ }
+ } else {
+ None
+ }
+ }
+ BrowseMessage::DeleteEntryPermanent(idx) => {
if let Some(entry) = state.entries.get(idx) {
Some(crate::services::fs::FsRequest::DeletePath(entry.path.clone(), entry.is_dir))
} else {
None
}
}
+ BrowseMessage::RestoreEntry(idx) => {
+ if let Some(entry) = state.entries.get(idx) {
+ Some(crate::services::fs::FsRequest::RestorePath(entry.path.clone()))
+ } else {
+ None
+ }
+ }
+ BrowseMessage::EmptyTrash => Some(crate::services::fs::FsRequest::EmptyTrash),
+ BrowseMessage::TrashEmptied(result) => {
+ if let Err(e) = result {
+ log::error!("Failed to empty trash: {}", e);
+ }
+ Some(crate::services::fs::FsRequest::ReadDirectory(state.current_dir.clone()))
+ }
BrowseMessage::Deleted(path, result) => {
match result {
Ok(_) => {
@@ -758,9 +790,12 @@ mod tests {
// Assert file exists before deletion
assert!(file_path.exists());
- // Perform update call for DeleteEntry
+ // DeleteEntry outside the trash routes to TrashPath (recoverable);
+ // DeleteEntryPermanent is the unrecoverable path.
let req = update(&mut state, BrowseMessage::DeleteEntry(0));
- assert!(matches!(req, Some(crate::services::fs::FsRequest::DeletePath(_, _))));
+ assert!(matches!(req, Some(crate::services::fs::FsRequest::TrashPath(_))));
+ let req_perm = update(&mut state, BrowseMessage::DeleteEntryPermanent(0));
+ assert!(matches!(req_perm, Some(crate::services::fs::FsRequest::DeletePath(_, _))));
// Directly delete the file to simulate the FsService action
std::fs::remove_file(&file_path).unwrap();
diff --git a/src/row_list.rs b/src/row_list.rs
index d983525..99f5567 100644
--- a/src/row_list.rs
+++ b/src/row_list.rs
@@ -130,7 +130,9 @@ impl RowList {
}
}
- fn row_at(&self, px: f32, py: f32) -> Option<usize> {
+ /// The visible row index under (px, py) — the click/hover hit-test, public
+ /// for the app's right-click row menu.
+ pub fn row_at(&self, px: f32, py: f32) -> Option<usize> {
for idx in 0..self.rows.len() {
if let Some(draw_y) = self.get_item_draw_y(idx) {
if px >= self.x + 2.0 && px <= self.x + self.w - 2.0 && py >= draw_y && py <= draw_y + self.item_height {
diff --git a/src/services/fs.rs b/src/services/fs.rs
index 09a2bcb..1ac1109 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -33,7 +33,13 @@ pub enum FsRequest {
ReadDirectory(PathBuf),
RefreshDirectory(PathBuf),
ReadPreview(PathBuf),
+ /// Move to the freedesktop trash (the default Delete).
+ TrashPath(PathBuf),
+ /// Unrecoverable delete — the trash's own rows, and "Delete Permanently".
DeletePath(PathBuf, bool), // (path, is_dir)
+ /// Restore a trashed item (a path under Trash/files) to its origin.
+ RestorePath(PathBuf),
+ EmptyTrash,
ReadLastDir,
SaveLastDir(PathBuf),
}
@@ -74,6 +80,14 @@ impl FsService {
));
});
}
+ FsRequest::TrashPath(path) => {
+ tokio::spawn(async move {
+ let result = super::trash::move_to_trash(&path).map_err(|e| e.to_string());
+ let _ = app_sender.send(crate::Message::Browse(
+ crate::pages::browse::BrowseMessage::Deleted(path, result),
+ ));
+ });
+ }
FsRequest::DeletePath(path, is_dir) => {
tokio::spawn(async move {
let res = if is_dir {
@@ -87,6 +101,24 @@ impl FsService {
));
});
}
+ FsRequest::RestorePath(path) => {
+ tokio::spawn(async move {
+ // A restored row leaves the trash listing exactly like a
+ // deleted row leaves its directory — same message.
+ let result = super::trash::restore(&path).map(|_| ()).map_err(|e| e.to_string());
+ let _ = app_sender.send(crate::Message::Browse(
+ crate::pages::browse::BrowseMessage::Deleted(path, result),
+ ));
+ });
+ }
+ FsRequest::EmptyTrash => {
+ tokio::spawn(async move {
+ let result = super::trash::empty().map(|_| ()).map_err(|e| e.to_string());
+ let _ = app_sender.send(crate::Message::Browse(
+ crate::pages::browse::BrowseMessage::TrashEmptied(result),
+ ));
+ });
+ }
FsRequest::ReadLastDir => {
tokio::spawn(async move {
let last_dir = read_last_dir_internal();
diff --git a/src/services/mod.rs b/src/services/mod.rs
index d521fbd..c557d75 100644
--- a/src/services/mod.rs
+++ b/src/services/mod.rs
@@ -1 +1,2 @@
pub mod fs;
+pub mod trash;
diff --git a/src/services/trash.rs b/src/services/trash.rs
new file mode 100644
index 0000000..1ef1644
--- /dev/null
+++ b/src/services/trash.rs
@@ -0,0 +1,314 @@
+//! The freedesktop.org Trash spec (v1.0), home-trash only: items move into
+//! `$XDG_DATA_HOME/Trash/files/` (else `~/.local/share/Trash/files/`) and each
+//! carries an `info/<name>.trashinfo` recording its origin and deletion date —
+//! so gio, trash-cli, and the KDE/GNOME file managers all see the same trash.
+//!
+//! All functions do blocking IO; they run on the FsService task like the rest
+//! of `services::fs`.
+
+use std::fs;
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+
+/// `$XDG_DATA_HOME/Trash`, else `~/.local/share/Trash`. None only when HOME
+/// is unset.
+pub fn trash_dir() -> Option<PathBuf> {
+ if let Some(data) = std::env::var_os("XDG_DATA_HOME") {
+ let data = PathBuf::from(data);
+ if data.is_absolute() {
+ return Some(data.join("Trash"));
+ }
+ }
+ std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/Trash"))
+}
+
+/// The directory trashed items live in — what the browse page navigates to.
+pub fn files_dir() -> Option<PathBuf> {
+ trash_dir().map(|t| t.join("files"))
+}
+
+/// Whether `dir` IS the trash files directory (the browse page's gate for
+/// swapping Delete → Restore/Delete Permanently in the row menu).
+pub fn is_trash_files_dir(dir: &Path) -> bool {
+ files_dir().is_some_and(|f| f == dir)
+}
+
+/// Move `path` into the trash: reserve a unique name by exclusively creating
+/// its .trashinfo, then rename (copy+delete across filesystems).
+pub fn move_to_trash(path: &Path) -> io::Result<()> {
+ let base = trash_dir()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no HOME — trash unavailable"))?;
+ move_to_trash_in(&base, path)
+}
+
+/// Restore a trashed item (a path under `files/`) to the origin its
+/// .trashinfo records. Refuses to overwrite an existing file at the origin.
+/// Returns the restored path.
+pub fn restore(trashed: &Path) -> io::Result<PathBuf> {
+ let base = trash_dir()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no HOME — trash unavailable"))?;
+ restore_in(&base, trashed)
+}
+
+/// Delete everything in the trash permanently. Returns how many items went.
+pub fn empty() -> io::Result<usize> {
+ let base = trash_dir()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no HOME — trash unavailable"))?;
+ empty_in(&base)
+}
+
+fn move_to_trash_in(base: &Path, path: &Path) -> io::Result<()> {
+ let files = base.join("files");
+ let info = base.join("info");
+ fs::create_dir_all(&files)?;
+ fs::create_dir_all(&info)?;
+
+ let orig = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
+ let name = orig
+ .file_name()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?
+ .to_string_lossy()
+ .to_string();
+
+ // Spec: DeletionDate is local time, no zone suffix.
+ let deleted_at = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
+ let body = format!(
+ "[Trash Info]\nPath={}\nDeletionDate={}\n",
+ percent_encode(&orig.to_string_lossy()),
+ deleted_at
+ );
+
+ // Reserve the trash name by O_EXCL-creating the info file: `name`, then
+ // `name.2`, `name.3`, … — the create-race is the spec's uniqueness lock.
+ let mut n = 1u32;
+ loop {
+ let candidate = if n == 1 { name.clone() } else { format!("{name}.{n}") };
+ let info_path = info.join(format!("{candidate}.trashinfo"));
+ match fs::OpenOptions::new().write(true).create_new(true).open(&info_path) {
+ Ok(mut f) => {
+ f.write_all(body.as_bytes())?;
+ let target = files.join(&candidate);
+ if let Err(e) = rename_or_copy(&orig, &target) {
+ let _ = fs::remove_file(&info_path);
+ return Err(e);
+ }
+ return Ok(());
+ }
+ Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
+ n += 1;
+ if n > 10_000 {
+ return Err(io::Error::new(io::ErrorKind::AlreadyExists, "no free trash name"));
+ }
+ }
+ Err(e) => return Err(e),
+ }
+ }
+}
+
+fn restore_in(base: &Path, trashed: &Path) -> io::Result<PathBuf> {
+ let name = trashed
+ .file_name()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?
+ .to_string_lossy()
+ .to_string();
+ let info_path = base.join("info").join(format!("{name}.trashinfo"));
+ let body = fs::read_to_string(&info_path)?;
+ let encoded = body
+ .lines()
+ .find_map(|l| l.strip_prefix("Path="))
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "trashinfo has no Path"))?;
+ let origin = PathBuf::from(percent_decode(encoded));
+
+ if origin.exists() {
+ return Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ format!("{} already exists", origin.display()),
+ ));
+ }
+ if let Some(parent) = origin.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ rename_or_copy(trashed, &origin)?;
+ fs::remove_file(&info_path)?;
+ Ok(origin)
+}
+
+fn empty_in(base: &Path) -> io::Result<usize> {
+ let mut count = 0usize;
+ let files = base.join("files");
+ if let Ok(entries) = fs::read_dir(&files) {
+ for entry in entries.flatten() {
+ let p = entry.path();
+ let removed = if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
+ fs::remove_dir_all(&p)
+ } else {
+ fs::remove_file(&p)
+ };
+ if removed.is_ok() {
+ count += 1;
+ }
+ }
+ }
+ if let Ok(entries) = fs::read_dir(base.join("info")) {
+ for entry in entries.flatten() {
+ let _ = fs::remove_file(entry.path());
+ }
+ }
+ Ok(count)
+}
+
+/// Rename, falling back to a recursive copy + delete when source and trash
+/// live on different filesystems (EXDEV).
+fn rename_or_copy(from: &Path, to: &Path) -> io::Result<()> {
+ match fs::rename(from, to) {
+ Ok(()) => Ok(()),
+ Err(e) if e.raw_os_error() == Some(libc_exdev()) => {
+ copy_recursive(from, to)?;
+ if from.is_dir() {
+ fs::remove_dir_all(from)
+ } else {
+ fs::remove_file(from)
+ }
+ }
+ Err(e) => Err(e),
+ }
+}
+
+const fn libc_exdev() -> i32 {
+ 18 // EXDEV on Linux
+}
+
+fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> {
+ if from.is_dir() {
+ fs::create_dir_all(to)?;
+ for entry in fs::read_dir(from)? {
+ let entry = entry?;
+ copy_recursive(&entry.path(), &to.join(entry.file_name()))?;
+ }
+ Ok(())
+ } else {
+ fs::copy(from, to).map(|_| ())
+ }
+}
+
+/// Percent-encode a path for a trashinfo `Path=` line: unreserved characters
+/// and `/` pass through, everything else (spaces, non-ASCII bytes, …) encodes.
+fn percent_encode(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for b in s.bytes() {
+ match b {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
+ out.push(b as char)
+ }
+ _ => out.push_str(&format!("%{b:02X}")),
+ }
+ }
+ out
+}
+
+fn percent_decode(s: &str) -> String {
+ let bytes = s.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'%' {
+ if let (Some(h), Some(l)) = (
+ bytes.get(i + 1).and_then(|b| (*b as char).to_digit(16)),
+ bytes.get(i + 2).and_then(|b| (*b as char).to_digit(16)),
+ ) {
+ out.push((h * 16 + l) as u8);
+ i += 3;
+ continue;
+ }
+ }
+ out.push(bytes[i]);
+ i += 1;
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn temp_base(tag: &str) -> PathBuf {
+ let base = std::env::temp_dir().join(format!("cce-files-trash-test-{tag}-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&base);
+ fs::create_dir_all(&base).unwrap();
+ base
+ }
+
+ #[test]
+ fn test_percent_roundtrip() {
+ let s = "/home/user/My Files/café ünï.txt";
+ let enc = percent_encode(s);
+ assert!(!enc.contains(' '));
+ assert_eq!(percent_decode(&enc), s);
+ }
+
+ #[test]
+ fn test_trash_restore_roundtrip() {
+ let base = temp_base("roundtrip");
+ let trash = base.join("Trash");
+ let victim = base.join("doomed file.txt");
+ fs::write(&victim, b"contents").unwrap();
+
+ move_to_trash_in(&trash, &victim).unwrap();
+ assert!(!victim.exists());
+ let trashed = trash.join("files/doomed file.txt");
+ assert!(trashed.exists());
+ let info = fs::read_to_string(trash.join("info/doomed file.txt.trashinfo")).unwrap();
+ assert!(info.starts_with("[Trash Info]\n"));
+ assert!(info.contains("DeletionDate="));
+
+ let restored = restore_in(&trash, &trashed).unwrap();
+ assert_eq!(restored, victim.canonicalize().unwrap_or(victim.clone()));
+ assert_eq!(fs::read(&victim).unwrap(), b"contents");
+ assert!(!trashed.exists());
+ assert!(!trash.join("info/doomed file.txt.trashinfo").exists());
+
+ let _ = fs::remove_dir_all(&base);
+ }
+
+ #[test]
+ fn test_name_collision_and_empty() {
+ let base = temp_base("collision");
+ let trash = base.join("Trash");
+ for _ in 0..3 {
+ let victim = base.join("dup.txt");
+ fs::write(&victim, b"x").unwrap();
+ move_to_trash_in(&trash, &victim).unwrap();
+ }
+ assert!(trash.join("files/dup.txt").exists());
+ assert!(trash.join("files/dup.txt.2").exists());
+ assert!(trash.join("files/dup.txt.3").exists());
+
+ // A directory victim exercises the recursive branch of empty.
+ let dir_victim = base.join("nested");
+ fs::create_dir_all(dir_victim.join("inner")).unwrap();
+ fs::write(dir_victim.join("inner/f.txt"), b"y").unwrap();
+ move_to_trash_in(&trash, &dir_victim).unwrap();
+
+ assert_eq!(empty_in(&trash).unwrap(), 4);
+ assert_eq!(fs::read_dir(trash.join("files")).unwrap().count(), 0);
+ assert_eq!(fs::read_dir(trash.join("info")).unwrap().count(), 0);
+
+ let _ = fs::remove_dir_all(&base);
+ }
+
+ #[test]
+ fn test_restore_refuses_overwrite() {
+ let base = temp_base("overwrite");
+ let trash = base.join("Trash");
+ let victim = base.join("clash.txt");
+ fs::write(&victim, b"old").unwrap();
+ move_to_trash_in(&trash, &victim).unwrap();
+ fs::write(&victim, b"new").unwrap();
+
+ let err = restore_in(&trash, &trash.join("files/clash.txt")).unwrap_err();
+ assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
+ assert_eq!(fs::read(&victim).unwrap(), b"new");
+
+ let _ = fs::remove_dir_all(&base);
+ }
+}