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

commit71a36d24efb5e5f7ce182765791109422a7cce41
parentee49aa3ee9
authorLucas Galante <[email protected]>
date2026-08-05 08:09
feat: trash view shows each item's original location

DirEntry grows an origin field, read from the .trashinfo sidecars by
read_directory_internal (trash listings only). In the trash the
size/permissions/modified columns yield to an Original Location column;
origins are front-truncated (leading …) since the deep end of the path
is the informative end, undershooting the list's own tail-truncation
estimate so it never re-truncates.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/pages/browse.rs   | 47 ++++++++++++++++++++++++++++++++++++++++-------
 src/pages/network.rs  |  3 +++
 src/services/fs.rs    |  7 +++++++
 src/services/trash.rs | 25 +++++++++++++++++++------
 4 files changed, 69 insertions(+), 13 deletions(-)

diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index f6432b8..7677982 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -14,6 +14,9 @@ pub struct DirEntry {
     pub size: u64,
     pub permissions: u32,
     pub modified: String,
+    /// For trash listings only: the path this item restores to, read from its
+    /// .trashinfo by `read_directory_internal`. None everywhere else.
+    pub origin: Option<String>,
 }
 
 #[derive(Debug, Clone)]
@@ -210,9 +213,12 @@ pub fn view(state: &mut BrowseState, view_dropdown: &mut cce_ui::widget::Adapted
     let (list_x, list_y, list_w, list_h) = layout.allocate(client_w, list_h_val);
 
     // Update List columns dynamically based on list width
-    let show_size = list_w > 400.0;
-    let show_perm = list_w > 480.0;
-    let show_modified = list_w > 280.0;
+    let in_trash = crate::services::trash::is_trash_files_dir(&state.current_dir);
+    let show_size = !in_trash && list_w > 400.0;
+    let show_perm = !in_trash && list_w > 480.0;
+    let show_modified = !in_trash && list_w > 280.0;
+    // Trash listing: the metadata columns yield to where the item restores to.
+    let origin_col_w = if in_trash { (list_w * 0.55).min(460.0).max(160.0) } else { 0.0 };
 
     let mut cols = vec![
         crate::row_list::ListColumn {
@@ -221,6 +227,13 @@ pub fn view(state: &mut BrowseState, view_dropdown: &mut cce_ui::widget::Adapted
             justification: cce_ui::widget::Justification::Left,
         }
     ];
+    if in_trash {
+        cols.push(crate::row_list::ListColumn {
+            name: "Original Location".to_string(),
+            width: crate::row_list::ColumnWidth::RightOffset(origin_col_w),
+            justification: cce_ui::widget::Justification::Left,
+        });
+    }
     if show_size {
         cols.push(crate::row_list::ListColumn {
             name: "Size".to_string(),
@@ -254,6 +267,22 @@ pub fn view(state: &mut BrowseState, view_dropdown: &mut cce_ui::widget::Adapted
         let perm_str = format_permissions(entry.permissions);
 
         let mut cells = vec![entry.name.clone()];
+        if in_trash {
+            // Front-truncate: the deep end of the path is the informative end,
+            // and the list's own truncation cuts the tail. Undershoot the
+            // list's secondary-cell char estimate so it never re-truncates.
+            let origin = entry.origin.as_deref().unwrap_or("—");
+            let cell_size = (cce_ui::layout::list_font_parsed().1 - 1.0).max(8.0);
+            let budget = (((origin_col_w - 8.0) / (cell_size * 0.65)) as usize).saturating_sub(1).max(4);
+            let n = origin.chars().count();
+            let cell = if n > budget {
+                let tail: String = origin.chars().skip(n - budget.saturating_sub(1)).collect();
+                format!("…{tail}")
+            } else {
+                origin.to_string()
+            };
+            cells.push(cell);
+        }
         if show_size {
             cells.push(size_str);
         }
@@ -465,6 +494,7 @@ mod tests {
                     size: 1024 * i as u64,
                     permissions: 0o644,
                     modified: String::new(),
+                    origin: None,
                 })
                 .collect(),
             ..BrowseState::default()
@@ -498,8 +528,8 @@ mod tests {
     fn apply_filters_hides_dotfiles() {
         let mut state = BrowseState::default();
         state.all_entries = vec![
-            DirEntry { name: ".hidden".into(), path: PathBuf::from("/a/.hidden"), is_dir: false, size: 0, permissions: 0o644, modified: String::new() },
-            DirEntry { name: "visible".into(), path: PathBuf::from("/a/visible"), is_dir: false, size: 0, permissions: 0o644, modified: String::new() },
+            DirEntry { name: ".hidden".into(), path: PathBuf::from("/a/.hidden"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
+            DirEntry { name: "visible".into(), path: PathBuf::from("/a/visible"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
         ];
         state.show_hidden = false;
         apply_filters(&mut state);
@@ -511,8 +541,8 @@ mod tests {
     fn apply_filters_shows_dotfiles_when_enabled() {
         let mut state = BrowseState::default();
         state.all_entries = vec![
-            DirEntry { name: ".hidden".into(), path: PathBuf::from("/a/.hidden"), is_dir: false, size: 0, permissions: 0o644, modified: String::new() },
-            DirEntry { name: "visible".into(), path: PathBuf::from("/a/visible"), is_dir: false, size: 0, permissions: 0o644, modified: String::new() },
+            DirEntry { name: ".hidden".into(), path: PathBuf::from("/a/.hidden"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
+            DirEntry { name: "visible".into(), path: PathBuf::from("/a/visible"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
         ];
         state.show_hidden = true;
         apply_filters(&mut state);
@@ -527,6 +557,7 @@ mod tests {
             size: 0,
             permissions: 0o644,
             modified: String::new(),
+            origin: None,
         }
     }
 
@@ -771,6 +802,7 @@ mod tests {
                     size: 19,
                     permissions: 0o644,
                     modified: String::new(),
+                    origin: None,
                 }
             ],
             entries: vec![
@@ -781,6 +813,7 @@ mod tests {
                     size: 19,
                     permissions: 0o644,
                     modified: String::new(),
+                    origin: None,
                 }
             ],
             selected: Some(0),
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 4db55ed..20bd733 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -185,6 +185,7 @@ mod tests {
                 size: 100,
                 permissions: 0o644,
                 modified: String::new(),
+                origin: None,
             },
             DirEntry {
                 name: "subdir".to_string(),
@@ -193,6 +194,7 @@ mod tests {
                 size: 4096,
                 permissions: 0o755,
                 modified: String::new(),
+                origin: None,
             },
         ];
 
@@ -236,6 +238,7 @@ mod tests {
                 size: 4096,
                 permissions: 0o755,
                 modified: String::new(),
+                origin: None,
             },
         ];
 
diff --git a/src/services/fs.rs b/src/services/fs.rs
index 1ac1109..755425c 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -150,6 +150,7 @@ impl FsService {
 // ── Internal Helper Functions ───────────────────────────────────────
 
 pub fn read_directory_internal(path: &Path) -> Vec<DirEntry> {
+    let in_trash = super::trash::is_trash_files_dir(path);
     let mut entries: Vec<DirEntry> = match fs::read_dir(path) {
         Ok(rd) => rd
             .filter_map(|e| e.ok())
@@ -169,6 +170,11 @@ pub fn read_directory_internal(path: &Path) -> Vec<DirEntry> {
                         Some(datetime.format("%Y-%m-%d %H:%M").to_string())
                     })
                     .unwrap_or_else(|| "—".to_string());
+                let origin = if in_trash {
+                    super::trash::origin_of(&e.path()).map(|p| p.display().to_string())
+                } else {
+                    None
+                };
                 Some(DirEntry {
                     name,
                     path: e.path(),
@@ -176,6 +182,7 @@ pub fn read_directory_internal(path: &Path) -> Vec<DirEntry> {
                     size,
                     permissions,
                     modified,
+                    origin,
                 })
             })
             .collect(),
diff --git a/src/services/trash.rs b/src/services/trash.rs
index 1ef1644..d9da41d 100644
--- a/src/services/trash.rs
+++ b/src/services/trash.rs
@@ -105,6 +105,24 @@ fn move_to_trash_in(base: &Path, path: &Path) -> io::Result<()> {
     }
 }
 
+/// The origin a trashed item (a path under `files/`) restores to, from its
+/// .trashinfo — the trash listing shows this. None when the sidecar is
+/// missing or malformed.
+pub fn origin_of(trashed: &Path) -> Option<PathBuf> {
+    let base = trash_dir()?;
+    let name = trashed.file_name()?.to_string_lossy().to_string();
+    read_origin(&base.join("info").join(format!("{name}.trashinfo"))).ok()
+}
+
+fn read_origin(info_path: &Path) -> io::Result<PathBuf> {
+    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"))?;
+    Ok(PathBuf::from(percent_decode(encoded)))
+}
+
 fn restore_in(base: &Path, trashed: &Path) -> io::Result<PathBuf> {
     let name = trashed
         .file_name()
@@ -112,12 +130,7 @@ fn restore_in(base: &Path, trashed: &Path) -> io::Result<PathBuf> {
         .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));
+    let origin = read_origin(&info_path)?;
 
     if origin.exists() {
         return Err(io::Error::new(