git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commitee49aa3ee9269fb23071ad158efe3a96c21fb326
parentc9bcb2b095
authorLucas Galante <[email protected]>
date2026-08-04 22:36
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);
+    }
+}