Skip to main content

mcraw_tui/
file_browser.rs

1use 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
45/// How often (in seconds) the file browser re-lists the current directory.
46const 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(&current_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        // Add parent directory navigation
74        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    /// Re-read the current directory if enough time has passed since the last
147    /// refresh.  Preserves the selected index and checkbox selection state
148    /// across the re-read.
149    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        // Save selection state across refresh
157        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        // Restore checkbox selections
166        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    /// Collect paths of all selected .mcraw files, or the cursor file if none selected
201    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        // fallback: current entry if it's a .mcraw
210        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        // First entry should be ".." if not root
235        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); // might just be ".."
262        assert!(hidden_count_hidden >= 1);
263    }
264}