file manager
git clone https://git.lucas.co/cce-files.git
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(