Skip to main content

mq_edit/ui/
file_browser.rs

1use ignore::gitignore::{Gitignore, GitignoreBuilder};
2use ratatui::{
3    buffer::Buffer,
4    layout::Rect,
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7    widgets::{Block, Borders, List, ListItem, Widget},
8};
9use std::path::{Path, PathBuf};
10
11/// Represents an item in the file tree
12#[derive(Debug, Clone)]
13pub struct FileTreeItem {
14    pub path: PathBuf,
15    pub name: String,
16    pub is_dir: bool,
17    pub depth: usize,
18    pub expanded: bool,
19}
20
21impl FileTreeItem {
22    pub fn new(path: PathBuf, depth: usize) -> Self {
23        let name = path
24            .file_name()
25            .map(|n| n.to_string_lossy().to_string())
26            .unwrap_or_else(|| path.to_string_lossy().to_string());
27        let is_dir = path.is_dir();
28
29        Self {
30            path,
31            name,
32            is_dir,
33            depth,
34            expanded: false,
35        }
36    }
37}
38
39/// File tree structure for navigating directories
40pub struct FileTree {
41    items: Vec<FileTreeItem>,
42    selected_index: usize,
43    root_path: PathBuf,
44    gitignore: Option<Gitignore>,
45}
46
47impl FileTree {
48    /// Create a new file tree from a root directory
49    pub fn new(root_path: impl AsRef<Path>) -> Self {
50        let root_path = root_path.as_ref().to_path_buf();
51        let gitignore = Self::load_gitignore(&root_path);
52        let mut tree = Self {
53            items: Vec::new(),
54            selected_index: 0,
55            root_path: root_path.clone(),
56            gitignore,
57        };
58
59        tree.load_directory(&root_path, 0);
60        tree
61    }
62
63    /// Load .gitignore from root directory
64    fn load_gitignore(root_path: &Path) -> Option<Gitignore> {
65        let gitignore_path = root_path.join(".gitignore");
66        if gitignore_path.exists() {
67            let mut builder = GitignoreBuilder::new(root_path);
68            builder.add(&gitignore_path);
69            builder.build().ok()
70        } else {
71            None
72        }
73    }
74
75    /// Check if a path should be ignored
76    fn is_ignored(&self, path: &Path) -> bool {
77        if let Some(ref gitignore) = self.gitignore {
78            gitignore.matched(path, path.is_dir()).is_ignore()
79        } else {
80            false
81        }
82    }
83
84    /// Load directory contents at a specific depth
85    fn load_directory(&mut self, path: &Path, depth: usize) {
86        if !path.is_dir() {
87            return;
88        }
89
90        let mut entries: Vec<_> = std::fs::read_dir(path)
91            .ok()
92            .map(|entries| entries.filter_map(|e| e.ok()).map(|e| e.path()).collect())
93            .unwrap_or_default();
94
95        // Sort: directories first, then files, alphabetically
96        entries.sort_by(|a, b| match (a.is_dir(), b.is_dir()) {
97            (true, false) => std::cmp::Ordering::Less,
98            (false, true) => std::cmp::Ordering::Greater,
99            _ => a.file_name().cmp(&b.file_name()),
100        });
101
102        for entry in entries {
103            // Skip files/directories matching .gitignore
104            if self.is_ignored(&entry) {
105                continue;
106            }
107
108            self.items.push(FileTreeItem::new(entry, depth));
109        }
110    }
111
112    /// Get all items
113    pub fn items(&self) -> &[FileTreeItem] {
114        &self.items
115    }
116
117    /// Get selected index
118    pub fn selected_index(&self) -> usize {
119        self.selected_index
120    }
121
122    /// Get selected item
123    pub fn selected_item(&self) -> Option<&FileTreeItem> {
124        self.items.get(self.selected_index)
125    }
126
127    /// Move selection up
128    pub fn move_up(&mut self) {
129        if self.selected_index > 0 {
130            self.selected_index -= 1;
131        }
132    }
133
134    /// Move selection down
135    pub fn move_down(&mut self) {
136        if self.selected_index + 1 < self.items.len() {
137            self.selected_index += 1;
138        }
139    }
140
141    /// Toggle directory expansion
142    pub fn toggle_expand(&mut self) {
143        if let Some(item) = self.items.get_mut(self.selected_index)
144            && item.is_dir
145        {
146            item.expanded = !item.expanded;
147
148            if item.expanded {
149                // Load subdirectory contents
150                let path = item.path.clone();
151                let depth = item.depth + 1;
152                let insert_pos = self.selected_index + 1;
153
154                let mut new_items = Vec::new();
155                let mut entries: Vec<_> = std::fs::read_dir(&path)
156                    .ok()
157                    .map(|entries| entries.filter_map(|e| e.ok()).map(|e| e.path()).collect())
158                    .unwrap_or_default();
159
160                entries.sort_by(|a, b| match (a.is_dir(), b.is_dir()) {
161                    (true, false) => std::cmp::Ordering::Less,
162                    (false, true) => std::cmp::Ordering::Greater,
163                    _ => a.file_name().cmp(&b.file_name()),
164                });
165
166                for entry in entries {
167                    if let Some(name) = entry.file_name()
168                        && name.to_string_lossy().starts_with('.')
169                    {
170                        continue;
171                    }
172
173                    // Skip files/directories matching .gitignore
174                    if self.is_ignored(&entry) {
175                        continue;
176                    }
177
178                    new_items.push(FileTreeItem::new(entry, depth));
179                }
180
181                // Insert new items after the current directory
182                for (i, item) in new_items.into_iter().enumerate() {
183                    self.items.insert(insert_pos + i, item);
184                }
185            } else {
186                // Collapse: remove all child items
187                let item_depth = item.depth;
188                let mut remove_count = 0;
189
190                for i in (self.selected_index + 1)..self.items.len() {
191                    if self.items[i].depth <= item_depth {
192                        break;
193                    }
194                    remove_count += 1;
195                }
196
197                for _ in 0..remove_count {
198                    self.items.remove(self.selected_index + 1);
199                }
200            }
201        }
202    }
203
204    /// Refresh the tree
205    pub fn refresh(&mut self) {
206        self.items.clear();
207        self.selected_index = 0;
208        self.load_directory(&self.root_path.clone(), 0);
209    }
210}
211
212/// File browser widget
213pub struct FileBrowserWidget<'a> {
214    tree: &'a FileTree,
215    title: &'a str,
216}
217
218impl<'a> FileBrowserWidget<'a> {
219    pub fn new(tree: &'a FileTree) -> Self {
220        Self {
221            tree,
222            title: "Files",
223        }
224    }
225
226    pub fn with_title(mut self, title: &'a str) -> Self {
227        self.title = title;
228        self
229    }
230}
231
232impl Widget for FileBrowserWidget<'_> {
233    fn render(self, area: Rect, buf: &mut Buffer) {
234        let items: Vec<ListItem> = self
235            .tree
236            .items()
237            .iter()
238            .enumerate()
239            .map(|(idx, item)| {
240                let indent = "  ".repeat(item.depth);
241                let icon = if item.is_dir {
242                    if item.expanded { "▾ " } else { "▸ " }
243                } else {
244                    "· "
245                };
246
247                let style = if idx == self.tree.selected_index() {
248                    Style::default()
249                        .fg(Color::Black)
250                        .bg(Color::Cyan)
251                        .add_modifier(Modifier::BOLD)
252                } else if item.is_dir {
253                    Style::default().fg(Color::Blue)
254                } else {
255                    Style::default().fg(Color::White)
256                };
257
258                let content = format!("{}{}{}", indent, icon, item.name);
259                ListItem::new(Line::from(vec![Span::styled(content, style)]))
260            })
261            .collect();
262
263        let list = List::new(items)
264            .block(
265                Block::default()
266                    .borders(Borders::ALL)
267                    .title(self.title)
268                    .border_style(Style::default().fg(Color::Gray)),
269            )
270            .style(Style::default().fg(Color::White).bg(Color::Black));
271
272        list.render(area, buf);
273    }
274}