1use super::list_viewer::{ListViewer, ListViewerState};
19use super::view::{View, ViewCore};
20use crate::core::event::{Event, EventType};
21use crate::core::geometry::Rect;
22use crate::core::state::State;
23use crate::terminal::Terminal;
24use std::fs;
25use std::path::{Path, PathBuf};
26use std::time::SystemTime;
27
28#[derive(Clone, Debug)]
30pub struct FileEntry {
31 pub name: String,
32 pub path: PathBuf,
33 pub is_dir: bool,
34 pub size: u64,
35 pub modified: Option<SystemTime>,
36}
37
38impl FileEntry {
39 pub fn from_dir_entry(entry: &fs::DirEntry) -> std::io::Result<Self> {
41 let metadata = entry.metadata()?;
42 let name = entry.file_name().to_string_lossy().to_string();
43 let path = entry.path();
44 let is_dir = metadata.is_dir();
45 let size = metadata.len();
46 let modified = metadata.modified().ok();
47
48 Ok(Self {
49 name,
50 path,
51 is_dir,
52 size,
53 modified,
54 })
55 }
56
57 pub fn display_name(&self) -> String {
59 if self.is_dir {
60 format!("[{}]", self.name)
61 } else {
62 self.name.clone()
63 }
64 }
65
66 pub fn size_string(&self) -> String {
68 if self.is_dir {
69 "<DIR>".to_string()
70 } else if self.size < 1024 {
71 format!("{} B", self.size)
72 } else if self.size < 1024 * 1024 {
73 format!("{} KB", self.size / 1024)
74 } else if self.size < 1024 * 1024 * 1024 {
75 format!("{} MB", self.size / (1024 * 1024))
76 } else {
77 format!("{} GB", self.size / (1024 * 1024 * 1024))
78 }
79 }
80}
81
82pub struct FileList {
86 core: ViewCore,
87 list_state: ListViewerState,
88 files: Vec<FileEntry>,
89 current_path: PathBuf,
90 wildcard: String,
91 show_hidden: bool,
92}
93
94impl FileList {
95 pub fn new(bounds: Rect, path: &Path) -> Self {
97 Self {
98 core: ViewCore {
99 bounds,
100 state: State::empty(),
101 palette_chain: None,
102 ..ViewCore::default()
103 },
104 list_state: ListViewerState::new(),
105 files: Vec::new(),
106 current_path: path.to_path_buf(),
107 wildcard: "*".to_string(),
108 show_hidden: false,
109 }
110 }
111
112 pub fn set_wildcard(&mut self, wildcard: &str) {
114 self.wildcard = wildcard.to_string();
115 self.refresh();
116 }
117
118 pub fn set_show_hidden(&mut self, show: bool) {
120 self.show_hidden = show;
121 self.refresh();
122 }
123
124 pub fn current_path(&self) -> &Path {
126 &self.current_path
127 }
128
129 pub fn change_dir(&mut self, path: &Path) -> std::io::Result<()> {
131 let canonical = fs::canonicalize(path)?;
132 self.current_path = canonical;
133 self.refresh();
134 Ok(())
135 }
136
137 pub fn refresh(&mut self) {
139 self.files.clear();
140
141 if self.current_path.parent().is_some() {
143 self.files.push(FileEntry {
144 name: "..".to_string(),
145 path: self.current_path.parent().unwrap().to_path_buf(),
146 is_dir: true,
147 size: 0,
148 modified: None,
149 });
150 }
151
152 if let Ok(entries) = fs::read_dir(&self.current_path) {
154 let mut file_entries: Vec<FileEntry> = entries
155 .filter_map(std::result::Result::ok)
156 .filter_map(|e| FileEntry::from_dir_entry(&e).ok())
157 .filter(|entry| {
158 if !self.show_hidden && entry.name.starts_with('.') {
160 return false;
161 }
162 if entry.is_dir {
164 return true;
165 }
166 self.matches_wildcard(&entry.name)
168 })
169 .collect();
170
171 file_entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
173 (true, false) => std::cmp::Ordering::Less,
174 (false, true) => std::cmp::Ordering::Greater,
175 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
176 });
177
178 self.files.extend(file_entries);
179 }
180
181 self.list_state.set_range(self.files.len());
183 if let Some(focused) = self.list_state.focused {
184 if focused >= self.files.len() {
185 self.list_state.focused = Some(0);
186 }
187 }
188 }
189
190 fn matches_wildcard(&self, filename: &str) -> bool {
192 if self.wildcard == "*" {
193 return true;
194 }
195
196 if let Some(pattern) = self.wildcard.strip_prefix('*') {
198 return filename.ends_with(pattern);
199 }
200
201 filename == self.wildcard
203 }
204
205 pub fn get_focused_entry(&self) -> Option<&FileEntry> {
207 let idx = self.list_state.focused?;
208 self.files.get(idx)
209 }
210
211 pub fn get_selected_file(&self) -> Option<PathBuf> {
213 let entry = self.get_focused_entry()?;
214 if entry.is_dir {
215 None
216 } else {
217 Some(entry.path.clone())
218 }
219 }
220
221 pub fn enter_focused_dir(&mut self) -> std::io::Result<bool> {
223 let path = if let Some(entry) = self.get_focused_entry() {
224 entry.is_dir.then(|| entry.path.clone())
225 } else {
226 None
227 };
228
229 if let Some(path) = path {
230 self.change_dir(&path)?;
231 Ok(true)
232 } else {
233 Ok(false)
234 }
235 }
236
237 pub fn file_count(&self) -> usize {
239 self.files.len()
240 }
241}
242
243impl ListViewer for FileList {
244 fn list_state(&self) -> &ListViewerState {
245 &self.list_state
246 }
247
248 fn list_state_mut(&mut self) -> &mut ListViewerState {
249 &mut self.list_state
250 }
251
252 fn get_text(&self, item: usize, _max_len: usize) -> String {
253 self.files
254 .get(item)
255 .map(FileEntry::display_name)
256 .unwrap_or_default()
257 }
258}
259
260impl View for FileList {
261 fn core(&self) -> &ViewCore {
262 &self.core
263 }
264
265 fn core_mut(&mut self) -> &mut ViewCore {
266 &mut self.core
267 }
268
269 fn draw(&mut self, terminal: &mut Terminal) {
270 let width = self.core.bounds.width_clamped() as usize;
271 let height = self.core.bounds.height_clamped() as usize;
272
273 self.list_state.set_range(self.files.len());
274
275 for y in 0..height {
276 let item_idx = self.list_state.top_item + y;
277
278 let (text, color) = if item_idx < self.files.len() {
279 let text = self.get_text(item_idx, width);
280 let is_focused = self.is_focused() && Some(item_idx) == self.list_state.focused;
281 let color = if is_focused {
282 crate::core::palette::colors::LISTBOX_FOCUSED
283 } else {
284 crate::core::palette::colors::LISTBOX_NORMAL
285 };
286 (text, color)
287 } else {
288 (String::new(), crate::core::palette::colors::LISTBOX_NORMAL)
289 };
290
291 let padded = format!("{text:width$}");
292
293 for (x, ch) in padded.chars().take(width).enumerate() {
294 terminal.write_cell(x as i16, y as i16, crate::core::draw::Cell::new(ch, color));
295 }
296 }
297 }
298
299 fn handle_event(&mut self, event: &mut Event) {
300 if !self.is_focused() {
301 return;
302 }
303
304 self.handle_list_event(event);
306
307 if event.what == EventType::Keyboard && event.key_code == crate::core::event::KB_ENTER {
309 let _ = self.enter_focused_dir();
310 event.clear();
311 }
312 }
313
314 fn can_focus(&self) -> bool {
315 true
316 }
317
318 fn get_palette(&self) -> Option<crate::core::palette::Palette> {
319 use crate::core::palette::{Palette, palettes};
320 Some(Palette::from_slice(palettes::CP_LISTBOX))
321 }
322
323 fn as_any(&self) -> &dyn std::any::Any {
324 self
325 }
326
327 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
328 self
329 }
330}
331
332pub struct FileListBuilder {
334 bounds: Option<Rect>,
335 path: Option<PathBuf>,
336}
337
338impl FileListBuilder {
339 pub fn new() -> Self {
340 Self {
341 bounds: None,
342 path: None,
343 }
344 }
345
346 #[must_use]
347 pub fn bounds(mut self, bounds: Rect) -> Self {
348 self.bounds = Some(bounds);
349 self
350 }
351
352 #[must_use]
353 pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
354 self.path = Some(path.into());
355 self
356 }
357
358 pub fn build(self) -> FileList {
359 let bounds = self.bounds.expect("FileList bounds must be set");
360 let path = self.path.expect("FileList path must be set");
361 FileList::new(bounds, &path)
362 }
363
364 pub fn build_boxed(self) -> Box<FileList> {
365 Box::new(self.build())
366 }
367}
368
369impl Default for FileListBuilder {
370 fn default() -> Self {
371 Self::new()
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use std::env;
379
380 #[test]
381 fn test_file_list_creation() {
382 let bounds = Rect::new(0, 0, 40, 10);
383 let path = env::current_dir().unwrap();
384 let mut list = FileList::new(bounds, &path);
385 list.refresh();
386
387 assert!(list.file_count() > 0, "Should find at least some files");
388 assert_eq!(list.current_path(), path.as_path());
389 }
390
391 #[test]
392 fn test_wildcard_filtering() {
393 let bounds = Rect::new(0, 0, 40, 10);
394 let path = env::current_dir().unwrap();
395 let mut list = FileList::new(bounds, &path);
396
397 list.set_wildcard("*.rs");
399
400 for entry in &list.files {
402 if !entry.is_dir {
403 assert!(
404 std::path::Path::new(&entry.name).extension()
405 == Some(std::ffi::OsStr::new("rs")),
406 "Should only show .rs files"
407 );
408 }
409 }
410 }
411
412 #[test]
413 fn test_file_entry_display() {
414 let entry = FileEntry {
415 name: "test.txt".to_string(),
416 path: PathBuf::from("test.txt"),
417 is_dir: false,
418 size: 1024,
419 modified: None,
420 };
421 assert_eq!(entry.display_name(), "test.txt");
422
423 let dir_entry = FileEntry {
424 name: "mydir".to_string(),
425 path: PathBuf::from("mydir"),
426 is_dir: true,
427 size: 0,
428 modified: None,
429 };
430 assert_eq!(dir_entry.display_name(), "[mydir]");
431 }
432
433 #[test]
434 fn test_size_formatting() {
435 let small = FileEntry {
436 name: "small".to_string(),
437 path: PathBuf::from("small"),
438 is_dir: false,
439 size: 512,
440 modified: None,
441 };
442 assert_eq!(small.size_string(), "512 B");
443
444 let kb = FileEntry {
445 name: "kb".to_string(),
446 path: PathBuf::from("kb"),
447 is_dir: false,
448 size: 2048,
449 modified: None,
450 };
451 assert_eq!(kb.size_string(), "2 KB");
452
453 let dir = FileEntry {
454 name: "dir".to_string(),
455 path: PathBuf::from("dir"),
456 is_dir: true,
457 size: 0,
458 modified: None,
459 };
460 assert_eq!(dir.size_string(), "<DIR>");
461 }
462}