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