gwm/tui/wt_tree.rs
1//! Working Tree file-explorer model (issue #300).
2//!
3//! A **pure, ratatui-free** transform of `git status --short` porcelain
4//! output (`XY PATH` lines) into a nested directory tree, so the Status
5//! pane's Working Tree section can render like a real file explorer
6//! (nerd-font folder / file-type icons + a per-file change badge) instead
7//! of a flat list. The renderer in [`super::ui`] walks the [`WtNode`] tree
8//! and paints it with [`Theme`](super::theme::Theme) colours; everything in
9//! this module is deterministic and theme-free so it can be unit-tested
10//! without a terminal.
11//!
12//! Layout rules baked into [`build_tree`]:
13//!
14//! - **Nesting** by path segment (`src/tui/ui.rs` → `src` → `tui` → `ui.rs`).
15//! - **Directories before files**, alphabetical within each level.
16//! - **Single-child directory chains collapse** for compactness
17//! (`src` → `tui` → `ui.rs` renders as `src/tui/` then `ui.rs`), matching
18//! the way file explorers fold empty intermediate folders.
19
20use std::collections::BTreeMap;
21use std::path::Path;
22
23/// Nerd-font glyph for a closed directory (`nf-fa-folder`). Kept public so
24/// a future expand/collapse affordance (issue #300, deferred) can pick the
25/// closed variant; the MVP renders the full tree with [`WT_DIR_OPEN_ICON`].
26pub const WT_DIR_ICON: &str = "\u{f07b}";
27/// Nerd-font glyph for an open directory (`nf-fa-folder_open`). The MVP
28/// always shows children, so every directory row uses this.
29pub const WT_DIR_OPEN_ICON: &str = "\u{f07c}";
30/// Generic file glyph (`nf-fa-file`) — the fallback when no extension in
31/// [`file_icon`]'s table matches.
32pub const WT_FILE_ICON: &str = "\u{f15b}";
33
34/// `.rs` (`nf-dev-rust`).
35pub const WT_RUST_ICON: &str = "\u{e7a8}";
36/// `.md` / `.markdown` (`nf-oct-markdown`).
37pub const WT_MARKDOWN_ICON: &str = "\u{f48a}";
38/// `.toml` (`nf-seti-config`).
39pub const WT_TOML_ICON: &str = "\u{e615}";
40/// `.json` (`nf-seti-json`).
41pub const WT_JSON_ICON: &str = "\u{e60b}";
42/// `.js` / `.cjs` / `.mjs` (`nf-seti-javascript`).
43pub const WT_JS_ICON: &str = "\u{e74e}";
44/// `.ts` / `.tsx` (`nf-seti-typescript`).
45pub const WT_TS_ICON: &str = "\u{e628}";
46/// `.lock` (`nf-fa-lock`).
47pub const WT_LOCK_ICON: &str = "\u{f023}";
48/// `.yml` / `.yaml` (`nf-seti-yml`).
49pub const WT_YAML_ICON: &str = "\u{e6a8}";
50/// `.sh` / `.bash` / `.zsh` (`nf-oct-terminal`).
51pub const WT_SHELL_ICON: &str = "\u{f489}";
52/// `.txt` (`nf-fa-file_text`).
53pub const WT_TEXT_ICON: &str = "\u{f15c}";
54
55/// The single change-category a `git status --short` `XY` pair falls into
56/// (issue #287, relocated here in #300 to be the shared, ratatui-free
57/// source of truth). Drives both the Working-Tree footer counts and the
58/// per-row / per-badge colouring so a file's colour always equals the
59/// footer segment it's counted in.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum WtCategory {
62 Created,
63 Modified,
64 Deleted,
65}
66
67/// Classify a porcelain `XY` status pair into its dominant
68/// [`WtCategory`], with a deterministic precedence (created > deleted >
69/// modified) so each file maps to exactly one bucket:
70///
71/// - `??` (untracked) or an `A` in either column → **created**,
72/// - else a `D` in either column → **deleted**,
73/// - else anything changed (`M`, `R`, `C`, `T`, `U`, …) → **modified**.
74pub fn working_tree_category(x: char, y: char) -> WtCategory {
75 if (x == '?' && y == '?') || x == 'A' || y == 'A' {
76 WtCategory::Created
77 } else if x == 'D' || y == 'D' {
78 WtCategory::Deleted
79 } else {
80 WtCategory::Modified
81 }
82}
83
84/// Representative single-character status badge for a porcelain `XY` pair,
85/// shown at the start of a file row in the same colour as its
86/// [`WtCategory`]:
87///
88/// - `??` → `?` (untracked, created colour),
89/// - `A` in either column → `A` (added, created colour),
90/// - `D` in either column → `D` (deleted colour),
91/// - anything else → `M` (modified colour).
92///
93/// The precedence mirrors [`working_tree_category`] so badge and row colour
94/// never disagree.
95pub fn status_badge(x: char, y: char) -> char {
96 if x == '?' && y == '?' {
97 '?'
98 } else if x == 'A' || y == 'A' {
99 'A'
100 } else if x == 'D' || y == 'D' {
101 'D'
102 } else {
103 'M'
104 }
105}
106
107/// Pick a nerd-font glyph for a file by its extension, falling back to the
108/// generic [`WT_FILE_ICON`] for unknown or extension-less names (including
109/// dotfiles like `.gitignore`). The match is a single table so new types
110/// are a one-line addition.
111pub fn file_icon(name: &str) -> &'static str {
112 let ext = Path::new(name)
113 .extension()
114 .and_then(|e| e.to_str())
115 .unwrap_or("")
116 .to_ascii_lowercase();
117 match ext.as_str() {
118 "rs" => WT_RUST_ICON,
119 "md" | "markdown" => WT_MARKDOWN_ICON,
120 "toml" => WT_TOML_ICON,
121 "json" => WT_JSON_ICON,
122 "js" | "cjs" | "mjs" => WT_JS_ICON,
123 "ts" | "tsx" => WT_TS_ICON,
124 "lock" => WT_LOCK_ICON,
125 "yml" | "yaml" => WT_YAML_ICON,
126 "sh" | "bash" | "zsh" => WT_SHELL_ICON,
127 "txt" => WT_TEXT_ICON,
128 _ => WT_FILE_ICON,
129 }
130}
131
132/// Make a path segment safe to render in the TUI: every control character
133/// (newline, tab, carriage return, ANSI escape, …) becomes `?`. `-z` emits
134/// filenames verbatim, so a name carrying embedded control bytes could
135/// otherwise break the sidebar layout or inject terminal escape sequences;
136/// the real bytes still live in git, this only guards what reaches the
137/// screen.
138///
139/// Delegates rather than keeping its own copy of the rule (issue #506): this
140/// had the same body as [`crate::naming::sanitise_for_terminal`] until that
141/// one grew the `Bidi_Control` characters, and a filename carrying one
142/// reorders a sidebar row exactly as it reorders a config value. A second copy
143/// is a second thing to forget.
144pub fn sanitize_name(name: &str) -> String {
145 crate::naming::sanitise_for_terminal(name)
146}
147
148/// A node in the Working Tree file-explorer model. A `Dir` carries its
149/// (possibly collapsed) display name, ordered children, and the aggregate
150/// change-category of its subtree (issue #300: `Some(c)` when every
151/// descendant shares category `c`, `None` when the subtree mixes
152/// categories) so the directory row can be coloured by what it contains. A
153/// `File` carries its leaf name plus the precomputed icon, badge glyph, and
154/// change category the renderer needs.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum WtNode {
157 Dir {
158 name: String,
159 children: Vec<WtNode>,
160 category: Option<WtCategory>,
161 },
162 File {
163 name: String,
164 icon: &'static str,
165 badge: char,
166 category: WtCategory,
167 },
168}
169
170/// Aggregate change-category of a directory subtree (issue #300): `Some(c)`
171/// when every categorised descendant shares category `c`, `None` when they
172/// mix (or the subtree is empty). A child directory that is itself mixed
173/// (`None`) makes its parent mixed too. Drives the retroactive directory
174/// colouring — a folder of only-modified files reads yellow, only-new
175/// green, only-deleted red, and a mixed folder a neutral accent.
176fn aggregate_category(children: &[WtNode]) -> Option<WtCategory> {
177 let mut found: Option<WtCategory> = None;
178 for child in children {
179 let cat = match child {
180 WtNode::File { category, .. } => *category,
181 WtNode::Dir { category: Some(c), .. } => *c,
182 // A child subtree that is already mixed makes this directory mixed.
183 WtNode::Dir { category: None, .. } => return None,
184 };
185 match found {
186 None => found = Some(cat),
187 Some(f) if f == cat => {}
188 Some(_) => return None,
189 }
190 }
191 found
192}
193
194/// One parsed `git status --porcelain -z` record: the two status columns
195/// and the working-tree path. For a rename/copy this is the **destination**
196/// (the source token is consumed and dropped during parsing), so every
197/// record is a live entry the tree can nest.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct StatusRecord {
200 pub x: char,
201 pub y: char,
202 pub path: String,
203}
204
205/// Parse `git status --porcelain -z` output into [`StatusRecord`]s.
206///
207/// The `-z` format is a run of **NUL-terminated** tokens, each `XY<space>PATH`;
208/// a rename/copy entry (`R`/`C` in either status column) is immediately
209/// *followed* by a second NUL-terminated token carrying its original path,
210/// which is skipped. Crucially, `-z` emits paths **verbatim** — no double-
211/// quoting, no C-escapes — and delimits on NUL, so a filename containing a
212/// space, a literal ` -> `, a quote, or non-ASCII bytes is unambiguous.
213/// This is why the file-explorer reads `-z` rather than the human `--short`
214/// format: it removes every textual-parsing edge case at the source.
215///
216/// Tokens too short to carry an `XY` pair, or with an empty path, are
217/// skipped; the trailing NUL's empty token is ignored. The helper is total
218/// for non-git callers.
219pub fn parse_status_z(raw: &str) -> Vec<StatusRecord> {
220 let mut records = Vec::new();
221 let mut tokens = raw.split('\0');
222 while let Some(tok) = tokens.next() {
223 if tok.is_empty() {
224 continue;
225 }
226 let mut chars = tok.chars();
227 let x = match chars.next() {
228 Some(c) => c,
229 None => continue,
230 };
231 let y = match chars.next() {
232 Some(c) => c,
233 None => continue,
234 };
235 // Skip the single separator space between the `XY` pair and the path.
236 if chars.next().is_none() {
237 continue;
238 }
239 let path: String = chars.collect();
240 if path.is_empty() {
241 continue;
242 }
243 // A rename/copy entry is trailed by its source-path token — drop it so
244 // the source dir doesn't show up as a phantom entry.
245 if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
246 tokens.next();
247 }
248 records.push(StatusRecord { x, y, path });
249 }
250 records
251}
252
253/// Build the nested Working Tree model from `git status --porcelain -z`
254/// output (via [`parse_status_z`]). Each record's path becomes a leaf at
255/// the end of its `/`-separated segments; intermediate segments are
256/// directories. The result is dir-first alphabetical at every level with
257/// single-child directory chains collapsed.
258pub fn build_tree(status_z: &str) -> Vec<WtNode> {
259 build_capped_tree(&parse_status_z(status_z), usize::MAX).0
260}
261
262/// Maximum file leaves the Working Tree explorer builds in one pass (issue
263/// #300). `--untracked-files=all` makes git enumerate every file inside an
264/// unignored generated/vendor directory; without a cap the sidebar would
265/// build and cache one `Line` per file and size its non-scrollable section
266/// from that full length, so selecting such a worktree could flood the TUI.
267/// Past the cap, [`build_capped_tree`] stops and reports the remainder for
268/// a single `… N more` row.
269pub const WT_TREE_MAX_FILES: usize = 500;
270
271/// Build the nested model from at most `max` of `records`, returning the
272/// node list plus the number of records dropped past the cap (`0` when
273/// nothing was capped). The kept records are the first `max` in the order
274/// git emitted them.
275pub fn build_capped_tree(records: &[StatusRecord], max: usize) -> (Vec<WtNode>, usize) {
276 let shown = records.len().min(max);
277 let mut root = DirBuilder::default();
278 for rec in &records[..shown] {
279 root.insert(&rec.path, rec.x, rec.y);
280 }
281 (root.into_nodes(), records.len() - shown)
282}
283
284// Intermediate mutable builder kept private; `BTreeMap` gives the
285// alphabetical ordering for free, and emitting dirs before files yields the
286// dir-first rule.
287#[derive(Default)]
288struct DirBuilder {
289 dirs: BTreeMap<String, DirBuilder>,
290 files: BTreeMap<String, FileLeaf>,
291}
292
293struct FileLeaf {
294 icon: &'static str,
295 badge: char,
296 category: WtCategory,
297}
298
299impl DirBuilder {
300 /// Insert one `XY PATH` entry, splitting `path` on `/` into directory
301 /// segments plus a final file leaf. Empty segments (a trailing slash or
302 /// `//`) are ignored so a stray separator can't spawn a blank node.
303 fn insert(&mut self, path: &str, x: char, y: char) {
304 let mut segments = path.split('/').filter(|s| !s.is_empty()).peekable();
305 let mut node = self;
306 while let Some(seg) = segments.next() {
307 if segments.peek().is_none() {
308 // Last segment → the file leaf.
309 node.files.insert(
310 seg.to_string(),
311 FileLeaf {
312 icon: file_icon(seg),
313 badge: status_badge(x, y),
314 category: working_tree_category(x, y),
315 },
316 );
317 return;
318 }
319 node = node.dirs.entry(seg.to_string()).or_default();
320 }
321 }
322
323 /// Lower the mutable builder into the public [`WtNode`] tree: directories
324 /// first (alphabetical, courtesy of `BTreeMap`), then files. Single-child
325 /// directory chains (a dir holding exactly one subdir and no files) are
326 /// folded into a single `a/b/c` row for compactness.
327 fn into_nodes(self) -> Vec<WtNode> {
328 let mut out = Vec::with_capacity(self.dirs.len() + self.files.len());
329 for (mut name, mut dir) in self.dirs {
330 while dir.files.is_empty() && dir.dirs.len() == 1 {
331 let (child_name, child_dir) = dir.dirs.into_iter().next().unwrap();
332 name.push('/');
333 name.push_str(&child_name);
334 dir = child_dir;
335 }
336 let children = dir.into_nodes();
337 let category = aggregate_category(&children);
338 out.push(WtNode::Dir {
339 name,
340 children,
341 category,
342 });
343 }
344 for (name, leaf) in self.files {
345 out.push(WtNode::File {
346 name,
347 icon: leaf.icon,
348 badge: leaf.badge,
349 category: leaf.category,
350 });
351 }
352 out
353 }
354}