file manager
git clone https://git.lucas.co/cce-files.git
Right-click a graph node for the same menu as a Space tile
The Space tile menu's body becomes path_menu, shared by graph_node_menu,
which maps a node to its path by the order populate_graph lays them in:
the parent, the directory showing (no Open), then the entries. Only a
node inside the pane answers, since a panned graph runs on under the
breadcrumb and the preview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 4 +--
src/main.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 85 insertions(+), 19 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 47905d6..e0594cd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,7 +49,7 @@ There are no composite container widgets. Every widget is owned outright by the
### Three pages, one preview
- **Browse** — the `List` widget (columnar, integrated search box) plus a `Breadcrumb`. The right pane is a `Preview` widget, split from the list by `SplitPane` — app-owned (`main.rs`), carrying the `SplitBox` two-child horizontal math verbatim plus the divider quad, its hover tint, and the proportion drag.
-- **Network** — a `Graph` view of the same directory (nodes = entries), also split against the preview. It uses the configured lattice and node body as the designer's network does (a node is CENTRED on a crossing, its name hangs off its right side), laid out to fit its pane by `NetworkState::lay_out`: the pitch across holds a body, the name's gap and the widest name (cut with an ellipsis past `NAME_MAX`), as many columns as fit, column 0 a margin inside the pane, re-laid out when the pane changes size. The graph is cut to its pane (`PageContent::clipped_to`) for when it is panned. Until 2026-10-06 it set the retired cell model (140 x 70 cells, origin 60, 60, seven columns), which the lattice turned into nodes left of the pane, past its right edge, and names lying across their neighbours. `the_graph_fits_its_pane_and_its_names_fit_their_columns` is the test. Its wires and ports draw since 2026-10-06: `render_widget` hands a widget's strokes, arcs and discs to `RenderTarget::line` / `arc` / `circle` (cce-ui c9b073a), which `PageContent` keeps as `strokes` and `rebuild_layout` paints as `WidgetFx::Stroke`, cut by a clip as a glyph is. The nodes wear no geometry toggle (`Graph::set_show_toggles(false)`). Nodes stand from column and row 1, the lattice's (0, 0) a pitch outside the pane, so its heavy axes do not lie over column 0's wires. A stroke's page-view clip limits it top and bottom only: clipped to its own one-pixel box across, a vertical wire on a half pixel rounded away and only the horizontal runs drew.
+- **Network** — a `Graph` view of the same directory (nodes = entries), also split against the preview. It uses the configured lattice and node body as the designer's network does (a node is CENTRED on a crossing, its name hangs off its right side), laid out to fit its pane by `NetworkState::lay_out`: the pitch across holds a body, the name's gap and the widest name (cut with an ellipsis past `NAME_MAX`), as many columns as fit, column 0 a margin inside the pane, re-laid out when the pane changes size. The graph is cut to its pane (`PageContent::clipped_to`) for when it is panned. Until 2026-10-06 it set the retired cell model (140 x 70 cells, origin 60, 60, seven columns), which the lattice turned into nodes left of the pane, past its right edge, and names lying across their neighbours. `the_graph_fits_its_pane_and_its_names_fit_their_columns` is the test. Its wires and ports draw since 2026-10-06: `render_widget` hands a widget's strokes, arcs and discs to `RenderTarget::line` / `arc` / `circle` (cce-ui c9b073a), which `PageContent` keeps as `strokes` and `rebuild_layout` paints as `WidgetFx::Stroke`, cut by a clip as a glyph is. The nodes wear no geometry toggle (`Graph::set_show_toggles(false)`). Right-clicking a node inside the pane opens the same context menu as a Space tile (`graph_node_menu` → `path_menu`), mapped by node order: the parent (when there is one), the directory showing (no Open), then the entries. Nodes stand from column and row 1, the lattice's (0, 0) a pitch outside the pane, so its heavy axes do not lie over column 0's wires. A stroke's page-view clip limits it top and bottom only: clipped to its own one-pixel box across, a vertical wire on a half pixel rounded away and only the horizontal runs drew.
- **Space** — a GrandPerspective-style treemap of the whole subtree, also split against the preview.
The active page is picked from the breadcrumb's context menu (there is no sidebar — it was removed; `has_sidebar` is hardcoded `false` — and since 2026-10-06 no view dropdown beside the breadcrumb either, which now runs the pane's full width). Right-clicking the breadcrumb opens the toolkit's shared menu (segment header + Copy Path); the app then re-shows it in place with a separator and a row per *other* view, named by `Page::view_label()` ("List"/"Graph"/"Space" — the visualization, not `Page::label()`). The rows are app-run (`BreadcrumbRow`, `breadcrumb_menu_rows`), routed in `handle_mouse_input` ahead of the toolkit's dispatch like the plate-dock rows, on press or release, and only while `context_menu::generation()` still matches the show that made them. The current view is left out rather than marked: the menu face has no radio or check glyph.
@@ -64,7 +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.
+- Right-clicking a tile opens the app's own context menu (`space_tile_menu` over the shared `path_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/main.rs b/src/main.rs
index 62d0e18..34ebe0d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -325,24 +325,15 @@ 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();
+/// The context menu for one path — a Space tile or a graph node — header
+/// first. A directory opens by navigating to it, as a double-click does;
+/// `openable` false leaves Open out (the directory already showing).
+fn path_menu(name: &str, path: std::path::PathBuf, is_dir: bool, openable: bool) -> Vec<(String, Option<Message>)> {
+ let kind = if is_dir { "Directory" } else { "File" };
+ let mut options = vec![(format!("[{kind}] {name}"), None)];
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 {
+ if is_dir {
+ if openable {
options.push(("Open".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(path)))));
}
} else {
@@ -353,6 +344,38 @@ fn space_tile_menu(tile: &pages::space::Tile) -> Vec<(String, Option<Message>)>
options
}
+/// The Space map's context menu for `tile`. 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>)> {
+ if tile.rest > 0 {
+ let path = tile.path.clone();
+ return vec![
+ (tile.name.clone(), None),
+ ("Open Folder".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(path.clone())))),
+ ("Copy Folder Path".to_string(), Some(Message::CopyPath(path.to_string_lossy().into_owned()))),
+ ];
+ }
+ path_menu(&tile.name, tile.path.clone(), tile.is_dir, tile.depth > 0)
+}
+
+/// The graph's context menu for node `idx`: the parent (when there is one),
+/// then the directory showing, then `entries` in order — the order
+/// `NetworkState::populate_graph` lays them in.
+fn graph_node_menu(idx: usize, current_dir: &std::path::Path, entries: &[pages::browse::DirEntry]) -> Option<Vec<(String, Option<Message>)>> {
+ let dir_name = |p: &std::path::Path| p.file_name().map_or_else(|| "/".to_string(), |n| n.to_string_lossy().into_owned());
+ let parent = current_dir.parent();
+ let offset = if parent.is_some() { 2 } else { 1 };
+ match (parent, idx) {
+ (Some(parent), 0) => Some(path_menu(&dir_name(parent), parent.to_path_buf(), true, true)),
+ _ if idx + 1 == offset => Some(path_menu(&dir_name(current_dir), current_dir.to_path_buf(), true, false)),
+ _ => {
+ let entry = entries.get(idx.checked_sub(offset)?)?;
+ Some(path_menu(&entry.name, entry.path.clone(), entry.is_dir, true))
+ }
+ }
+}
+
/// 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,
@@ -2500,6 +2523,21 @@ impl Application for FilesystemApp {
*needs_rebuild = true;
self.needs_rebuild = true;
return None;
+ } else if let Some(options) = (self.current_page == Page::Network)
+ .then(|| {
+ // Only a node inside the pane: a panned graph's nodes
+ // run on under the breadcrumb and the preview.
+ let (x, y, w, h) = self.network.laid_out_for;
+ let inside = pos.x >= x && pos.x < x + w && pos.y >= y && pos.y < y + h;
+ inside.then(|| self.network.graph.inner().node_at(pos.x, pos.y)).flatten()
+ })
+ .flatten()
+ .and_then(|idx| graph_node_menu(idx, &self.browse.current_dir, &self.browse.entries))
+ {
+ 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
@@ -3166,4 +3204,32 @@ mod tests {
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"));
}
+
+ #[test]
+ fn a_graph_node_menu_follows_the_node_order() {
+ let entry = |name: &str, is_dir| pages::browse::DirEntry {
+ name: name.to_string(),
+ path: std::path::PathBuf::from("/home/me").join(name),
+ is_dir,
+ size: 0,
+ permissions: 0,
+ modified: String::new(),
+ origin: None,
+ };
+ let entries = [entry("docs", true), entry("a.txt", false)];
+ let dir = std::path::Path::new("/home/me");
+ let labels = |idx| graph_node_menu(idx, dir, &entries).map(|m| m.into_iter().map(|(l, _)| l).collect::<Vec<_>>());
+ assert_eq!(labels(0).unwrap(), ["[Directory] home", "Open", "Copy Path"]);
+ assert_eq!(labels(1).unwrap(), ["[Directory] me", "Copy Path"]);
+ assert_eq!(labels(2).unwrap(), ["[Directory] docs", "Open", "Copy Path"]);
+ assert_eq!(labels(3).unwrap(), ["[File] a.txt", "Open", "Open with...", "Copy Path"]);
+ assert!(labels(4).is_none());
+
+ let menu = graph_node_menu(3, dir, &entries).unwrap();
+ assert!(matches!(&menu.last().unwrap().1, Some(Message::CopyPath(p)) if p == "/home/me/a.txt"));
+ // At the root there is no parent node: node 0 is the root itself.
+ let root = graph_node_menu(0, std::path::Path::new("/"), &[]).unwrap();
+ assert_eq!(root[0].0, "[Directory] /");
+ assert_eq!(root.len(), 2);
+ }
}