file manager
git clone https://git.lucas.co/cce-files.git
Right-click a Space tile for a context menu with Copy Path
The map's tiles open the app's context menu: Open and Open with... for a
file, Open (re-root the map) for a directory, and Copy Path for both. A
"smaller items" block acts on its directory and says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 1 +
src/lib.rs | 2 ++
src/main.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 67 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 2c37cca..47905d6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,6 +64,7 @@ Unlike the other two pages, Space needs data no other page has: the recursive si
Two things to know when touching it:
- Tiles are flattened **parents-before-children**, so the hit-test is `rposition` (last match = deepest tile). The same order finds the faint frame drawn round the hovered tile's top-level folder: `top_folder` takes the nearest depth-1 tile *before* it, which in a depth-first list is its ancestor.
- Selection is held as a `PathBuf`, not an index, because a relayout renumbers every tile. Same reason `last_space_path` (not a row index) drives Space's double-click detection.
+- Right-clicking a tile opens the app's own context menu (`space_tile_menu`, routed beside the Browse row menu in `handle_mouse_input`): a header, then Open / Open with... for a file, Open for a directory (re-roots the map, as a double-click does; the root tile offers none), and Copy Path. A "smaller items" block acts on the directory it carries, so its rows say Folder.
## Domain specifics
diff --git a/src/lib.rs b/src/lib.rs
index de020bc..dc0e442 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -19,4 +19,6 @@ pub enum Message {
PromptSubmit,
PromptCancel,
CopyPath(String),
+ /// Open a file with its default handler.
+ OpenFile(std::path::PathBuf),
}
diff --git a/src/main.rs b/src/main.rs
index 61a5f96..62d0e18 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -325,6 +325,34 @@ fn breadcrumb_menu_rows(path: String, current: Page) -> (Vec<String>, Vec<Option
(labels, rows)
}
+/// The Space map's context menu for `tile`, header first. A directory opens
+/// by re-rooting the map, as a double-click does; the root tile is the map's
+/// own directory, so it offers nothing to open. A "smaller items" block
+/// carries its directory's path, so its rows name the folder they act on.
+fn space_tile_menu(tile: &pages::space::Tile) -> Vec<(String, Option<Message>)> {
+ let path = tile.path.clone();
+ let copy = Some(Message::CopyPath(path.to_string_lossy().into_owned()));
+ if tile.rest > 0 {
+ return vec![
+ (tile.name.clone(), None),
+ ("Open Folder".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(path)))),
+ ("Copy Folder Path".to_string(), copy),
+ ];
+ }
+ let kind = if tile.is_dir { "Directory" } else { "File" };
+ let mut options = vec![(format!("[{kind}] {}", tile.name), None)];
+ if tile.is_dir {
+ if tile.depth > 0 {
+ options.push(("Open".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(path)))));
+ }
+ } else {
+ options.push(("Open".to_string(), Some(Message::OpenFile(path.clone()))));
+ options.push(("Open with...".to_string(), Some(Message::PromptOpenWith(path))));
+ }
+ options.push(("Copy Path".to_string(), copy));
+ options
+}
+
/// App-owned two-pane horizontal split, replacing the dissolved `SplitBox` +
/// `BrowseContainer`/`NetworkContainer` shims (Phase 6y). Those existed to (a) position
/// pane content — but the pages already lay out and render everything from the pane rect,
@@ -1802,6 +1830,9 @@ impl Application for FilesystemApp {
Message::CopyPath(path) => {
cce_ui::widget::clipboard::copy_to_clipboard(&path);
}
+ Message::OpenFile(path) => {
+ services::fs::open_file(&path);
+ }
}
}
@@ -2459,6 +2490,16 @@ impl Application for FilesystemApp {
self.needs_rebuild = true;
return None;
}
+ } else if let Some(tile) = (self.current_page == Page::Space)
+ .then(|| self.space.tile_at(pos.x, pos.y))
+ .flatten()
+ .and_then(|idx| self.space.tiles.get(idx))
+ {
+ let options = space_tile_menu(tile);
+ self.open_context_menu(pos.x, pos.y, options);
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ return None;
} else if self.current_page == Page::Browse
&& self.browse.list.hit(pos.x, pos.y)
&& pos.y <= self.browse.list.y + self.browse.list.viewport_h
@@ -3102,4 +3143,27 @@ mod tests {
assert!(!rows.contains(&Some(BreadcrumbRow::View(page))), "{page:?} offers itself");
}
}
+
+ #[test]
+ fn a_space_tile_menu_copies_its_path() {
+ let tile = |name: &str, is_dir, depth, rest| pages::space::Tile {
+ path: std::path::PathBuf::from("/root").join(name),
+ name: name.to_string(),
+ size: 1,
+ is_dir,
+ depth,
+ rect: (0.0, 0.0, 10.0, 10.0),
+ aggregate: None,
+ rest,
+ };
+ let labels = |t| space_tile_menu(&t).into_iter().map(|(l, _)| l).collect::<Vec<_>>();
+ assert_eq!(labels(tile("a.txt", false, 2, 0)), ["[File] a.txt", "Open", "Open with...", "Copy Path"]);
+ assert_eq!(labels(tile("sub", true, 1, 0)), ["[Directory] sub", "Open", "Copy Path"]);
+ assert_eq!(labels(tile("here", true, 0, 0)), ["[Directory] here", "Copy Path"]);
+ assert_eq!(labels(tile("3 smaller items", true, 2, 3)), ["3 smaller items", "Open Folder", "Copy Folder Path"]);
+
+ let menu = space_tile_menu(&tile("a.txt", false, 2, 0));
+ assert!(menu[0].1.is_none(), "the header does nothing");
+ assert!(matches!(&menu.last().unwrap().1, Some(Message::CopyPath(p)) if p == "/root/a.txt"));
+ }
}