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

CLAUDE.md (17K)

 1 # CLAUDE.md
 2 
 3 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
 4 
 5 ## What this is
 6 
 7 `cce-files` is a Wayland-native file manager, one app in the larger **CCE** desktop-environment ecosystem (the sibling `cce-*` crates under `../`). It renders directly on a Wayland surface with raw Vulkan (via **ash**), with **cosmic-text** for text shaping — there is no GTK/Qt/web layer. All GUI primitives come from the sibling crate **`cce-ui`** (`../cce-ui`, a path dependency), which owns the windowing/event loop, the widget toolkit, layout, fonts, and colors; the rendering itself lives in `cce-ui/src/vk/` (`VkRenderer`), so this crate declares no graphics dependency of its own.
 8 
 9 ## Build / run / test
10 
11 ```sh
12 make build      # cargo build --release
13 make install    # release build, then `ccebuild install --no-build cce-files`
14 make run        # cargo run  (needs a live Wayland compositor)
15 cargo test      # run unit tests (nine modules have them; `browse.rs` has the most)
16 cargo test test_is_project_dir_detection    # run a single test by name
17 ```
18 
19 **Tests never set `HOME` / `XDG_CONFIG_HOME`.** The env is process-wide, so every test running alongside one sees the temp dir too — and cce-ui's style registry loads once per process, so a first load inside that window left the whole run on built-in defaults (that is what made the network fit test flaky). A config reader takes the dir as a parameter instead (`read_last_dir_in`, `save_last_dir_in`, `load_kdl_associations_in`), with the env-resolving wrapper beside it; tests pass a temp dir.
20 
21 Running the binary requires a Wayland session — it will not run headless. Edition is **2024**; the `cce-ui` sibling is edition 2021. When touching layout/widget behavior, the actual widget implementations live in `../cce-ui/src/widget/`, not here.
22 
23 ## Architecture
24 
25 ### Elm-style app on the cce-ui engine
26 `main.rs` defines `FilesystemApp`, which implements `cce_ui::engine::Application`. That trait drives everything through a message loop:
27 - **`Message`** (`lib.rs`) is the single top-level event enum; page-specific messages nest inside it (`Message::Browse(BrowseMessage)`, `Message::Preview(PreviewMessage)`).
28 - **`update()`** mutates state in response to a `Message` and may dispatch async work to `FsService`.
29 - **`view` / `rebuild_layout()`** produce the frame, and **`display_list()`** is the single paint path: it re-runs `rebuild_layout()` when the size, the scale, or `needs_rebuild` says to, then replays the flattened buffers into a `PaintCtx`. `display_list_text()` opts the text into the engine's shaping pass. (The legacy `view_rounded_quads()` / `text_items()` pull methods are gone.)
30 
31 Each page has a `state` struct and a `view()` that returns a `PageContent` (`pages/mod.rs`) — a flat list of rects, texts, and buttons — which `rebuild_layout` later flattens into `self.widgets` + `self.texts` for the paint path. (`PageContent` also carries `reliefs`, `grooves`, and `images` — the edge-only relief walls drawn over the flat rects, the breadcrumb's slanted seams, and GPU-textured quads.) Interactive pages also carry their own `Message` enum + `update()` (`pages/browse.rs`, `pages/preview.rs`); Network is view-only, driven directly from `BrowseState` and pointer/graph events, so it has no message type of its own.
32 
33 Shared, page-independent formatting helpers (`format_size`, `format_permissions`) live in `src/util.rs`.
34 
35 ### FsService: all filesystem IO is async and off-thread
36 `services/fs.rs` runs a Tokio task that receives `FsRequest`s (read dir, refresh, read preview, delete, load/save last dir), performs the blocking IO, and sends results back into the app as `Message`s over a `calloop::channel::Sender`. `update()` never does blocking IO directly — it sends an `FsRequest` and handles the resulting message later. A `notify` watcher (`start_watching`) debounces filesystem events and triggers `RefreshDirectory`.
37 
38 ### rebuild_layout is the render heart (main.rs)
39 `rebuild_layout()` gathers geometry from five sources — the root window, page content, popovers, the context menu, and the open-with dialog — and flattens them into `self.widgets` + `self.texts`. Two non-obvious concerns live here:
40 - **Viewport clipping**: page content is clipped to the content region so scrolled rows/text don't overflow into the breadcrumb or selection bar.
41 - **Overlay occlusion**: text/buttons under a popover, context menu, or dialog are either discarded or bound-clipped so they don't bleed through overlays. This is the logic behind commits like "Fix text rendering through popovers/overlays."
42 
43 ### Widgets register parentless, once per rebuild
44 There are no composite container widgets. Every widget is owned outright by the app or by a page's state struct — `self.paginator`, `self.browse.breadcrumb`, `self.browse.save_name_box`, `self.network.graph`, `self.space.breadcrumb`, … — and each `rebuild_layout` re-establishes the whole hierarchy from scratch: `ui_context.clear_hierarchy()`, then a teardown block calling `clear_children` + `set_parent(None)` on every widget, then registration via `ctx.register_widget(w.base().id(), w.as_ptr_mut())` with `set_parent(None)` again. Raw pointers are still involved (`as_ptr_mut`, plus a `self_ptr` alias so the registration loop can hold the app twice), but they belong to the cce-ui widget model rather than to any container of this crate's.
45 
46 **When adding a widget, add it to both halves of that pass** — the teardown block and the registration block. Skipping the teardown leaves hierarchy links alive across frames.
47 
48 (`BrowseContainer` and `NetworkContainer` were shims holding children as `*mut dyn Element`; they dissolved in Phase 6y along with the root plate container and `SplitBox`. Their positioning duplicated what the pages already computed from the pane rect — that coincidence was the Phase 0 double-paint — and the only part worth keeping, the divider, became `SplitPane`. See the comment above `SplitPane` in `main.rs`.)
49 
50 ### Three pages, one preview
51 - **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.
52 - **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.
53 - **Space** — a GrandPerspective-style treemap of the whole subtree, also split against the preview.
54 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.
55 
56 ### The list's scrollbar rides its centre line, behind the well
57 Browse's row list (`row_list.rs`, `RowList`) follows the DE's one scrollbar rule (cce-ui's CLAUDE.md, "Every scrollbar rides a centre line, behind the plate"; since 2026-10-06 — it was a flat bar at the right edge, always on top). The bar is a pair of pills down the middle of the list's width, `centred_scrollbar_width()` thick, over the rows: no column reserves a lane for it. A `ScrollbarActivity` decides its depth. Sunk, it is drawn before the well's translucent `list_bg_color` fill, which dims it, and it takes no press: a press on its lane goes to the row under it. A wheel, a glide or coast in motion, `scroll_into_view` (how keyboard navigation scrolls the list) and a thumb drag's release raise it, and a pointer over a raised bar holds it up. `RowList::tick`, which the app's `tick` already runs for the glide, ticks the activity too and returns true while the hold or the fade is running, so frames keep coming until the bar has sunk. `push_prims` draws the idle copy at full alpha every frame and the fore copy after the row overlays at `fade()`. That order holds because the list's prims go into the PAGE's `PageContent`, whose rects are replayed in call order (only `plain_pc` is partitioned by radius — see the note at the top of `row_list.rs`). A part's icons and reliefs are replayed after all its rects, so the row glyphs and the well's wall still draw over the fore copy. `the_scrollbar_rides_the_centre_and_sinks_until_scrolled` is the test.
58 
59 ### The Space treemap
60 Unlike the other two pages, Space needs data no other page has: the recursive size of everything below the current directory. `services/scan.rs` walks it on the FsService's blocking pool (`FsRequest::ScanTree`), never following symlinks, and entering another device only at a mount point on the same physical disk as the scan root (`crossable`: `/proc/self/mountinfo`, each source resolved through `/sys/class/block` past partitions and device-mapper to its disk). That keeps a scan of `/` out of `/proc`, `/sys`, tmpfs, shares and other drives while still taking in btrfs subvolumes and sibling partitions — a plain device check stopped at `/home`, its own subvolume (and so its own device) on this machine, and left most of the disk out. Unmounted nested subvolumes (snapshots, container layers) stay out. Every directory is entered once by (device, inode), so a bind mount neither double-counts nor loops. Progress is reported every 150 ms; the finished tree arrives as `SpaceMessage::Scanned`.
61 
62 `pages/space.rs` then lays that tree out with a **squarified** treemap (Bruls/Huizing/van Wijk), which keeps tiles near-square so areas stay visually comparable — a naive slice-and-dice degenerates into unreadable slivers. Layout is recursive, with a directory's children nested inside its rect, and is cached against the pane rect (`laid_out`) so it only recomputes on a resize or a new tree. Tiles under `MIN_TILE` px are dropped rather than emitted as sub-pixel slivers; that culling, not `MAX_TILES`, is what actually bounds tile count. Before that, a directory's entries under `REST_AREA` px² (its tail, since children are sorted largest first) are lumped into one "N smaller items" tile (`Tile::rest`, carrying the directory's own path) — dropped one by one, a folder of thousands of them left a dark hole that read as empty. Files are colored by extension `Category`, each inset half of `FILE_GAP` a side (`file_face`) so the frame shows as a seam between neighbours — without it, files of one kind side by side fused into one slab; directories paint only a frame — except one drawn as a single block (too small to open, or nothing inside it big enough to place: `Tile::aggregate`), which is filled with a dimmed copy of the colour of the kind holding most of its bytes. Painted in the frame colour, such blocks read as empty space. `tally` sums the tree by kind once when a scan lands (`SpaceState::breakdown`, plus each directory's dominant kind by path), so a relayout only looks colours up. The footer is two lines: the hovered tile (path relative to the scanned directory, cut from the front; size; share of the total; "mostly …" for a block) or else the summary, and under it the colour legend, largest kind first, the hovered kind lit. Labels on a filled tile pick light text or the frame's near-black by WCAG contrast (`label_on`): light text was 1.6:1 on Code's yellow. The root tile covers the whole map, so hovering it counts as hovering nothing.
63 
64 Two things to know when touching it:
65 - 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.
66 - 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.
67 - 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.
68 
69 ## Domain specifics
70 
71 - **CCE projects**: a directory containing `state.json` or `state.kdl` is treated as a *project* (`is_project_dir`), gets MIME `application/x-cce-project`, and on double-click is opened by its handler rather than entered. "Enter Directory" in the context menu overrides this.
72 - **Opening files**: `open_file()` resolves a handler via `get_mime_type` → `get_default_application`, which checks (1) `~/.config/cce/mime.kdl` custom associations, then (2) `xdg-mime` + `.desktop` parsing, looking the entry up in the XDG data dirs (`applications_dirs`: `$XDG_DATA_HOME`, then `$XDG_DATA_DIRS`) so it finds what `xdg-mime` named. Bare command names are resolved against `~/.local/bin` before falling back to `xdg-open`. Always launch via `spawn_detached` (in `services/fs.rs`; it reaps the child so it never lingers as a zombie).
73 - **MIME by extension**: `get_mime_type` decides .kdl and the 3D model formats (stl, obj, gltf, glb, ply → `mime_for_extension`) before shelling out to `xdg-mime query filetype`, because that falls back to content sniffing here and reads .gltf as JSON, .obj as text and binary STL/PLY as octet-stream, so Open never reached cce-model. PLY has no shared-mime-info type; `model/x-ply` is the one `cce-model.desktop` claims. The preview pane does not use the MIME (it sniffs UTF-8 itself).
74 - **Chooser modes**: launched with `--select`, `--select-dir`, or `--save`, the app becomes a file picker for other CCE apps — it shows a bottom action bar, prints the chosen path to stdout, and `std::process::exit(0)` on selection (or exit code 1 on cancel). This is why `SelectOpen`/`SelectCancel` call `process::exit` directly.
75 - **Persistence**: the last-visited directory is saved to `~/.config/cce/cce-files/cce-files-last-dir.txt` and restored on launch.
76 - **Double-click**: opening is temporal — `last_click_time` / `last_clicked_idx` in `update()` detect a double-click within 500ms rather than relying on a windowing double-click event.
77 - **Symbols are cce-icons glyphs, never characters**: a row's kind (`entry_icon` → `folder`, `file-image`, `file-code`, …), the preview header and the graph nodes draw a glyph by NAME through `PageContent::icon` / `icon_bounded`, which `rebuild_layout` flattens to `WidgetFx::Icon` and paints with `PaintCtx::icon` in the part's order (cut to its clip, not squashed). No emoji or symbol characters in labels, node names or text bodies — a directory listed in a text preview is `name/`.
78 - **Fonts**: `cce_ui::create_font_system()` loads bundled fonts from `cce_ui::fonts_dir()` — `$CCE_FONTS_DIR`, else `$HOME/Dropbox/Fonts`. It is resolved, not hardcoded, and the override is what a shadow session needs: a shadow HOME cannot see the real `~/Dropbox/Fonts`, so without `CCE_FONTS_DIR` its screenshots render in a fallback sans. Set `CCE_LOAD_SYSTEM_FONTS` to also load system fonts.