lattice_listing/file_tree/
mod.rs1pub 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#[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 Directory { expanded: bool },
65 File,
67}
68
69#[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 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 pub fn line_count(&self) -> u32 {
105 self.content.content_line_count()
106 }
107
108 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
141pub fn entry_at_line(entries: &[FileTreeEntry], line: u32) -> Option<&FileTreeEntry> {
145 entries.get(line as usize)
146}
147
148pub 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
173pub 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
235fn read_dir_sorted(root: &Path) -> std::io::Result<Vec<(PathBuf, bool)>> {
239 Ok(crate::dir::read_dir_sorted(root)?
244 .into_iter()
245 .map(|e| (e.path, e.is_dir))
246 .collect())
247}
248
249pub 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
266pub 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
286fn 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
309pub 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 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 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 toggle_entries_at(&mut entries, 1).unwrap();
380 assert_eq!(entries.len(), 3);
382 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 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 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 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 #[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 #[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 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 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}