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

commitaff774318670a2b498821b98f04ac0121d2d2ca1
parent144d0f4789
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 12:32
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);
+    }
 }