mcraw_tui/
file_browser.rs1use std::fs;
2use std::path::PathBuf;
3use std::time::Instant;
4
5use crate::file::McrawFileInfo;
6
7#[derive(Debug, Clone)]
8pub struct FileEntry {
9 pub path: PathBuf,
10 pub name: String,
11 pub is_dir: bool,
12 pub size: u64,
13 pub file_info: Option<McrawFileInfo>,
14 pub selected: bool,
15}
16
17impl FileEntry {
18 fn from_path(path: PathBuf) -> Self {
19 let name = path
20 .file_name()
21 .map(|f| f.to_string_lossy().into_owned())
22 .unwrap_or_default();
23 let is_dir = path.is_dir();
24 let size = path.metadata().map(|m| m.len()).unwrap_or(0);
25 FileEntry {
26 path,
27 name,
28 is_dir,
29 size,
30 file_info: None,
31 selected: false,
32 }
33 }
34}
35
36#[derive(Debug, Clone)]
37pub struct FileBrowser {
38 pub current_path: PathBuf,
39 pub entries: Vec<FileEntry>,
40 pub selected_index: usize,
41 pub show_hidden: bool,
42 last_refresh: Instant,
43}
44
45const REFRESH_INTERVAL_SECS: u64 = 2;
47
48impl FileBrowser {
49 pub fn new() -> Self {
50 let current_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
51 FileBrowser {
52 current_path: current_path.clone(),
53 entries: Self::list_dir(¤t_path, false),
54 selected_index: 0,
55 show_hidden: false,
56 last_refresh: Instant::now(),
57 }
58 }
59
60 pub fn from_path(path: PathBuf) -> Self {
61 FileBrowser {
62 current_path: path.clone(),
63 entries: Self::list_dir(&path, false),
64 selected_index: 0,
65 show_hidden: false,
66 last_refresh: Instant::now(),
67 }
68 }
69
70 pub fn list_dir(path: &PathBuf, include_hidden: bool) -> Vec<FileEntry> {
71 let mut entries = Vec::new();
72
73 if path.parent().is_some() && path.as_os_str().len() > 1 {
75 entries.push(FileEntry {
76 path: path.parent().unwrap().to_path_buf(),
77 name: "..".to_string(),
78 is_dir: true,
79 size: 0,
80 file_info: None,
81 selected: false,
82 });
83 }
84
85 if let Ok(read_dir) = fs::read_dir(path) {
86 let mut dir_entries: Vec<FileEntry> = read_dir
87 .filter_map(|e| e.ok())
88 .map(|e| FileEntry::from_path(e.path()))
89 .filter(|e| !e.name.starts_with('.') || include_hidden)
90 .collect();
91
92 dir_entries.sort_by(|a, b| {
93 a.is_dir.cmp(&b.is_dir).then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
94 });
95
96 entries.extend(dir_entries);
97 }
98
99 entries
100 }
101
102 pub fn navigate_down(&mut self) {
103 if self.selected_index < self.entries.len().saturating_sub(1) {
104 self.selected_index += 1;
105 }
106 }
107
108 pub fn navigate_up(&mut self) {
109 if self.selected_index > 0 {
110 self.selected_index -= 1;
111 }
112 }
113
114 pub fn enter(&mut self) {
115 if self.selected_index < self.entries.len() {
116 let entry = &self.entries[self.selected_index];
117 if entry.is_dir {
118 tracing::debug!("browser enter: navigating to {}", entry.path.display());
119 self.current_path = entry.path.clone();
120 self.entries = Self::list_dir(&self.current_path, self.show_hidden);
121 self.selected_index = 0;
122 }
123 }
124 }
125
126 pub fn go_up(&mut self) {
127 if self.selected_index < self.entries.len() {
128 let entry = &self.entries[self.selected_index];
129 if entry.name == ".." {
130 tracing::debug!("browser go_up: navigating to {}", entry.path.display());
131 self.current_path = entry.path.clone();
132 self.entries = Self::list_dir(&self.current_path, self.show_hidden);
133 self.selected_index = 0;
134 }
135 }
136 }
137
138 pub fn toggle_hidden(&mut self) {
139 self.show_hidden = !self.show_hidden;
140 tracing::debug!("browser toggle_hidden: show_hidden={}", self.show_hidden);
141 self.entries = Self::list_dir(&self.current_path, self.show_hidden);
142 self.selected_index = 0;
143 self.last_refresh = Instant::now();
144 }
145
146 pub fn try_refresh(&mut self) {
150 let now = Instant::now();
151 if now.duration_since(self.last_refresh).as_secs() < REFRESH_INTERVAL_SECS {
152 return;
153 }
154 self.last_refresh = now;
155
156 let old_selections: std::collections::HashMap<std::path::PathBuf, bool> = self.entries.iter()
158 .filter(|e| e.selected)
159 .map(|e| (e.path.clone(), e.selected))
160 .collect();
161 let selected_path = self.entries.get(self.selected_index).map(|e| e.path.clone());
162
163 self.entries = Self::list_dir(&self.current_path, self.show_hidden);
164
165 for entry in self.entries.iter_mut() {
167 if let Some(&sel) = old_selections.get(&entry.path) {
168 entry.selected = sel;
169 }
170 }
171
172 self.selected_index = selected_path
173 .and_then(|p| self.entries.iter().position(|e| e.path == p))
174 .unwrap_or(0);
175 }
176
177 pub fn selected_entry(&self) -> Option<&FileEntry> {
178 self.entries.get(self.selected_index)
179 }
180
181 pub fn selected_file_info(&self) -> Option<&McrawFileInfo> {
182 self.selected_entry()
183 .and_then(|e| e.file_info.as_ref())
184 }
185
186 pub fn current_path_display(&self) -> String {
187 self.current_path
188 .to_string_lossy()
189 .to_string()
190 }
191
192 pub fn toggle_selection(&mut self) {
193 if let Some(entry) = self.entries.get_mut(self.selected_index) {
194 if entry.name.to_lowercase().ends_with(".mcraw") {
195 entry.selected = !entry.selected;
196 }
197 }
198 }
199
200 pub fn selected_mcraw_paths(&self) -> Vec<String> {
202 let checked: Vec<String> = self.entries.iter()
203 .filter(|e| e.selected && e.name.to_lowercase().ends_with(".mcraw"))
204 .map(|e| e.path.to_string_lossy().to_string())
205 .collect();
206 if !checked.is_empty() {
207 return checked;
208 }
209 self.selected_entry()
211 .filter(|e| e.name.to_lowercase().ends_with(".mcraw"))
212 .map(|e| e.path.to_string_lossy().to_string())
213 .into_iter()
214 .collect()
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn test_browser_new() {
224 let browser = FileBrowser::new();
225 assert!(!browser.current_path.as_os_str().is_empty());
226 assert!(!browser.show_hidden);
227 }
228
229 #[test]
230 fn test_list_dir() {
231 let dir = std::env::current_dir().unwrap();
232 let entries = FileBrowser::list_dir(&dir, false);
233 assert!(!entries.is_empty());
234 if dir.as_os_str().len() > 1 {
236 assert_eq!(entries[0].name, "..");
237 assert!(entries[0].is_dir);
238 }
239 }
240
241 #[test]
242 fn test_list_dir_hidden() {
243 use std::fs::File;
244 use std::io::Write;
245
246 let temp_dir = std::env::temp_dir().join("mcraw-tui-test-hidden");
247 let _ = fs::remove_dir_all(&temp_dir);
248 fs::create_dir_all(&temp_dir).unwrap();
249
250 File::create(temp_dir.join(".hidden_file")).unwrap();
251 File::create(temp_dir.join("visible_file")).unwrap();
252
253 let entries_visible = FileBrowser::list_dir(&temp_dir, false);
254 let hidden_count_visible = entries_visible.iter().filter(|e| e.name.starts_with('.')).count();
255
256 let entries_hidden = FileBrowser::list_dir(&temp_dir, true);
257 let hidden_count_hidden = entries_hidden.iter().filter(|e| e.name.starts_with('.')).count();
258
259 let _ = fs::remove_dir_all(&temp_dir);
260
261 assert!(hidden_count_visible == 0 || hidden_count_visible == 1); assert!(hidden_count_hidden >= 1);
263 }
264}