use crate::input::fuzzy::fuzzy_match;
use crate::services::fs::{FsEntry, FsEntryType};
use std::cmp::Ordering;
use std::path::PathBuf;
use std::time::SystemTime;
#[derive(Debug, Clone)]
pub struct FileOpenEntry {
pub fs_entry: FsEntry,
pub matches_filter: bool,
pub match_score: i32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortMode {
#[default]
Name,
Size,
Modified,
Type,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FileOpenSection {
Navigation,
#[default]
Files,
}
#[derive(Debug, Clone)]
pub struct NavigationShortcut {
pub label: String,
pub path: PathBuf,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct FileOpenState {
pub current_dir: PathBuf,
pub entries: Vec<FileOpenEntry>,
pub loading: bool,
pub error: Option<String>,
pub sort_mode: SortMode,
pub sort_ascending: bool,
pub selected_index: Option<usize>,
pub scroll_offset: usize,
pub active_section: FileOpenSection,
pub filter: String,
pub shortcuts: Vec<NavigationShortcut>,
pub selected_shortcut: usize,
pub show_hidden: bool,
}
impl FileOpenState {
pub fn new(dir: PathBuf) -> Self {
let shortcuts = Self::build_shortcuts(&dir);
Self {
current_dir: dir,
entries: Vec::new(),
loading: true,
error: None,
sort_mode: SortMode::Name,
sort_ascending: true,
selected_index: None,
scroll_offset: 0,
active_section: FileOpenSection::Files,
filter: String::new(),
shortcuts,
selected_shortcut: 0,
show_hidden: false,
}
}
fn build_shortcuts(current_dir: &PathBuf) -> Vec<NavigationShortcut> {
let mut shortcuts = Vec::new();
if let Some(parent) = current_dir.parent() {
shortcuts.push(NavigationShortcut {
label: "..".to_string(),
path: parent.to_path_buf(),
description: "Parent directory".to_string(),
});
}
#[cfg(unix)]
{
shortcuts.push(NavigationShortcut {
label: "/".to_string(),
path: PathBuf::from("/"),
description: "Root directory".to_string(),
});
}
if let Some(home) = dirs::home_dir() {
shortcuts.push(NavigationShortcut {
label: "~".to_string(),
path: home,
description: "Home directory".to_string(),
});
}
if let Some(docs) = dirs::document_dir() {
shortcuts.push(NavigationShortcut {
label: "Documents".to_string(),
path: docs,
description: "Documents folder".to_string(),
});
}
if let Some(downloads) = dirs::download_dir() {
shortcuts.push(NavigationShortcut {
label: "Downloads".to_string(),
path: downloads,
description: "Downloads folder".to_string(),
});
}
#[cfg(windows)]
{
for letter in b'A'..=b'Z' {
let path = PathBuf::from(format!("{}:\\", letter as char));
if path.exists() {
shortcuts.push(NavigationShortcut {
label: format!("{}:", letter as char),
path,
description: "Drive".to_string(),
});
}
}
}
shortcuts
}
pub fn update_shortcuts(&mut self) {
self.shortcuts = Self::build_shortcuts(&self.current_dir);
self.selected_shortcut = 0;
}
pub fn set_entries(&mut self, entries: Vec<FsEntry>) {
let mut result: Vec<FileOpenEntry> = Vec::new();
if let Some(parent) = self.current_dir.parent() {
let parent_entry = FsEntry::new(
parent.to_path_buf(),
"..".to_string(),
FsEntryType::Directory,
);
result.push(FileOpenEntry {
fs_entry: parent_entry,
matches_filter: true,
match_score: 0,
});
}
result.extend(
entries
.into_iter()
.filter(|e| self.show_hidden || !Self::is_hidden(&e.name))
.map(|fs_entry| FileOpenEntry {
fs_entry,
matches_filter: true,
match_score: 0,
}),
);
self.entries = result;
self.loading = false;
self.error = None;
self.apply_filter_internal();
self.sort_entries();
self.selected_index = None;
self.scroll_offset = 0;
}
pub fn set_error(&mut self, error: String) {
self.loading = false;
self.error = Some(error);
self.entries.clear();
}
fn is_hidden(name: &str) -> bool {
name.starts_with('.')
}
pub fn apply_filter(&mut self, filter: &str) {
self.filter = filter.to_string();
self.apply_filter_internal();
if !filter.is_empty() {
self.entries.sort_by(|a, b| {
let a_is_parent = a.fs_entry.name == "..";
let b_is_parent = b.fs_entry.name == "..";
if a_is_parent && !b_is_parent {
return Ordering::Less;
}
if !a_is_parent && b_is_parent {
return Ordering::Greater;
}
match (a.matches_filter, b.matches_filter) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
(true, true) => {
b.match_score.cmp(&a.match_score)
}
(false, false) => {
a.fs_entry
.name
.to_lowercase()
.cmp(&b.fs_entry.name.to_lowercase())
}
}
});
let first_match = self
.entries
.iter()
.position(|e| e.matches_filter && e.fs_entry.name != "..");
if let Some(idx) = first_match {
self.selected_index = Some(idx);
self.ensure_selected_visible();
} else {
self.selected_index = None;
}
} else {
self.sort_entries();
self.selected_index = None;
}
}
fn apply_filter_internal(&mut self) {
for entry in &mut self.entries {
if self.filter.is_empty() {
entry.matches_filter = true;
entry.match_score = 0;
} else {
let result = fuzzy_match(&self.filter, &entry.fs_entry.name);
entry.matches_filter = result.matched;
entry.match_score = result.score;
}
}
}
pub fn sort_entries(&mut self) {
let sort_mode = self.sort_mode;
let ascending = self.sort_ascending;
self.entries.sort_by(|a, b| {
let a_is_parent = a.fs_entry.name == "..";
let b_is_parent = b.fs_entry.name == "..";
match (a_is_parent, b_is_parent) {
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
(true, true) => return Ordering::Equal,
_ => {}
}
match (a.fs_entry.is_dir(), b.fs_entry.is_dir()) {
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
_ => {}
}
let ord = match sort_mode {
SortMode::Name => a
.fs_entry
.name
.to_lowercase()
.cmp(&b.fs_entry.name.to_lowercase()),
SortMode::Size => {
let a_size = a
.fs_entry
.metadata
.as_ref()
.and_then(|m| m.size)
.unwrap_or(0);
let b_size = b
.fs_entry
.metadata
.as_ref()
.and_then(|m| m.size)
.unwrap_or(0);
a_size.cmp(&b_size)
}
SortMode::Modified => {
let a_mod = a.fs_entry.metadata.as_ref().and_then(|m| m.modified);
let b_mod = b.fs_entry.metadata.as_ref().and_then(|m| m.modified);
match (a_mod, b_mod) {
(Some(a), Some(b)) => a.cmp(&b),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
}
SortMode::Type => {
let a_ext = std::path::Path::new(&a.fs_entry.name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let b_ext = std::path::Path::new(&b.fs_entry.name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
a_ext.to_lowercase().cmp(&b_ext.to_lowercase())
}
};
if ascending {
ord
} else {
ord.reverse()
}
});
}
pub fn set_sort_mode(&mut self, mode: SortMode) {
if self.sort_mode == mode {
self.sort_ascending = !self.sort_ascending;
} else {
self.sort_mode = mode;
self.sort_ascending = true;
}
self.sort_entries();
}
pub fn toggle_hidden(&mut self) {
self.show_hidden = !self.show_hidden;
}
pub fn select_prev(&mut self) {
match self.active_section {
FileOpenSection::Navigation => {
if self.selected_shortcut > 0 {
self.selected_shortcut -= 1;
}
}
FileOpenSection::Files => {
if let Some(idx) = self.selected_index {
if idx > 0 {
self.selected_index = Some(idx - 1);
self.ensure_selected_visible();
}
} else if !self.entries.is_empty() {
self.selected_index = Some(self.entries.len() - 1);
self.ensure_selected_visible();
}
}
}
}
pub fn select_next(&mut self) {
match self.active_section {
FileOpenSection::Navigation => {
if self.selected_shortcut + 1 < self.shortcuts.len() {
self.selected_shortcut += 1;
}
}
FileOpenSection::Files => {
if let Some(idx) = self.selected_index {
if idx + 1 < self.entries.len() {
self.selected_index = Some(idx + 1);
self.ensure_selected_visible();
}
} else if !self.entries.is_empty() {
self.selected_index = Some(0);
self.ensure_selected_visible();
}
}
}
}
pub fn page_up(&mut self, page_size: usize) {
if self.active_section == FileOpenSection::Files {
if let Some(idx) = self.selected_index {
self.selected_index = Some(idx.saturating_sub(page_size));
self.ensure_selected_visible();
} else if !self.entries.is_empty() {
self.selected_index = Some(0);
}
}
}
pub fn page_down(&mut self, page_size: usize) {
if self.active_section == FileOpenSection::Files {
if let Some(idx) = self.selected_index {
self.selected_index =
Some((idx + page_size).min(self.entries.len().saturating_sub(1)));
self.ensure_selected_visible();
} else if !self.entries.is_empty() {
self.selected_index = Some(self.entries.len().saturating_sub(1));
}
}
}
pub fn select_first(&mut self) {
match self.active_section {
FileOpenSection::Navigation => self.selected_shortcut = 0,
FileOpenSection::Files => {
if !self.entries.is_empty() {
self.selected_index = Some(0);
self.scroll_offset = 0;
}
}
}
}
pub fn select_last(&mut self) {
match self.active_section {
FileOpenSection::Navigation => {
self.selected_shortcut = self.shortcuts.len().saturating_sub(1);
}
FileOpenSection::Files => {
if !self.entries.is_empty() {
self.selected_index = Some(self.entries.len() - 1);
self.ensure_selected_visible();
}
}
}
}
fn ensure_selected_visible(&mut self) {
let Some(idx) = self.selected_index else {
return;
};
let visible_rows = 15;
if idx < self.scroll_offset {
self.scroll_offset = idx;
} else if idx >= self.scroll_offset + visible_rows {
self.scroll_offset = idx.saturating_sub(visible_rows - 1);
}
}
pub fn update_scroll_for_visible_rows(&mut self, visible_rows: usize) {
let Some(idx) = self.selected_index else {
return;
};
if idx < self.scroll_offset {
self.scroll_offset = idx;
} else if idx >= self.scroll_offset + visible_rows {
self.scroll_offset = idx.saturating_sub(visible_rows - 1);
}
}
pub fn switch_section(&mut self) {
self.active_section = match self.active_section {
FileOpenSection::Navigation => FileOpenSection::Files,
FileOpenSection::Files => FileOpenSection::Navigation,
};
}
pub fn selected_entry(&self) -> Option<&FileOpenEntry> {
if self.active_section == FileOpenSection::Files {
self.selected_index.and_then(|idx| self.entries.get(idx))
} else {
None
}
}
pub fn selected_shortcut_entry(&self) -> Option<&NavigationShortcut> {
if self.active_section == FileOpenSection::Navigation {
self.shortcuts.get(self.selected_shortcut)
} else {
None
}
}
pub fn get_selected_path(&self) -> Option<PathBuf> {
match self.active_section {
FileOpenSection::Navigation => self
.shortcuts
.get(self.selected_shortcut)
.map(|s| s.path.clone()),
FileOpenSection::Files => self
.selected_index
.and_then(|idx| self.entries.get(idx))
.map(|e| e.fs_entry.path.clone()),
}
}
pub fn selected_is_dir(&self) -> bool {
match self.active_section {
FileOpenSection::Navigation => true, FileOpenSection::Files => self
.selected_index
.and_then(|idx| self.entries.get(idx))
.map(|e| e.fs_entry.is_dir())
.unwrap_or(false),
}
}
pub fn matching_count(&self) -> usize {
self.entries.iter().filter(|e| e.matches_filter).count()
}
pub fn visible_entries(&self, max_rows: usize) -> &[FileOpenEntry] {
let start = self.scroll_offset;
let end = (start + max_rows).min(self.entries.len());
&self.entries[start..end]
}
}
pub fn format_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if size >= GB {
format!("{:.1} GB", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.1} MB", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.1} KB", size as f64 / KB as f64)
} else {
format!("{} B", size)
}
}
pub fn format_modified(time: SystemTime) -> String {
let now = SystemTime::now();
match now.duration_since(time) {
Ok(duration) => {
let secs = duration.as_secs();
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{} min ago", secs / 60)
} else if secs < 86400 {
format!("{} hr ago", secs / 3600)
} else if secs < 86400 * 7 {
format!("{} days ago", secs / 86400)
} else {
let datetime: chrono::DateTime<chrono::Local> = time.into();
datetime.format("%Y-%m-%d").to_string()
}
}
Err(_) => {
let datetime: chrono::DateTime<chrono::Local> = time.into();
datetime.format("%Y-%m-%d").to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::fs::{FsEntryType, FsMetadata};
fn make_entry(name: &str, is_dir: bool) -> FsEntry {
FsEntry {
path: PathBuf::from(format!("/test/{}", name)),
name: name.to_string(),
entry_type: if is_dir {
FsEntryType::Directory
} else {
FsEntryType::File
},
metadata: None,
}
}
fn make_entry_with_size(name: &str, size: u64) -> FsEntry {
let mut entry = make_entry(name, false);
entry.metadata = Some(FsMetadata {
size: Some(size),
modified: None,
is_hidden: false,
is_readonly: false,
});
entry
}
#[test]
fn test_sort_by_name() {
let mut state = FileOpenState::new(PathBuf::from("/"));
state.set_entries(vec![
make_entry("zebra.txt", false),
make_entry("alpha.txt", false),
make_entry("beta", true),
]);
assert_eq!(state.entries[0].fs_entry.name, "beta"); assert_eq!(state.entries[1].fs_entry.name, "alpha.txt");
assert_eq!(state.entries[2].fs_entry.name, "zebra.txt");
}
#[test]
fn test_sort_by_size() {
let mut state = FileOpenState::new(PathBuf::from("/"));
state.sort_mode = SortMode::Size;
state.set_entries(vec![
make_entry_with_size("big.txt", 1000),
make_entry_with_size("small.txt", 100),
make_entry_with_size("medium.txt", 500),
]);
assert_eq!(state.entries[0].fs_entry.name, "small.txt");
assert_eq!(state.entries[1].fs_entry.name, "medium.txt");
assert_eq!(state.entries[2].fs_entry.name, "big.txt");
}
#[test]
fn test_filter() {
let mut state = FileOpenState::new(PathBuf::from("/"));
state.set_entries(vec![
make_entry("foo.txt", false),
make_entry("bar.txt", false),
make_entry("foobar.txt", false),
]);
state.apply_filter("foo");
assert_eq!(state.entries[0].fs_entry.name, "foo.txt");
assert!(state.entries[0].matches_filter);
assert_eq!(state.entries[1].fs_entry.name, "foobar.txt");
assert!(state.entries[1].matches_filter);
assert_eq!(state.entries[2].fs_entry.name, "bar.txt");
assert!(!state.entries[2].matches_filter);
assert_eq!(state.matching_count(), 2);
}
#[test]
fn test_filter_case_insensitive() {
let mut state = FileOpenState::new(PathBuf::from("/"));
state.set_entries(vec![
make_entry("README.md", false),
make_entry("readme.txt", false),
make_entry("other.txt", false),
]);
state.apply_filter("readme");
assert!(state.entries[0].matches_filter);
assert!(state.entries[1].matches_filter);
assert_eq!(state.entries[2].fs_entry.name, "other.txt");
assert!(!state.entries[2].matches_filter);
}
#[test]
fn test_hidden_files() {
let mut state = FileOpenState::new(PathBuf::from("/"));
state.show_hidden = false;
state.set_entries(vec![
make_entry(".hidden", false),
make_entry("visible.txt", false),
]);
assert_eq!(state.entries.len(), 1);
assert_eq!(state.entries[0].fs_entry.name, "visible.txt");
}
#[test]
fn test_format_size() {
assert_eq!(format_size(500), "500 B");
assert_eq!(format_size(1024), "1.0 KB");
assert_eq!(format_size(1536), "1.5 KB");
assert_eq!(format_size(1048576), "1.0 MB");
assert_eq!(format_size(1073741824), "1.0 GB");
}
#[test]
fn test_navigation() {
let mut state = FileOpenState::new(PathBuf::from("/"));
state.set_entries(vec![
make_entry("a.txt", false),
make_entry("b.txt", false),
make_entry("c.txt", false),
]);
assert_eq!(state.selected_index, None);
state.select_next();
assert_eq!(state.selected_index, Some(0));
state.select_next();
assert_eq!(state.selected_index, Some(1));
state.select_next();
assert_eq!(state.selected_index, Some(2));
state.select_next(); assert_eq!(state.selected_index, Some(2));
state.select_prev();
assert_eq!(state.selected_index, Some(1));
state.select_first();
assert_eq!(state.selected_index, Some(0));
state.select_last();
assert_eq!(state.selected_index, Some(2));
}
#[test]
fn test_fuzzy_filter() {
let mut state = FileOpenState::new(PathBuf::from("/"));
state.set_entries(vec![
make_entry("command_registry.rs", false),
make_entry("commands.rs", false),
make_entry("keybindings.rs", false),
make_entry("mod.rs", false),
]);
state.apply_filter("cmdreg");
assert!(state.entries[0].matches_filter);
assert_eq!(state.entries[0].fs_entry.name, "command_registry.rs");
assert_eq!(state.matching_count(), 1);
}
#[test]
fn test_fuzzy_filter_sparse_match() {
let mut state = FileOpenState::new(PathBuf::from("/"));
state.set_entries(vec![
make_entry("Save File", false),
make_entry("Select All", false),
make_entry("something_else.txt", false),
]);
state.apply_filter("sf");
assert_eq!(state.matching_count(), 1);
assert!(state.entries[0].matches_filter);
assert_eq!(state.entries[0].fs_entry.name, "Save File");
}
}