git_iris/studio/components/
file_tree.rs

1//! File tree component for Iris Studio
2//!
3//! Hierarchical file browser with git status indicators.
4
5use 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// ═══════════════════════════════════════════════════════════════════════════════
20// Git Status
21// ═══════════════════════════════════════════════════════════════════════════════
22
23/// Git status for a file
24#[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    /// Get the indicator character for this status
38    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    /// Get the style for this status
51    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// ═══════════════════════════════════════════════════════════════════════════════
65// Tree Node
66// ═══════════════════════════════════════════════════════════════════════════════
67
68/// A node in the file tree
69#[derive(Debug, Clone)]
70pub struct TreeNode {
71    /// File or directory name
72    pub name: String,
73    /// Full path from repository root
74    pub path: PathBuf,
75    /// Is this a directory?
76    pub is_dir: bool,
77    /// Git status (for files)
78    pub git_status: FileGitStatus,
79    /// Depth in tree (for indentation)
80    pub depth: usize,
81    /// Children (for directories)
82    pub children: Vec<TreeNode>,
83}
84
85impl TreeNode {
86    /// Create a new file node
87    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    /// Create a new directory node
99    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    /// Set git status
111    pub fn with_status(mut self, status: FileGitStatus) -> Self {
112        self.git_status = status;
113        self
114    }
115
116    /// Add a child node
117    pub fn add_child(&mut self, child: TreeNode) {
118        self.children.push(child);
119    }
120
121    /// Sort children (directories first, then alphabetically)
122    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// ═══════════════════════════════════════════════════════════════════════════════
135// Flattened Entry (for rendering)
136// ═══════════════════════════════════════════════════════════════════════════════
137
138/// A flattened view of the tree for rendering
139#[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// ═══════════════════════════════════════════════════════════════════════════════
151// File Tree State
152// ═══════════════════════════════════════════════════════════════════════════════
153
154/// File tree widget state
155#[derive(Debug, Clone)]
156pub struct FileTreeState {
157    /// Root nodes of the tree
158    root: Vec<TreeNode>,
159    /// Expanded directories (by path)
160    expanded: HashSet<PathBuf>,
161    /// Currently selected index in flat view
162    selected: usize,
163    /// Scroll offset
164    scroll_offset: usize,
165    /// Cached flat view
166    flat_cache: Vec<FlatEntry>,
167    /// Cache is dirty flag
168    cache_dirty: bool,
169}
170
171impl Default for FileTreeState {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177impl FileTreeState {
178    /// Create new empty file tree state
179    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    /// Check if tree is empty
191    pub fn is_empty(&self) -> bool {
192        self.root.is_empty()
193    }
194
195    /// Set root nodes
196    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    /// Build tree from a list of file paths
204    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        // Build status lookup
209        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        // Sort all nodes
217        for node in &mut root_nodes {
218            node.sort_children();
219        }
220
221        // Sort root level
222        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        // Auto-expand first 2 levels for visibility
231        state.expand_to_depth(2);
232        state
233    }
234
235    /// Get flat view (rebuilds cache if needed)
236    pub fn flat_view(&mut self) -> &[FlatEntry] {
237        if self.cache_dirty {
238            self.rebuild_cache();
239        }
240        &self.flat_cache
241    }
242
243    /// Rebuild the flat cache
244    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    /// Flatten a node into the cache
254    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    /// Get selected entry
276    pub fn selected_entry(&mut self) -> Option<FlatEntry> {
277        self.ensure_cache();
278        self.flat_cache.get(self.selected).cloned()
279    }
280
281    /// Ensure cache is up to date
282    fn ensure_cache(&mut self) {
283        if self.cache_dirty {
284            self.rebuild_cache();
285        }
286    }
287
288    /// Get selected path
289    pub fn selected_path(&mut self) -> Option<PathBuf> {
290        self.selected_entry().map(|e| e.path)
291    }
292
293    /// Move selection up
294    pub fn select_prev(&mut self) {
295        if self.selected > 0 {
296            self.selected -= 1;
297            self.ensure_visible();
298        }
299    }
300
301    /// Move selection down
302    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    /// Jump to first item
311    pub fn select_first(&mut self) {
312        self.selected = 0;
313        self.scroll_offset = 0;
314    }
315
316    /// Jump to last item
317    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    /// Page up
325    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    /// Page down
331    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    /// Toggle expansion of selected item
338    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    /// Expand selected directory
352    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    /// Collapse selected directory (or parent)
363    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                // Find and select parent
370                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                    // Find parent in flat view and select it
375                    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    /// Expand all directories
388    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    /// Collapse all directories
403    pub fn collapse_all(&mut self) {
404        self.expanded.clear();
405        self.cache_dirty = true;
406        self.selected = 0;
407    }
408
409    /// Expand directories up to a certain depth
410    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    /// Ensure selected item is visible (stub for future scroll viewport tracking)
433    #[allow(clippy::unused_self)]
434    fn ensure_visible(&mut self) {
435        // Will be adjusted based on render area height
436    }
437
438    /// Update scroll offset based on area height
439    pub fn update_scroll(&mut self, visible_height: usize) {
440        if visible_height == 0 {
441            return;
442        }
443
444        // Ensure selected is within scroll view
445        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    /// Get current scroll offset
453    pub fn scroll_offset(&self) -> usize {
454        self.scroll_offset
455    }
456
457    /// Get selected index
458    pub fn selected_index(&self) -> usize {
459        self.selected
460    }
461
462    /// Select an item by visible row (for mouse clicks)
463    /// Returns true if selection changed, false otherwise
464    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    /// Check if the clicked row matches the currently selected item
477    /// Used for double-click detection
478    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    /// Handle mouse click at a specific row within the visible area.
484    /// Returns a tuple of (`selection_changed`, `is_directory`) for the caller to handle.
485    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
504/// Helper to insert a path into the tree structure
505fn 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    // Build path up to this directory (take first depth+1 components from full_path)
520    let current_path: PathBuf = full_path.components().take(depth + 1).collect();
521
522    // Find or create node
523    let node_idx = nodes.iter().position(|n| n.name == name);
524
525    if is_last {
526        // This is a file
527        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        // This is a directory
533        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
550// ═══════════════════════════════════════════════════════════════════════════════
551// Rendering
552// ═══════════════════════════════════════════════════════════════════════════════
553
554/// Render the file tree widget
555pub 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    // Get values we need before borrowing flat view
582    let scroll_offset = state.scroll_offset();
583    let selected = state.selected_index();
584
585    // Now get the flat view
586    let flat = state.flat_view().to_vec(); // Clone to avoid borrow issues
587    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    // Render scrollbar if needed
604    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
622/// Render a single tree entry
623fn render_entry(entry: &FlatEntry, is_selected: bool, width: usize) -> Line<'static> {
624    let indent = "  ".repeat(entry.depth);
625
626    // Icon with nice Unicode symbols
627    let icon = if entry.is_dir {
628        if entry.is_expanded { "▾" } else { "▸" }
629    } else {
630        get_file_icon(&entry.name)
631    };
632
633    // Git status indicator with Unicode symbols - positioned at start for visibility
634    // Uses theme git_* styles for consistent, harmonized colors
635    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    // Selection marker
646    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    // Name style - color coded by git status using theme git_* styles
656    let name_style = if is_selected {
657        // When selected, use git status color with selection background
658        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        // Color filename by git status using theme styles
678        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    // Icon style matches name for cohesion - use theme git_* styles
690    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    // Calculate available width for name using unicode width
703    // Format: status (1) + ">" (1) + " " (1) + indent + icon (1) + " " (1) + name
704    let fixed_width = 1 + 1 + 1 + indent.width() + 1 + 1;
705    let max_name_width = width.saturating_sub(fixed_width);
706
707    // Truncate name if needed (using unicode width)
708    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
720/// Get icon for file based on extension (Unicode symbols, no emoji)
721fn get_file_icon(name: &str) -> &'static str {
722    // Check for special filenames first
723    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        // Rust
746        "rs" => "●",
747        // Config files
748        "toml" => "⚙",
749        "yaml" | "yml" => "⚙",
750        "json" => "◇",
751        "xml" => "◇",
752        "ini" | "cfg" | "conf" => "⚙",
753        // Documentation
754        "md" | "mdx" => "◈",
755        "txt" => "≡",
756        "pdf" => "▤",
757        // Web
758        "html" | "htm" => "◊",
759        "css" | "scss" | "sass" | "less" => "◊",
760        "js" | "mjs" | "cjs" => "◆",
761        "jsx" => "◆",
762        "ts" | "mts" | "cts" => "◇",
763        "tsx" => "◇",
764        "vue" => "◊",
765        "svelte" => "◊",
766        // Programming languages
767        "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        // Shell
781        "sh" | "bash" | "zsh" | "fish" => "▷",
782        "ps1" | "psm1" => "▷",
783        // Data
784        "csv" => "◫",
785        "db" | "sqlite" | "sqlite3" => "◫",
786        // Images
787        "png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" | "webp" => "◧",
788        // Archives
789        "zip" | "tar" | "gz" | "rar" | "7z" => "▣",
790        // Lock files
791        "lock" => "◉",
792        // Git
793        "gitignore" | "gitattributes" | "gitmodules" => "⊙",
794        // Env
795        "env" | "env.local" | "env.development" | "env.production" => "◉",
796        // Default
797        _ => "◦",
798    }
799}