Skip to main content

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    #[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    /// Get the style for this status
52    #[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// ═══════════════════════════════════════════════════════════════════════════════
67// Tree Node
68// ═══════════════════════════════════════════════════════════════════════════════
69
70/// A node in the file tree
71#[derive(Debug, Clone)]
72pub struct TreeNode {
73    /// File or directory name
74    pub name: String,
75    /// Full path from repository root
76    pub path: PathBuf,
77    /// Is this a directory?
78    pub is_dir: bool,
79    /// Git status (for files)
80    pub git_status: FileGitStatus,
81    /// Depth in tree (for indentation)
82    pub depth: usize,
83    /// Children (for directories)
84    pub children: Vec<TreeNode>,
85}
86
87impl TreeNode {
88    /// Create a new file node
89    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    /// Create a new directory node
101    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    /// Set git status
113    #[must_use]
114    pub fn with_status(mut self, status: FileGitStatus) -> Self {
115        self.git_status = status;
116        self
117    }
118
119    /// Add a child node
120    pub fn add_child(&mut self, child: TreeNode) {
121        self.children.push(child);
122    }
123
124    /// Sort children (directories first, then alphabetically)
125    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// ═══════════════════════════════════════════════════════════════════════════════
138// Flattened Entry (for rendering)
139// ═══════════════════════════════════════════════════════════════════════════════
140
141/// A flattened view of the tree for rendering
142#[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// ═══════════════════════════════════════════════════════════════════════════════
154// File Tree State
155// ═══════════════════════════════════════════════════════════════════════════════
156
157/// File tree widget state
158#[derive(Debug, Clone)]
159pub struct FileTreeState {
160    /// Root nodes of the tree
161    root: Vec<TreeNode>,
162    /// Expanded directories (by path)
163    expanded: HashSet<PathBuf>,
164    /// Currently selected index in flat view
165    selected: usize,
166    /// Scroll offset
167    scroll_offset: usize,
168    /// Cached flat view
169    flat_cache: Vec<FlatEntry>,
170    /// Cache is dirty flag
171    cache_dirty: bool,
172}
173
174impl Default for FileTreeState {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180impl FileTreeState {
181    /// Create new empty file tree state
182    #[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    /// Check if tree is empty
195    #[must_use]
196    pub fn is_empty(&self) -> bool {
197        self.root.is_empty()
198    }
199
200    /// Set root nodes
201    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    /// Build tree from a list of file paths
209    #[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        // Build status lookup
215        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        // Sort all nodes
223        for node in &mut root_nodes {
224            node.sort_children();
225        }
226
227        // Sort root level
228        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        // Auto-expand first 2 levels for visibility
237        state.expand_to_depth(2);
238        state
239    }
240
241    /// Get flat view (rebuilds cache if needed)
242    pub fn flat_view(&mut self) -> &[FlatEntry] {
243        if self.cache_dirty {
244            self.rebuild_cache();
245        }
246        &self.flat_cache
247    }
248
249    /// Rebuild the flat cache
250    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    /// Flatten a node into the cache
260    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    /// Get selected entry
282    pub fn selected_entry(&mut self) -> Option<FlatEntry> {
283        self.ensure_cache();
284        self.flat_cache.get(self.selected).cloned()
285    }
286
287    /// Ensure cache is up to date
288    fn ensure_cache(&mut self) {
289        if self.cache_dirty {
290            self.rebuild_cache();
291        }
292    }
293
294    /// Get selected path
295    pub fn selected_path(&mut self) -> Option<PathBuf> {
296        self.selected_entry().map(|e| e.path)
297    }
298
299    /// Select an entry by path
300    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    /// Move selection up
313    pub fn select_prev(&mut self) {
314        if self.selected > 0 {
315            self.selected -= 1;
316            self.ensure_visible();
317        }
318    }
319
320    /// Move selection down
321    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    /// Jump to first item
330    pub fn select_first(&mut self) {
331        self.selected = 0;
332        self.scroll_offset = 0;
333    }
334
335    /// Jump to last item
336    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    /// Page up
344    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    /// Page down
350    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    /// Toggle expansion of selected item
357    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    /// Expand selected directory
371    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    /// Collapse selected directory (or parent)
382    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                // Find and select parent
389                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                    // Find parent in flat view and select it
394                    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    /// Expand all directories
407    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    /// Collapse all directories
422    pub fn collapse_all(&mut self) {
423        self.expanded.clear();
424        self.cache_dirty = true;
425        self.selected = 0;
426    }
427
428    /// Expand directories up to a certain depth
429    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    /// Ensure selected item is visible (stub for future scroll viewport tracking)
452    #[allow(clippy::unused_self)]
453    fn ensure_visible(&mut self) {
454        // Will be adjusted based on render area height
455    }
456
457    /// Update scroll offset based on area height
458    pub fn update_scroll(&mut self, visible_height: usize) {
459        if visible_height == 0 {
460            return;
461        }
462
463        // Ensure selected is within scroll view
464        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    /// Get current scroll offset
472    #[must_use]
473    pub fn scroll_offset(&self) -> usize {
474        self.scroll_offset
475    }
476
477    /// Get selected index
478    #[must_use]
479    pub fn selected_index(&self) -> usize {
480        self.selected
481    }
482
483    /// Select an item by visible row (for mouse clicks)
484    /// Returns true if selection changed, false otherwise
485    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    /// Check if the clicked row matches the currently selected item
498    /// Used for double-click detection
499    #[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    /// Handle mouse click at a specific row within the visible area.
506    /// Returns a tuple of (`selection_changed`, `is_directory`) for the caller to handle.
507    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
526/// Helper to insert a path into the tree structure
527fn 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    // Build path up to this directory (take first depth+1 components from full_path)
542    let current_path: PathBuf = full_path.components().take(depth + 1).collect();
543
544    // Find or create node
545    let node_idx = nodes.iter().position(|n| n.name == name);
546
547    if is_last {
548        // This is a file
549        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        // This is a directory
555        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
572// ═══════════════════════════════════════════════════════════════════════════════
573// Rendering
574// ═══════════════════════════════════════════════════════════════════════════════
575
576/// Render the file tree widget
577pub 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    // Get values we need before borrowing flat view
604    let scroll_offset = state.scroll_offset();
605    let selected = state.selected_index();
606
607    // Now get the flat view
608    let flat = state.flat_view().to_vec(); // Clone to avoid borrow issues
609    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    // Render scrollbar if needed
626    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
644/// Render a single tree entry
645fn render_entry(entry: &FlatEntry, is_selected: bool, width: usize) -> Line<'static> {
646    let indent = "  ".repeat(entry.depth);
647
648    // Icon with nice Unicode symbols
649    let icon = if entry.is_dir {
650        if entry.is_expanded { "▾" } else { "▸" }
651    } else {
652        get_file_icon(&entry.name)
653    };
654
655    // Git status indicator with Unicode symbols - positioned at start for visibility
656    // Uses theme git_* styles for consistent, harmonized colors
657    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    // Selection marker
668    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    // Name style - color coded by git status using theme git_* styles
678    let name_style = if is_selected {
679        // When selected, use git status color with selection background
680        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        // Color filename by git status using theme styles
700        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    // Icon style matches name for cohesion - use theme git_* styles
712    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    // Calculate available width for name using unicode width
725    // Format: status (1) + ">" (1) + " " (1) + indent + icon (1) + " " (1) + name
726    let fixed_width = 1 + 1 + 1 + indent.width() + 1 + 1;
727    let max_name_width = width.saturating_sub(fixed_width);
728
729    // Truncate name if needed (using unicode width)
730    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
742/// Get icon for file based on extension (Unicode symbols, no emoji)
743fn get_file_icon(name: &str) -> &'static str {
744    // Check for special filenames first
745    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        // Rust
768        "rs" => "●",
769        // Config files
770        "toml" => "⚙",
771        "yaml" | "yml" => "⚙",
772        "json" => "◇",
773        "xml" => "◇",
774        "ini" | "cfg" | "conf" => "⚙",
775        // Documentation
776        "md" | "mdx" => "◈",
777        "txt" => "≡",
778        "pdf" => "▤",
779        // Web
780        "html" | "htm" => "◊",
781        "css" | "scss" | "sass" | "less" => "◊",
782        "js" | "mjs" | "cjs" => "◆",
783        "jsx" => "◆",
784        "ts" | "mts" | "cts" => "◇",
785        "tsx" => "◇",
786        "vue" => "◊",
787        "svelte" => "◊",
788        // Programming languages
789        "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        // Shell
803        "sh" | "bash" | "zsh" | "fish" => "▷",
804        "ps1" | "psm1" => "▷",
805        // Data
806        "csv" => "◫",
807        "db" | "sqlite" | "sqlite3" => "◫",
808        // Images
809        "png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" | "webp" => "◧",
810        // Archives
811        "zip" | "tar" | "gz" | "rar" | "7z" => "▣",
812        // Lock files
813        "lock" => "◉",
814        // Git
815        "gitignore" | "gitattributes" | "gitmodules" => "⊙",
816        // Env
817        "env" | "env.local" | "env.development" | "env.production" => "◉",
818        // Default
819        _ => "◦",
820    }
821}