Skip to main content

lattice_listing/file_tree/
mod.rs

1//! File-tree buffer (DESIGN.md §5.9 buffer-as-content).
2//!
3//! v1 status (B.1.d): a flattened, lazily-expanded tree rendered
4//! one entry per line. `<CR>` on a directory toggles its expansion;
5//! on a file opens that file as a new Document buffer through the
6//! standard `:e FILE` path. Standard motions (`j`/`k`/`0`/`$`/`G`/`gg`/`<C-d>`/...)
7//! navigate via the active-buffer routing in `lattice_core::ui::pane` -- the
8//! file tree carries no special motion bindings of its own.
9//!
10//! The buffer is read-only (mutations error with the same
11//! "read-only" echo as the help buffer). The rendered text is a
12//! [`lattice_core::Buffer`] derived from the entry list; the same
13//! buffer feeds syntax highlighting and motions that need the line
14//! text.
15//!
16//! ## Where does "the root", "the entry list", "nerd-fonts" live?
17//!
18//! Not on `FileTreeBuffer`. M.3.2.c.5 retired the struct fields;
19//! the canonical answer for each is a `BufferLocal` owned by
20//! `file-tree-mode`:
21//!
22//! - [`modes::FileTreeRoot`] -- the rooted directory.
23//! - [`modes::FileTreeEntries`] -- the flat list of visible
24//!   entries.
25//! - [`modes::FileTreeNerdFonts`] -- the nerd-font toggle.
26//!
27//! The `FileTreeBuffer` carries only the rendered rope + cursor +
28//! scroll. Operations that need the entry list / root / nerd-fonts
29//! flag take them as explicit parameters or read them from
30//! buffer-locals through the App's chokepoint helpers. Mutations
31//! to the entry list flow through [`toggle_entries_at`] (a pure
32//! function that takes `&mut Vec<FileTreeEntry>`), so the App can
33//! orchestrate "update locals + re-render rope" as one atomic step
34//! and no struct mirror exists to drift.
35
36pub mod modes;
37
38pub use modes::{
39    FileTreeEntries, FileTreeMode, FileTreeNerdFonts, FileTreeRoot, register_file_tree_modes,
40};
41
42use std::path::{Path, PathBuf};
43
44use lattice_core::Buffer;
45use lattice_protocol::edit::Edit;
46use lattice_protocol::position::Position;
47
48use lattice_core::BufferId;
49
50/// One row in the rendered file tree. `depth` controls the
51/// indentation prefix; `kind` decides whether `<CR>` toggles
52/// expansion or opens a file.
53#[derive(Debug, Clone)]
54pub struct FileTreeEntry {
55    pub path: PathBuf,
56    pub depth: u32,
57    pub kind: FileTreeEntryKind,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum FileTreeEntryKind {
62    /// A directory. `expanded = true` means the renderer recurses
63    /// into its children.
64    Directory { expanded: bool },
65    /// A regular file.
66    File,
67}
68
69/// One open file-tree buffer. Carries only the rendered rope and
70/// motion state (cursor + scroll). All "where does this buffer
71/// point" data -- root, entry list, nerd-fonts toggle -- lives in
72/// the App's `BufferLocals` map under the
73/// [`modes::FileTreeRoot`] / [`modes::FileTreeEntries`] /
74/// [`modes::FileTreeNerdFonts`] keys.
75#[derive(Debug)]
76pub struct FileTreeBuffer {
77    pub id: BufferId,
78    pub content: Buffer,
79    pub cursor: Position,
80    pub scroll: usize,
81}
82
83impl FileTreeBuffer {
84    /// Build a fresh file-tree rooted at `root`. Returns the
85    /// buffer plus the initial entry list -- the caller is
86    /// responsible for seeding the [`modes::FileTreeRoot`] /
87    /// [`modes::FileTreeEntries`] / [`modes::FileTreeNerdFonts`]
88    /// buffer-locals from these values.
89    pub fn open(root: &Path, nerd_fonts: bool) -> std::io::Result<(Self, Vec<FileTreeEntry>)> {
90        let entries = initial_entries(root)?;
91        let content = render_to_buffer(&entries, nerd_fonts);
92        let buf = Self {
93            id: BufferId::next(),
94            content,
95            cursor: Position::ZERO,
96            scroll: 0,
97        };
98        Ok((buf, entries))
99    }
100
101    /// Entries in the listing. CV.3: content space — this bounds
102    /// cursor motion, and the empty line ropey reports after the
103    /// listing's terminating newline is not an entry.
104    pub fn line_count(&self) -> u32 {
105        self.content.content_line_count()
106    }
107
108    /// Same shape as `HelpBuffer::move_cursor` so the active-buffer
109    /// routing in App can drive both kinds through the same motion
110    /// path.
111    pub fn move_cursor(&mut self, dx: i32, dy: i32, viewport: usize) {
112        let last_line = self.line_count().saturating_sub(1) as i32;
113        let new_line = (self.cursor.line as i32 + dy).clamp(0, last_line) as u32;
114        let line_len = line_byte_len(&self.content, new_line);
115        let new_byte = (self.cursor.byte as i32 + dx).clamp(0, line_len as i32) as u32;
116        self.cursor = Position::new(new_line, new_byte);
117        self.adjust_scroll_to_cursor(viewport);
118    }
119
120    pub fn jump_cursor_to(&mut self, line: u32, viewport: usize) {
121        let last_line = self.line_count().saturating_sub(1);
122        let target = line.min(last_line);
123        let line_len = line_byte_len(&self.content, target);
124        self.cursor = Position::new(target, self.cursor.byte.min(line_len));
125        self.adjust_scroll_to_cursor(viewport);
126    }
127
128    pub fn adjust_scroll_to_cursor(&mut self, viewport: usize) {
129        if viewport == 0 {
130            return;
131        }
132        let line = self.cursor.line as usize;
133        if line < self.scroll {
134            self.scroll = line;
135        } else if line >= self.scroll + viewport {
136            self.scroll = line + 1 - viewport;
137        }
138    }
139}
140
141/// Look up the entry at `line` in an entry list. The App reads
142/// `FileTreeEntries` from buffer-locals and calls this with
143/// `app.cursor.line` for `<CR>` dispatch.
144pub fn entry_at_line(entries: &[FileTreeEntry], line: u32) -> Option<&FileTreeEntry> {
145    entries.get(line as usize)
146}
147
148/// Build the initial entry list for `root`: the root itself as a
149/// depth-0 expanded directory, plus its immediate children at
150/// depth 1.
151pub fn initial_entries(root: &Path) -> std::io::Result<Vec<FileTreeEntry>> {
152    let mut entries = Vec::new();
153    entries.push(FileTreeEntry {
154        path: root.to_path_buf(),
155        depth: 0,
156        kind: FileTreeEntryKind::Directory { expanded: true },
157    });
158    let children = read_dir_sorted(root)?;
159    for (path, is_dir) in children {
160        entries.push(FileTreeEntry {
161            path,
162            depth: 1,
163            kind: if is_dir {
164                FileTreeEntryKind::Directory { expanded: false }
165            } else {
166                FileTreeEntryKind::File
167            },
168        });
169    }
170    Ok(entries)
171}
172
173/// Toggle expansion of the directory entry at `index` (1-based
174/// within `entries`). Children of newly-expanded directories
175/// are inserted right after the directory's row at depth + 1;
176/// closing a directory removes every entry whose depth is
177/// strictly greater than the closing one until depth drops
178/// back. No-op for non-directory rows.
179///
180/// Pure function over the entry list -- no struct state, no
181/// rope re-render. The App-side caller composes this with
182/// [`render_to_buffer`] and a `FileTreeEntries` buffer-local
183/// write to atomically refresh both halves of the file-tree
184/// representation.
185pub fn toggle_entries_at(entries: &mut Vec<FileTreeEntry>, index: usize) -> std::io::Result<()> {
186    let Some(entry) = entries.get(index) else {
187        return Ok(());
188    };
189    if let FileTreeEntryKind::Directory { expanded } = entry.kind {
190        if expanded {
191            collapse(entries, index);
192        } else {
193            expand(entries, index)?;
194        }
195    }
196    Ok(())
197}
198
199fn expand(entries: &mut Vec<FileTreeEntry>, index: usize) -> std::io::Result<()> {
200    let parent_path = entries[index].path.clone();
201    let parent_depth = entries[index].depth;
202    let children = read_dir_sorted(&parent_path)?;
203    let insert_at = index + 1;
204    let mut new_entries: Vec<FileTreeEntry> = children
205        .into_iter()
206        .map(|(path, is_dir)| FileTreeEntry {
207            path,
208            depth: parent_depth + 1,
209            kind: if is_dir {
210                FileTreeEntryKind::Directory { expanded: false }
211            } else {
212                FileTreeEntryKind::File
213            },
214        })
215        .collect();
216    if let FileTreeEntryKind::Directory { ref mut expanded } = entries[index].kind {
217        *expanded = true;
218    }
219    entries.splice(insert_at..insert_at, new_entries.drain(..));
220    Ok(())
221}
222
223fn collapse(entries: &mut Vec<FileTreeEntry>, index: usize) {
224    let parent_depth = entries[index].depth;
225    if let FileTreeEntryKind::Directory { ref mut expanded } = entries[index].kind {
226        *expanded = false;
227    }
228    let mut end = index + 1;
229    while end < entries.len() && entries[end].depth > parent_depth {
230        end += 1;
231    }
232    entries.drain(index + 1..end);
233}
234
235/// Read a directory's children, partitioned (directories first)
236/// and sorted by name. Hidden files (those starting with `.`) are
237/// included so the user can navigate config dirs.
238fn read_dir_sorted(root: &Path) -> std::io::Result<Vec<(PathBuf, bool)>> {
239    // DL.7: one shared read + ordering rule
240    // (`dir::read_dir_sorted`). The tree keeps the path — rows and
241    // icons are keyed off it, and depth comes from the walk, not the
242    // read.
243    Ok(crate::dir::read_dir_sorted(root)?
244        .into_iter()
245        .map(|e| (e.path, e.is_dir))
246        .collect())
247}
248
249/// Serialise the entry list to a [`Buffer`]. Each row is
250/// `<indent><marker><icon><name>` where `<marker>` is `▾ ` for an
251/// expanded dir, `▸ ` for a collapsed dir, and `  ` for a file.
252/// Indent is two spaces per `depth`. The `<icon>` glyph comes from
253/// `lattice_core::ui::icons::glyph_for_entry` -- nerd-fonts when
254/// `nerd_fonts` is true, the BMP-block fallback palette otherwise.
255/// Both palettes occupy two cells, so column geometry is the same
256/// either way.
257pub fn render_to_buffer(entries: &[FileTreeEntry], _nerd_fonts: bool) -> Buffer {
258    let mut buffer = Buffer::empty();
259    let text = render_to_text(entries);
260    if !text.is_empty() {
261        let _ = buffer.apply_edit(&Edit::insert(Position::ZERO, text));
262    }
263    buffer
264}
265
266/// The tree's rope text: indent, expand marker, name — and **no
267/// icon**.
268///
269/// DL.4: the glyph used to be baked in here. It is virtual text now
270/// (`directory-listing-mode` publishes it as a leading inlay), which
271/// makes the rope the entry NAMES: searchable, yankable, and not a
272/// rendering artefact. It also makes the tree symmetric with oil,
273/// whose rope never contained glyphs because `:w` diffs it.
274pub fn render_to_text(entries: &[FileTreeEntry]) -> String {
275    let mut text = String::new();
276    for (i, entry) in entries.iter().enumerate() {
277        text.push_str(&row_prefix(entry));
278        text.push_str(&row_name(entry));
279        if i + 1 < entries.len() {
280            text.push('\n');
281        }
282    }
283    text
284}
285
286/// Indent + expand marker — everything before the icon anchor.
287fn row_prefix(entry: &FileTreeEntry) -> String {
288    let indent = "  ".repeat(entry.depth as usize);
289    let marker = match entry.kind {
290        FileTreeEntryKind::Directory { expanded: true } => "▾ ",
291        FileTreeEntryKind::Directory { expanded: false } => "▸ ",
292        FileTreeEntryKind::File => "  ",
293    };
294    format!("{indent}{marker}")
295}
296
297fn row_name(entry: &FileTreeEntry) -> String {
298    if entry.depth == 0 {
299        entry.path.display().to_string()
300    } else {
301        entry
302            .path
303            .file_name()
304            .map(|s| s.to_string_lossy().into_owned())
305            .unwrap_or_default()
306    }
307}
308
309/// Project the tree's entries into the shape
310/// `directory-listing-mode` reads (DL.2's shared local).
311///
312/// `icon_byte` is the byte length of the indent + marker, so the icon
313/// splices between the tree structure and the name rather than at the
314/// far left.
315pub fn listing_entries(entries: &[FileTreeEntry]) -> Vec<crate::listing_mode::ListingEntry> {
316    entries
317        .iter()
318        .map(|e| crate::listing_mode::ListingEntry {
319            path: e.path.clone(),
320            is_dir: matches!(e.kind, FileTreeEntryKind::Directory { .. }),
321            icon_byte: row_prefix(e).len() as u32,
322            // `row_name`, not `path.file_name()` — the root row's text is
323            // the whole path, so deriving the name from the path would
324            // under-span exactly one row and leave the root half-coloured.
325            name_byte_len: row_name(e).len() as u32,
326        })
327        .collect()
328}
329
330fn line_byte_len(buf: &Buffer, line: u32) -> u32 {
331    let s = buf.as_string();
332    s.split('\n')
333        .nth(line as usize)
334        .map(|l| l.len() as u32)
335        .unwrap_or(0)
336}
337
338#[cfg(test)]
339mod tests {
340    #![allow(clippy::unwrap_used)]
341    use super::*;
342
343    fn temp_dir() -> PathBuf {
344        let dir =
345            std::env::temp_dir().join(format!("lattice-tree-{}-{}", std::process::id(), uniq()));
346        std::fs::create_dir_all(&dir).unwrap();
347        dir
348    }
349
350    fn uniq() -> u64 {
351        use std::sync::atomic::{AtomicU64, Ordering};
352        static N: AtomicU64 = AtomicU64::new(0);
353        N.fetch_add(1, Ordering::Relaxed)
354    }
355
356    #[test]
357    fn open_returns_buffer_and_initial_entries() {
358        let dir = temp_dir();
359        std::fs::write(dir.join("a.txt"), "x").unwrap();
360        std::fs::create_dir(dir.join("sub")).unwrap();
361        let (_buf, entries) = FileTreeBuffer::open(&dir, false).unwrap();
362        // Root + 2 children = 3 entries.
363        assert_eq!(entries.len(), 3);
364        assert!(matches!(
365            entries[1].kind,
366            FileTreeEntryKind::Directory { .. }
367        ));
368        assert!(matches!(entries[2].kind, FileTreeEntryKind::File));
369        std::fs::remove_dir_all(dir).ok();
370    }
371
372    #[test]
373    fn toggle_entries_at_expands_and_collapses() {
374        let dir = temp_dir();
375        std::fs::create_dir(dir.join("sub")).unwrap();
376        std::fs::write(dir.join("sub").join("inner.txt"), "x").unwrap();
377        let (_buf, mut entries) = FileTreeBuffer::open(&dir, false).unwrap();
378        // "sub" is at index 1 (after root row).
379        toggle_entries_at(&mut entries, 1).unwrap();
380        // After expand: root, sub, sub/inner.txt = 3 rows.
381        assert_eq!(entries.len(), 3);
382        // Collapse again.
383        toggle_entries_at(&mut entries, 1).unwrap();
384        assert_eq!(entries.len(), 2);
385        std::fs::remove_dir_all(dir).ok();
386    }
387
388    #[test]
389    fn render_uses_indentation_and_marker() {
390        let dir = temp_dir();
391        std::fs::write(dir.join("a.txt"), "x").unwrap();
392        let (buf, _) = FileTreeBuffer::open(&dir, false).unwrap();
393        let body = buf.content.as_string();
394        assert!(body.contains("a.txt"));
395        // File row is the second; depth 1 = 2 spaces of indent + 2
396        // spaces for the file marker + the 2-cell BMP icon.
397        assert!(body.lines().nth(1).unwrap().starts_with("    "));
398        std::fs::remove_dir_all(dir).ok();
399    }
400
401    #[test]
402    fn render_bmp_mode_does_not_double_arrow_on_directories() {
403        // Regression: the BMP fallback used to return `▸ ` as the
404        // dir glyph -- identical to the collapsed-marker, so a
405        // row would render as `▸ ▸ name`. Post-fix the glyph is
406        // 2-space padding, eliminating the visual collision while
407        // keeping the name column aligned with files.
408        let dir = temp_dir();
409        std::fs::create_dir(dir.join("sub")).unwrap();
410        let entries = vec![
411            FileTreeEntry {
412                path: dir.clone(),
413                depth: 0,
414                kind: FileTreeEntryKind::Directory { expanded: true },
415            },
416            FileTreeEntry {
417                path: dir.join("sub"),
418                depth: 1,
419                kind: FileTreeEntryKind::Directory { expanded: false },
420            },
421        ];
422        let buf = render_to_buffer(&entries, false);
423        let body = buf.as_string();
424        assert!(
425            !body.contains("▸ ▸"),
426            "BMP-mode collapsed dir row should not have double arrow; got:\n{body}",
427        );
428        assert!(
429            !body.contains("▾ ▸"),
430            "BMP-mode expanded dir + collapsed child should not pair the two markers as a double glyph; got:\n{body}",
431        );
432        // Sanity: the row still contains the marker and the dir
433        // name, just not a redundant glyph between them.
434        let row = body.lines().nth(1).unwrap();
435        assert!(
436            row.contains("▸ "),
437            "collapsed marker should be present: {row:?}"
438        );
439        assert!(row.contains("sub"), "dir name should be present: {row:?}");
440        std::fs::remove_dir_all(dir).ok();
441    }
442
443    #[test]
444    fn move_cursor_clamps_to_last_line() {
445        let dir = temp_dir();
446        std::fs::write(dir.join("a.txt"), "x").unwrap();
447        let (mut buf, _) = FileTreeBuffer::open(&dir, false).unwrap();
448        buf.move_cursor(0, 1000, 10);
449        assert_eq!(buf.cursor.line, 1);
450        std::fs::remove_dir_all(dir).ok();
451    }
452
453    #[test]
454    fn entry_at_line_returns_currently_targeted() {
455        let dir = temp_dir();
456        std::fs::write(dir.join("a.txt"), "x").unwrap();
457        let (_buf, entries) = FileTreeBuffer::open(&dir, false).unwrap();
458        let e = entry_at_line(&entries, 1).unwrap();
459        assert!(matches!(e.kind, FileTreeEntryKind::File));
460        std::fs::remove_dir_all(dir).ok();
461    }
462
463    /// DL.4: the rope is entry NAMES. The glyph used to be baked in;
464    /// it is virtual text now, published by `directory-listing-mode`
465    /// as a leading inlay.
466    ///
467    /// This test is the inverse of the two it replaces (which asserted
468    /// the nerd glyph and the BMP fallback were *in* the body). Text a
469    /// user can search and yank should not contain rendering artefacts,
470    /// and keeping it out is what makes the tree symmetric with oil,
471    /// whose rope never held glyphs because `:w` diffs it.
472    #[test]
473    fn rope_holds_names_not_glyphs() {
474        let dir = temp_dir();
475        std::fs::write(dir.join("main.rs"), "x").unwrap();
476        let (_buf, entries) = FileTreeBuffer::open(&dir, true).unwrap();
477        let body = render_to_text(&entries);
478        assert!(
479            body.contains("main.rs"),
480            "the name must be in the rope: {body}"
481        );
482        for glyph in ["\u{f1617} ", "· ", "◆ "] {
483            assert!(
484                !body.contains(glyph),
485                "glyph {glyph:?} leaked into the rope: {body}"
486            );
487        }
488        std::fs::remove_dir_all(dir).ok();
489    }
490
491    /// The icon anchors between the tree structure and the name — at
492    /// byte 0 it would render left of the indent and the tree's shape
493    /// would collapse.
494    #[test]
495    fn listing_entries_anchor_icons_after_indent_and_marker() {
496        let dir = temp_dir();
497        std::fs::create_dir(dir.join("sub")).unwrap();
498        std::fs::write(dir.join("sub").join("main.rs"), "x").unwrap();
499        let (_buf, mut entries) = FileTreeBuffer::open(&dir, false).unwrap();
500        // Expand `sub` so there is a depth-1 row to check.
501        let sub = entries
502            .iter()
503            .position(|e| matches!(e.kind, FileTreeEntryKind::Directory { .. }) && e.depth == 1)
504            .expect("a child directory row");
505        toggle_entries_at(&mut entries, sub).unwrap();
506
507        let listing = listing_entries(&entries);
508        assert_eq!(listing.len(), entries.len(), "one per row");
509
510        let text = render_to_text(&entries);
511        for (row, (le, line)) in listing.iter().zip(text.split('\n')).enumerate() {
512            let anchor = le.icon_byte as usize;
513            assert!(
514                line.is_char_boundary(anchor),
515                "row {row}: icon_byte {anchor} must be a char boundary in {line:?}"
516            );
517            // Everything before the anchor is structure (indent +
518            // marker); nothing of the name may precede it.
519            let prefix = &line[..anchor];
520            assert!(
521                prefix.chars().all(|c| c == ' ' || c == '▾' || c == '▸'),
522                "row {row}: prefix {prefix:?} must be indent + marker only"
523            );
524        }
525        std::fs::remove_dir_all(dir).ok();
526    }
527}