1use ratatui::Frame;
6use ratatui::layout::Rect;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{
10 Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
11};
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use unicode_width::UnicodeWidthStr;
15
16use crate::studio::theme;
17use crate::studio::utils::truncate_width;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum FileGitStatus {
26 #[default]
27 Normal,
28 Staged,
29 Modified,
30 Untracked,
31 Deleted,
32 Renamed,
33 Conflict,
34}
35
36impl FileGitStatus {
37 #[must_use]
39 pub fn indicator(self) -> &'static str {
40 match self {
41 Self::Normal => " ",
42 Self::Staged => "●",
43 Self::Modified => "○",
44 Self::Untracked => "?",
45 Self::Deleted => "✕",
46 Self::Renamed => "→",
47 Self::Conflict => "!",
48 }
49 }
50
51 #[must_use]
53 pub fn style(self) -> Style {
54 match self {
55 Self::Normal => theme::dimmed(),
56 Self::Staged => theme::git_staged(),
57 Self::Modified => theme::git_modified(),
58 Self::Untracked => theme::git_untracked(),
59 Self::Deleted => theme::git_deleted(),
60 Self::Renamed => theme::git_staged(),
61 Self::Conflict => theme::error(),
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
72pub struct TreeNode {
73 pub name: String,
75 pub path: PathBuf,
77 pub is_dir: bool,
79 pub git_status: FileGitStatus,
81 pub depth: usize,
83 pub children: Vec<TreeNode>,
85}
86
87impl TreeNode {
88 pub fn file(name: impl Into<String>, path: impl Into<PathBuf>, depth: usize) -> Self {
90 Self {
91 name: name.into(),
92 path: path.into(),
93 is_dir: false,
94 git_status: FileGitStatus::Normal,
95 depth,
96 children: Vec::new(),
97 }
98 }
99
100 pub fn dir(name: impl Into<String>, path: impl Into<PathBuf>, depth: usize) -> Self {
102 Self {
103 name: name.into(),
104 path: path.into(),
105 is_dir: true,
106 git_status: FileGitStatus::Normal,
107 depth,
108 children: Vec::new(),
109 }
110 }
111
112 #[must_use]
114 pub fn with_status(mut self, status: FileGitStatus) -> Self {
115 self.git_status = status;
116 self
117 }
118
119 pub fn add_child(&mut self, child: TreeNode) {
121 self.children.push(child);
122 }
123
124 pub fn sort_children(&mut self) {
126 self.children.sort_by(|a, b| match (a.is_dir, b.is_dir) {
127 (true, false) => std::cmp::Ordering::Less,
128 (false, true) => std::cmp::Ordering::Greater,
129 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
130 });
131 for child in &mut self.children {
132 child.sort_children();
133 }
134 }
135}
136
137#[derive(Debug, Clone)]
143pub struct FlatEntry {
144 pub name: String,
145 pub path: PathBuf,
146 pub is_dir: bool,
147 pub git_status: FileGitStatus,
148 pub depth: usize,
149 pub is_expanded: bool,
150 pub has_children: bool,
151}
152
153#[derive(Debug, Clone)]
159pub struct FileTreeState {
160 root: Vec<TreeNode>,
162 expanded: HashSet<PathBuf>,
164 selected: usize,
166 scroll_offset: usize,
168 flat_cache: Vec<FlatEntry>,
170 cache_dirty: bool,
172}
173
174impl Default for FileTreeState {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180impl FileTreeState {
181 #[must_use]
183 pub fn new() -> Self {
184 Self {
185 root: Vec::new(),
186 expanded: HashSet::new(),
187 selected: 0,
188 scroll_offset: 0,
189 flat_cache: Vec::new(),
190 cache_dirty: true,
191 }
192 }
193
194 #[must_use]
196 pub fn is_empty(&self) -> bool {
197 self.root.is_empty()
198 }
199
200 pub fn set_root(&mut self, root: Vec<TreeNode>) {
202 self.root = root;
203 self.cache_dirty = true;
204 self.selected = 0;
205 self.scroll_offset = 0;
206 }
207
208 #[must_use]
210 pub fn from_paths(paths: &[PathBuf], git_statuses: &[(PathBuf, FileGitStatus)]) -> Self {
211 let mut state = Self::new();
212 let mut root_nodes: Vec<TreeNode> = Vec::new();
213
214 let status_map: std::collections::HashMap<_, _> = git_statuses.iter().cloned().collect();
216
217 for path in paths {
218 let components: Vec<_> = path.components().collect();
219 insert_path(&mut root_nodes, &components, 0, path, &status_map);
220 }
221
222 for node in &mut root_nodes {
224 node.sort_children();
225 }
226
227 root_nodes.sort_by(|a, b| match (a.is_dir, b.is_dir) {
229 (true, false) => std::cmp::Ordering::Less,
230 (false, true) => std::cmp::Ordering::Greater,
231 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
232 });
233
234 state.root = root_nodes;
235 state.cache_dirty = true;
236 state.expand_to_depth(2);
238 state
239 }
240
241 pub fn flat_view(&mut self) -> &[FlatEntry] {
243 if self.cache_dirty {
244 self.rebuild_cache();
245 }
246 &self.flat_cache
247 }
248
249 fn rebuild_cache(&mut self) {
251 self.flat_cache.clear();
252 let root_clone = self.root.clone();
253 for node in &root_clone {
254 self.flatten_node(node);
255 }
256 self.cache_dirty = false;
257 }
258
259 fn flatten_node(&mut self, node: &TreeNode) {
261 let is_expanded = self.expanded.contains(&node.path);
262
263 self.flat_cache.push(FlatEntry {
264 name: node.name.clone(),
265 path: node.path.clone(),
266 is_dir: node.is_dir,
267 git_status: node.git_status,
268 depth: node.depth,
269 is_expanded,
270 has_children: !node.children.is_empty(),
271 });
272
273 if is_expanded {
274 let children = node.children.clone();
275 for child in &children {
276 self.flatten_node(child);
277 }
278 }
279 }
280
281 pub fn selected_entry(&mut self) -> Option<FlatEntry> {
283 self.ensure_cache();
284 self.flat_cache.get(self.selected).cloned()
285 }
286
287 fn ensure_cache(&mut self) {
289 if self.cache_dirty {
290 self.rebuild_cache();
291 }
292 }
293
294 pub fn selected_path(&mut self) -> Option<PathBuf> {
296 self.selected_entry().map(|e| e.path)
297 }
298
299 pub fn select_path(&mut self, path: &Path) -> bool {
301 let selected = self.flat_view().iter().position(|entry| entry.path == path);
302
303 if let Some(index) = selected {
304 self.selected = index;
305 self.ensure_visible();
306 true
307 } else {
308 false
309 }
310 }
311
312 pub fn select_prev(&mut self) {
314 if self.selected > 0 {
315 self.selected -= 1;
316 self.ensure_visible();
317 }
318 }
319
320 pub fn select_next(&mut self) {
322 let len = self.flat_view().len();
323 if self.selected + 1 < len {
324 self.selected += 1;
325 self.ensure_visible();
326 }
327 }
328
329 pub fn select_first(&mut self) {
331 self.selected = 0;
332 self.scroll_offset = 0;
333 }
334
335 pub fn select_last(&mut self) {
337 let len = self.flat_view().len();
338 if len > 0 {
339 self.selected = len - 1;
340 }
341 }
342
343 pub fn page_up(&mut self, page_size: usize) {
345 self.selected = self.selected.saturating_sub(page_size);
346 self.ensure_visible();
347 }
348
349 pub fn page_down(&mut self, page_size: usize) {
351 let len = self.flat_view().len();
352 self.selected = (self.selected + page_size).min(len.saturating_sub(1));
353 self.ensure_visible();
354 }
355
356 pub fn toggle_expand(&mut self) {
358 if let Some(entry) = self.selected_entry()
359 && entry.is_dir
360 {
361 if self.expanded.contains(&entry.path) {
362 self.expanded.remove(&entry.path);
363 } else {
364 self.expanded.insert(entry.path);
365 }
366 self.cache_dirty = true;
367 }
368 }
369
370 pub fn expand(&mut self) {
372 if let Some(entry) = self.selected_entry()
373 && entry.is_dir
374 && !self.expanded.contains(&entry.path)
375 {
376 self.expanded.insert(entry.path);
377 self.cache_dirty = true;
378 }
379 }
380
381 pub fn collapse(&mut self) {
383 if let Some(entry) = self.selected_entry() {
384 if entry.is_dir && self.expanded.contains(&entry.path) {
385 self.expanded.remove(&entry.path);
386 self.cache_dirty = true;
387 } else if entry.depth > 0 {
388 let parent_path = entry.path.parent().map(Path::to_path_buf);
390 if let Some(parent) = parent_path {
391 self.expanded.remove(&parent);
392 self.cache_dirty = true;
393 let flat = self.flat_view();
395 for (i, e) in flat.iter().enumerate() {
396 if e.path == parent {
397 self.selected = i;
398 break;
399 }
400 }
401 }
402 }
403 }
404 }
405
406 pub fn expand_all(&mut self) {
408 self.expand_all_recursive(&self.root.clone());
409 self.cache_dirty = true;
410 }
411
412 fn expand_all_recursive(&mut self, nodes: &[TreeNode]) {
413 for node in nodes {
414 if node.is_dir {
415 self.expanded.insert(node.path.clone());
416 self.expand_all_recursive(&node.children);
417 }
418 }
419 }
420
421 pub fn collapse_all(&mut self) {
423 self.expanded.clear();
424 self.cache_dirty = true;
425 self.selected = 0;
426 }
427
428 pub fn expand_to_depth(&mut self, max_depth: usize) {
430 self.expand_to_depth_recursive(&self.root.clone(), 0, max_depth);
431 self.cache_dirty = true;
432 }
433
434 fn expand_to_depth_recursive(
435 &mut self,
436 nodes: &[TreeNode],
437 current_depth: usize,
438 max_depth: usize,
439 ) {
440 if current_depth >= max_depth {
441 return;
442 }
443 for node in nodes {
444 if node.is_dir {
445 self.expanded.insert(node.path.clone());
446 self.expand_to_depth_recursive(&node.children, current_depth + 1, max_depth);
447 }
448 }
449 }
450
451 #[allow(clippy::unused_self)]
453 fn ensure_visible(&mut self) {
454 }
456
457 pub fn update_scroll(&mut self, visible_height: usize) {
459 if visible_height == 0 {
460 return;
461 }
462
463 if self.selected < self.scroll_offset {
465 self.scroll_offset = self.selected;
466 } else if self.selected >= self.scroll_offset + visible_height {
467 self.scroll_offset = self.selected - visible_height + 1;
468 }
469 }
470
471 #[must_use]
473 pub fn scroll_offset(&self) -> usize {
474 self.scroll_offset
475 }
476
477 #[must_use]
479 pub fn selected_index(&self) -> usize {
480 self.selected
481 }
482
483 pub fn select_by_row(&mut self, row: usize) -> bool {
486 let flat_len = self.flat_view().len();
487 let target_index = self.scroll_offset + row;
488
489 if target_index < flat_len && target_index != self.selected {
490 self.selected = target_index;
491 true
492 } else {
493 false
494 }
495 }
496
497 #[must_use]
500 pub fn is_row_selected(&self, row: usize) -> bool {
501 let target_index = self.scroll_offset + row;
502 target_index == self.selected
503 }
504
505 pub fn handle_click(&mut self, row: usize) -> (bool, bool) {
508 let flat_len = self.flat_view().len();
509 let target_index = self.scroll_offset + row;
510
511 if target_index >= flat_len {
512 return (false, false);
513 }
514
515 let was_selected = target_index == self.selected;
516 let is_dir = self.flat_cache.get(target_index).is_some_and(|e| e.is_dir);
517
518 if !was_selected {
519 self.selected = target_index;
520 }
521
522 (!was_selected, is_dir)
523 }
524}
525
526fn insert_path(
528 nodes: &mut Vec<TreeNode>,
529 components: &[std::path::Component<'_>],
530 depth: usize,
531 full_path: &Path,
532 status_map: &std::collections::HashMap<PathBuf, FileGitStatus>,
533) {
534 if components.is_empty() {
535 return;
536 }
537
538 let name = components[0].as_os_str().to_string_lossy().to_string();
539 let is_last = components.len() == 1;
540
541 let current_path: PathBuf = full_path.components().take(depth + 1).collect();
543
544 let node_idx = nodes.iter().position(|n| n.name == name);
546
547 if is_last {
548 let status = status_map.get(full_path).copied().unwrap_or_default();
550 if node_idx.is_none() {
551 nodes.push(TreeNode::file(name, full_path, depth).with_status(status));
552 }
553 } else {
554 let idx = if let Some(idx) = node_idx {
556 idx
557 } else {
558 nodes.push(TreeNode::dir(name, current_path, depth));
559 nodes.len() - 1
560 };
561
562 insert_path(
563 &mut nodes[idx].children,
564 &components[1..],
565 depth + 1,
566 full_path,
567 status_map,
568 );
569 }
570}
571
572pub fn render_file_tree(
578 frame: &mut Frame,
579 area: Rect,
580 state: &mut FileTreeState,
581 title: &str,
582 focused: bool,
583) {
584 let block = Block::default()
585 .title(format!(" {} ", title))
586 .borders(Borders::ALL)
587 .border_style(if focused {
588 theme::focused_border()
589 } else {
590 theme::unfocused_border()
591 });
592
593 let inner = block.inner(area);
594 frame.render_widget(block, area);
595
596 if inner.height == 0 || inner.width == 0 {
597 return;
598 }
599
600 let visible_height = inner.height as usize;
601 state.update_scroll(visible_height);
602
603 let scroll_offset = state.scroll_offset();
605 let selected = state.selected_index();
606
607 let flat = state.flat_view().to_vec(); let flat_len = flat.len();
610
611 let lines: Vec<Line> = flat
612 .iter()
613 .enumerate()
614 .skip(scroll_offset)
615 .take(visible_height)
616 .map(|(i, entry)| {
617 let is_selected = i == selected;
618 render_entry(entry, is_selected, inner.width as usize)
619 })
620 .collect();
621
622 let paragraph = Paragraph::new(lines);
623 frame.render_widget(paragraph, inner);
624
625 if flat_len > visible_height {
627 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
628 .begin_symbol(None)
629 .end_symbol(None);
630
631 let mut scrollbar_state = ScrollbarState::new(flat_len).position(scroll_offset);
632
633 frame.render_stateful_widget(
634 scrollbar,
635 area.inner(ratatui::layout::Margin {
636 vertical: 1,
637 horizontal: 0,
638 }),
639 &mut scrollbar_state,
640 );
641 }
642}
643
644fn render_entry(entry: &FlatEntry, is_selected: bool, width: usize) -> Line<'static> {
646 let indent = " ".repeat(entry.depth);
647
648 let icon = if entry.is_dir {
650 if entry.is_expanded { "▾" } else { "▸" }
651 } else {
652 get_file_icon(&entry.name)
653 };
654
655 let (status_indicator, status_style) = match entry.git_status {
658 FileGitStatus::Staged => ("▍", theme::git_staged().add_modifier(Modifier::BOLD)),
659 FileGitStatus::Modified => ("▍", theme::git_modified()),
660 FileGitStatus::Untracked => ("▍", theme::git_untracked()),
661 FileGitStatus::Deleted => ("▍", theme::git_deleted()),
662 FileGitStatus::Renamed => ("▍", theme::git_staged()),
663 FileGitStatus::Conflict => ("▍", theme::error().add_modifier(Modifier::BOLD)),
664 FileGitStatus::Normal => (" ", Style::default()),
665 };
666
667 let marker = if is_selected { "›" } else { " " };
669 let marker_style = if is_selected {
670 Style::default()
671 .fg(theme::accent_primary())
672 .add_modifier(Modifier::BOLD)
673 } else {
674 Style::default()
675 };
676
677 let name_style = if is_selected {
679 match entry.git_status {
681 FileGitStatus::Staged => theme::git_staged()
682 .bg(theme::bg_highlight_color())
683 .add_modifier(Modifier::BOLD),
684 FileGitStatus::Modified => theme::git_modified().bg(theme::bg_highlight_color()),
685 FileGitStatus::Deleted => theme::git_deleted()
686 .bg(theme::bg_highlight_color())
687 .add_modifier(Modifier::DIM),
688 FileGitStatus::Untracked => theme::git_untracked().bg(theme::bg_highlight_color()),
689 FileGitStatus::Conflict => theme::error()
690 .bg(theme::bg_highlight_color())
691 .add_modifier(Modifier::BOLD),
692 _ => theme::selected(),
693 }
694 } else if entry.is_dir {
695 Style::default()
696 .fg(theme::accent_secondary())
697 .add_modifier(Modifier::BOLD)
698 } else {
699 match entry.git_status {
701 FileGitStatus::Staged => theme::git_staged().add_modifier(Modifier::BOLD),
702 FileGitStatus::Modified => theme::git_modified(),
703 FileGitStatus::Deleted => theme::git_deleted().add_modifier(Modifier::DIM),
704 FileGitStatus::Untracked => theme::git_untracked(),
705 FileGitStatus::Renamed => theme::git_staged(),
706 FileGitStatus::Conflict => theme::error().add_modifier(Modifier::BOLD),
707 FileGitStatus::Normal => Style::default().fg(theme::text_primary_color()),
708 }
709 };
710
711 let icon_style = if entry.is_dir {
713 Style::default().fg(theme::accent_secondary())
714 } else {
715 match entry.git_status {
716 FileGitStatus::Staged => theme::git_staged(),
717 FileGitStatus::Modified => theme::git_modified(),
718 FileGitStatus::Deleted => theme::git_deleted(),
719 FileGitStatus::Untracked => theme::git_untracked(),
720 _ => Style::default().fg(theme::text_dim_color()),
721 }
722 };
723
724 let fixed_width = 1 + 1 + 1 + indent.width() + 1 + 1;
727 let max_name_width = width.saturating_sub(fixed_width);
728
729 let display_name = truncate_width(&entry.name, max_name_width);
731
732 Line::from(vec![
733 Span::styled(status_indicator, status_style),
734 Span::styled(marker, marker_style),
735 Span::raw(" "),
736 Span::raw(indent),
737 Span::styled(format!("{} ", icon), icon_style),
738 Span::styled(display_name, name_style),
739 ])
740}
741
742fn get_file_icon(name: &str) -> &'static str {
744 let lower_name = name.to_lowercase();
746 if lower_name == "cargo.toml" || lower_name == "cargo.lock" {
747 return "◫";
748 }
749 if lower_name.starts_with("readme") {
750 return "◈";
751 }
752 if lower_name.starts_with("license") {
753 return "§";
754 }
755 if lower_name.starts_with(".git") {
756 return "⊙";
757 }
758 if lower_name == "dockerfile" || lower_name.starts_with("docker-compose") {
759 return "◲";
760 }
761 if lower_name == "makefile" {
762 return "⚙";
763 }
764
765 let ext = name.rsplit('.').next().unwrap_or("");
766 match ext.to_lowercase().as_str() {
767 "rs" => "●",
769 "toml" => "⚙",
771 "yaml" | "yml" => "⚙",
772 "json" => "◇",
773 "xml" => "◇",
774 "ini" | "cfg" | "conf" => "⚙",
775 "md" | "mdx" => "◈",
777 "txt" => "≡",
778 "pdf" => "▤",
779 "html" | "htm" => "◊",
781 "css" | "scss" | "sass" | "less" => "◊",
782 "js" | "mjs" | "cjs" => "◆",
783 "jsx" => "◆",
784 "ts" | "mts" | "cts" => "◇",
785 "tsx" => "◇",
786 "vue" => "◊",
787 "svelte" => "◊",
788 "py" | "pyi" => "◈",
790 "go" => "◈",
791 "rb" => "◈",
792 "java" | "class" | "jar" => "◈",
793 "kt" | "kts" => "◈",
794 "swift" => "◈",
795 "c" | "h" => "○",
796 "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "○",
797 "cs" => "◈",
798 "php" => "◈",
799 "lua" => "◈",
800 "r" => "◈",
801 "sql" => "◫",
802 "sh" | "bash" | "zsh" | "fish" => "▷",
804 "ps1" | "psm1" => "▷",
805 "csv" => "◫",
807 "db" | "sqlite" | "sqlite3" => "◫",
808 "png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" | "webp" => "◧",
810 "zip" | "tar" | "gz" | "rar" | "7z" => "▣",
812 "lock" => "◉",
814 "gitignore" | "gitattributes" | "gitmodules" => "⊙",
816 "env" | "env.local" | "env.development" | "env.production" => "◉",
818 _ => "◦",
820 }
821}