use std::cmp::Ordering; use std::fs; use std::path::{Path, PathBuf}; #[derive(Clone, PartialEq, Eq)] pub enum FileBrowserMode { Save, Load, } #[derive(Clone, PartialEq, Eq)] pub struct DirEntry { pub name: String, pub is_dir: bool, } #[derive(Clone, PartialEq, Eq)] pub struct FileBrowserState { pub mode: FileBrowserMode, pub input: String, pub entries: Vec, pub selected: usize, pub scroll_offset: usize, } impl FileBrowserState { pub fn new_save(initial_path: String) -> Self { let mut state = Self { mode: FileBrowserMode::Save, input: initial_path, entries: Vec::new(), selected: 0, scroll_offset: 0, }; state.refresh_entries(); state } pub fn new_load(initial_path: String) -> Self { let mut state = Self { mode: FileBrowserMode::Load, input: initial_path, entries: Vec::new(), selected: 0, scroll_offset: 0, }; state.refresh_entries(); state } pub fn current_dir(&self) -> PathBuf { if self.input.is_empty() { return std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); } let path = Path::new(&self.input); if self.input.ends_with('/') { path.to_path_buf() } else { match path.parent() { Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), } } } fn partial_name(&self) -> &str { if self.input.is_empty() || self.input.ends_with('/') { "" } else if !self.input.contains('/') { &self.input } else { Path::new(&self.input) .file_name() .and_then(|n| n.to_str()) .unwrap_or("") } } pub fn refresh_entries(&mut self) { let dir = self.current_dir(); let prefix = self.partial_name().to_lowercase(); let mut entries = Vec::new(); if prefix.is_empty() && dir.parent().is_some() { entries.push(DirEntry { name: "..".to_string(), is_dir: true, }); } if let Ok(read_dir) = fs::read_dir(&dir) { for entry in read_dir.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); if name.starts_with('.') { continue; } let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); if prefix.is_empty() || name.to_lowercase().starts_with(&prefix) { entries.push(DirEntry { name, is_dir }); } } } entries.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) { ("..", _) => Ordering::Less, (_, "..") => Ordering::Greater, _ => match (a.is_dir, b.is_dir) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), }, }); self.entries = entries; self.selected = 0; self.scroll_offset = 0; } pub fn autocomplete(&mut self) { let real_entries: Vec<&DirEntry> = self.entries.iter().filter(|e| e.name != "..").collect(); if real_entries.is_empty() { return; } let lcp = longest_common_prefix(&real_entries); if lcp.is_empty() { return; } let dir = self.current_dir(); let mut new_input = dir.join(&lcp).display().to_string(); if real_entries.len() == 1 && real_entries[0].is_dir && !new_input.ends_with('/') { new_input.push('/'); } if new_input != self.input { self.input = new_input; self.refresh_entries(); } } pub fn select_next(&mut self, visible_height: usize) { if self.selected + 1 < self.entries.len() { self.selected += 1; if self.selected >= self.scroll_offset + visible_height { self.scroll_offset = self.selected + 1 - visible_height; } } } pub fn select_prev(&mut self, _visible_height: usize) { if self.selected > 0 { self.selected -= 1; if self.selected < self.scroll_offset { self.scroll_offset = self.selected; } } } pub fn confirm(&mut self) -> Option { if !self.entries.is_empty() { let entry = &self.entries[self.selected]; if entry.is_dir { self.navigate_to_selected(); return None; } let path = self.current_dir().join(&entry.name); if self.mode == FileBrowserMode::Save { ensure_parent_dirs(&path); } return Some(path); } if self.mode == FileBrowserMode::Save && !self.input.is_empty() { let path = PathBuf::from(&self.input); ensure_parent_dirs(&path); return Some(path); } None } pub fn enter_selected(&mut self) { if let Some(entry) = self.entries.get(self.selected) { if entry.is_dir { self.navigate_to_selected(); } } } pub fn go_up(&mut self) { let dir = self.current_dir(); if let Some(parent) = dir.parent() { self.input = format_dir_path(parent); self.refresh_entries(); } } pub fn backspace(&mut self) { if self.input.is_empty() { return; } if self.input.ends_with('/') && self.input.len() > 1 { let trimmed = &self.input[..self.input.len() - 1]; match trimmed.rfind('/') { Some(pos) => self.input = trimmed[..=pos].to_string(), None => self.input.clear(), } } else { self.input.pop(); } self.refresh_entries(); } fn navigate_to_selected(&mut self) { let entry = &self.entries[self.selected]; if entry.name == ".." { self.go_up(); } else { let dir = self.current_dir(); self.input = format_dir_path(&dir.join(&entry.name)); self.refresh_entries(); } } } fn format_dir_path(path: &Path) -> String { let mut s = path.display().to_string(); if !s.ends_with('/') { s.push('/'); } s } fn ensure_parent_dirs(path: &Path) { if let Some(parent) = path.parent() { if !parent.exists() { let _ = fs::create_dir_all(parent); } } } fn longest_common_prefix(entries: &[&DirEntry]) -> String { if entries.is_empty() { return String::new(); } let first = &entries[0].name; let mut len = first.len(); for entry in &entries[1..] { len = first .chars() .zip(entry.name.chars()) .take_while(|(a, b)| a.to_lowercase().eq(b.to_lowercase())) .count() .min(len); } first[..first .char_indices() .nth(len) .map(|(i, _)| i) .unwrap_or(first.len())] .to_string() }