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.
138pub fn sanitize_name(name: &str) -> String {
139 name.chars().map(|c| if c.is_control() { '?' } else { c }).collect()
140}
141
142/// A node in the Working Tree file-explorer model. A `Dir` carries its
143/// (possibly collapsed) display name, ordered children, and the aggregate
144/// change-category of its subtree (issue #300: `Some(c)` when every
145/// descendant shares category `c`, `None` when the subtree mixes
146/// categories) so the directory row can be coloured by what it contains. A
147/// `File` carries its leaf name plus the precomputed icon, badge glyph, and
148/// change category the renderer needs.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum WtNode {
151 Dir {
152 name: String,
153 children: Vec<WtNode>,
154 category: Option<WtCategory>,
155 },
156 File {
157 name: String,
158 icon: &'static str,
159 badge: char,
160 category: WtCategory,
161 },
162}
163
164/// Aggregate change-category of a directory subtree (issue #300): `Some(c)`
165/// when every categorised descendant shares category `c`, `None` when they
166/// mix (or the subtree is empty). A child directory that is itself mixed
167/// (`None`) makes its parent mixed too. Drives the retroactive directory
168/// colouring — a folder of only-modified files reads yellow, only-new
169/// green, only-deleted red, and a mixed folder a neutral accent.
170fn aggregate_category(children: &[WtNode]) -> Option<WtCategory> {
171 let mut found: Option<WtCategory> = None;
172 for child in children {
173 let cat = match child {
174 WtNode::File { category, .. } => *category,
175 WtNode::Dir { category: Some(c), .. } => *c,
176 // A child subtree that is already mixed makes this directory mixed.
177 WtNode::Dir { category: None, .. } => return None,
178 };
179 match found {
180 None => found = Some(cat),
181 Some(f) if f == cat => {}
182 Some(_) => return None,
183 }
184 }
185 found
186}
187
188/// One parsed `git status --porcelain -z` record: the two status columns
189/// and the working-tree path. For a rename/copy this is the **destination**
190/// (the source token is consumed and dropped during parsing), so every
191/// record is a live entry the tree can nest.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct StatusRecord {
194 pub x: char,
195 pub y: char,
196 pub path: String,
197}
198
199/// Parse `git status --porcelain -z` output into [`StatusRecord`]s.
200///
201/// The `-z` format is a run of **NUL-terminated** tokens, each `XY<space>PATH`;
202/// a rename/copy entry (`R`/`C` in either status column) is immediately
203/// *followed* by a second NUL-terminated token carrying its original path,
204/// which is skipped. Crucially, `-z` emits paths **verbatim** — no double-
205/// quoting, no C-escapes — and delimits on NUL, so a filename containing a
206/// space, a literal ` -> `, a quote, or non-ASCII bytes is unambiguous.
207/// This is why the file-explorer reads `-z` rather than the human `--short`
208/// format: it removes every textual-parsing edge case at the source.
209///
210/// Tokens too short to carry an `XY` pair, or with an empty path, are
211/// skipped; the trailing NUL's empty token is ignored. The helper is total
212/// for non-git callers.
213pub fn parse_status_z(raw: &str) -> Vec<StatusRecord> {
214 let mut records = Vec::new();
215 let mut tokens = raw.split('\0');
216 while let Some(tok) = tokens.next() {
217 if tok.is_empty() {
218 continue;
219 }
220 let mut chars = tok.chars();
221 let x = match chars.next() {
222 Some(c) => c,
223 None => continue,
224 };
225 let y = match chars.next() {
226 Some(c) => c,
227 None => continue,
228 };
229 // Skip the single separator space between the `XY` pair and the path.
230 if chars.next().is_none() {
231 continue;
232 }
233 let path: String = chars.collect();
234 if path.is_empty() {
235 continue;
236 }
237 // A rename/copy entry is trailed by its source-path token — drop it so
238 // the source dir doesn't show up as a phantom entry.
239 if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
240 tokens.next();
241 }
242 records.push(StatusRecord { x, y, path });
243 }
244 records
245}
246
247/// Build the nested Working Tree model from `git status --porcelain -z`
248/// output (via [`parse_status_z`]). Each record's path becomes a leaf at
249/// the end of its `/`-separated segments; intermediate segments are
250/// directories. The result is dir-first alphabetical at every level with
251/// single-child directory chains collapsed.
252pub fn build_tree(status_z: &str) -> Vec<WtNode> {
253 build_capped_tree(&parse_status_z(status_z), usize::MAX).0
254}
255
256/// Maximum file leaves the Working Tree explorer builds in one pass (issue
257/// #300). `--untracked-files=all` makes git enumerate every file inside an
258/// unignored generated/vendor directory; without a cap the sidebar would
259/// build and cache one `Line` per file and size its non-scrollable section
260/// from that full length, so selecting such a worktree could flood the TUI.
261/// Past the cap, [`build_capped_tree`] stops and reports the remainder for
262/// a single `… N more` row.
263pub const WT_TREE_MAX_FILES: usize = 500;
264
265/// Build the nested model from at most `max` of `records`, returning the
266/// node list plus the number of records dropped past the cap (`0` when
267/// nothing was capped). The kept records are the first `max` in the order
268/// git emitted them.
269pub fn build_capped_tree(records: &[StatusRecord], max: usize) -> (Vec<WtNode>, usize) {
270 let shown = records.len().min(max);
271 let mut root = DirBuilder::default();
272 for rec in &records[..shown] {
273 root.insert(&rec.path, rec.x, rec.y);
274 }
275 (root.into_nodes(), records.len() - shown)
276}
277
278// Intermediate mutable builder kept private; `BTreeMap` gives the
279// alphabetical ordering for free, and emitting dirs before files yields the
280// dir-first rule.
281#[derive(Default)]
282struct DirBuilder {
283 dirs: BTreeMap<String, DirBuilder>,
284 files: BTreeMap<String, FileLeaf>,
285}
286
287struct FileLeaf {
288 icon: &'static str,
289 badge: char,
290 category: WtCategory,
291}
292
293impl DirBuilder {
294 /// Insert one `XY PATH` entry, splitting `path` on `/` into directory
295 /// segments plus a final file leaf. Empty segments (a trailing slash or
296 /// `//`) are ignored so a stray separator can't spawn a blank node.
297 fn insert(&mut self, path: &str, x: char, y: char) {
298 let mut segments = path.split('/').filter(|s| !s.is_empty()).peekable();
299 let mut node = self;
300 while let Some(seg) = segments.next() {
301 if segments.peek().is_none() {
302 // Last segment → the file leaf.
303 node.files.insert(
304 seg.to_string(),
305 FileLeaf {
306 icon: file_icon(seg),
307 badge: status_badge(x, y),
308 category: working_tree_category(x, y),
309 },
310 );
311 return;
312 }
313 node = node.dirs.entry(seg.to_string()).or_default();
314 }
315 }
316
317 /// Lower the mutable builder into the public [`WtNode`] tree: directories
318 /// first (alphabetical, courtesy of `BTreeMap`), then files. Single-child
319 /// directory chains (a dir holding exactly one subdir and no files) are
320 /// folded into a single `a/b/c` row for compactness.
321 fn into_nodes(self) -> Vec<WtNode> {
322 let mut out = Vec::with_capacity(self.dirs.len() + self.files.len());
323 for (mut name, mut dir) in self.dirs {
324 while dir.files.is_empty() && dir.dirs.len() == 1 {
325 let (child_name, child_dir) = dir.dirs.into_iter().next().unwrap();
326 name.push('/');
327 name.push_str(&child_name);
328 dir = child_dir;
329 }
330 let children = dir.into_nodes();
331 let category = aggregate_category(&children);
332 out.push(WtNode::Dir {
333 name,
334 children,
335 category,
336 });
337 }
338 for (name, leaf) in self.files {
339 out.push(WtNode::File {
340 name,
341 icon: leaf.icon,
342 badge: leaf.badge,
343 category: leaf.category,
344 });
345 }
346 out
347 }
348}