mq_edit/ui/
file_browser.rs1use 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#[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
39pub struct FileTree {
41 items: Vec<FileTreeItem>,
42 selected_index: usize,
43 root_path: PathBuf,
44 gitignore: Option<Gitignore>,
45}
46
47impl FileTree {
48 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 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 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 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 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 if self.is_ignored(&entry) {
105 continue;
106 }
107
108 self.items.push(FileTreeItem::new(entry, depth));
109 }
110 }
111
112 pub fn items(&self) -> &[FileTreeItem] {
114 &self.items
115 }
116
117 pub fn selected_index(&self) -> usize {
119 self.selected_index
120 }
121
122 pub fn selected_item(&self) -> Option<&FileTreeItem> {
124 self.items.get(self.selected_index)
125 }
126
127 pub fn move_up(&mut self) {
129 if self.selected_index > 0 {
130 self.selected_index -= 1;
131 }
132 }
133
134 pub fn move_down(&mut self) {
136 if self.selected_index + 1 < self.items.len() {
137 self.selected_index += 1;
138 }
139 }
140
141 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 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 if self.is_ignored(&entry) {
175 continue;
176 }
177
178 new_items.push(FileTreeItem::new(entry, depth));
179 }
180
181 for (i, item) in new_items.into_iter().enumerate() {
183 self.items.insert(insert_pos + i, item);
184 }
185 } else {
186 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 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
212pub 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}