use crate::key::{Binding, matches};
use bubbletea::{Cmd, KeyMsg, Message, Model, WindowSizeMsg};
use lipgloss::{Color, Style};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
fn next_id() -> u64 {
NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
#[derive(Debug, Clone)]
pub struct DirEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
pub is_symlink: bool,
pub size: u64,
pub mode: String,
}
#[derive(Debug, Clone)]
pub struct ReadDirMsg {
pub id: u64,
pub entries: Vec<DirEntry>,
}
#[derive(Debug, Clone)]
pub struct ReadDirErrMsg {
pub id: u64,
pub error: String,
}
#[derive(Debug, Clone)]
pub struct KeyMap {
pub goto_top: Binding,
pub goto_last: Binding,
pub down: Binding,
pub up: Binding,
pub page_up: Binding,
pub page_down: Binding,
pub back: Binding,
pub open: Binding,
pub select: Binding,
}
impl Default for KeyMap {
fn default() -> Self {
Self {
goto_top: Binding::new().keys(&["g"]).help("g", "first"),
goto_last: Binding::new().keys(&["G"]).help("G", "last"),
down: Binding::new()
.keys(&["j", "down", "ctrl+n"])
.help("j", "down"),
up: Binding::new().keys(&["k", "up", "ctrl+p"]).help("k", "up"),
page_up: Binding::new().keys(&["K", "pgup"]).help("pgup", "page up"),
page_down: Binding::new()
.keys(&["J", "pgdown"])
.help("pgdown", "page down"),
back: Binding::new()
.keys(&["h", "backspace", "left", "esc"])
.help("h", "back"),
open: Binding::new()
.keys(&["l", "right", "enter"])
.help("l", "open"),
select: Binding::new().keys(&["enter"]).help("enter", "select"),
}
}
}
#[derive(Debug, Clone)]
pub struct Styles {
pub disabled_cursor: Style,
pub cursor: Style,
pub symlink: Style,
pub directory: Style,
pub file: Style,
pub disabled_file: Style,
pub permission: Style,
pub selected: Style,
pub disabled_selected: Style,
pub file_size: Style,
pub empty_directory: Style,
}
impl Default for Styles {
fn default() -> Self {
Self {
disabled_cursor: Style::new().foreground_color(Color::from("247")),
cursor: Style::new().foreground_color(Color::from("212")),
symlink: Style::new().foreground_color(Color::from("36")),
directory: Style::new().foreground_color(Color::from("99")),
file: Style::new(),
disabled_file: Style::new().foreground_color(Color::from("243")),
permission: Style::new().foreground_color(Color::from("244")),
selected: Style::new().foreground_color(Color::from("212")).bold(),
disabled_selected: Style::new().foreground_color(Color::from("247")),
file_size: Style::new().foreground_color(Color::from("240")),
empty_directory: Style::new().foreground_color(Color::from("240")),
}
}
}
#[derive(Debug, Clone)]
pub struct FilePicker {
id: u64,
pub root: Option<PathBuf>,
pub path: Option<PathBuf>,
current_directory: PathBuf,
pub allowed_types: Vec<String>,
pub key_map: KeyMap,
files: Vec<DirEntry>,
pub show_permissions: bool,
pub show_size: bool,
pub show_hidden: bool,
pub dir_allowed: bool,
pub file_allowed: bool,
selected: usize,
selected_stack: Vec<usize>,
min: usize,
max: usize,
min_stack: Vec<usize>,
max_stack: Vec<usize>,
pub height: usize,
pub auto_height: bool,
pub cursor_char: String,
pub styles: Styles,
}
impl Default for FilePicker {
fn default() -> Self {
Self::new()
}
}
impl FilePicker {
#[must_use]
pub fn new() -> Self {
Self {
id: next_id(),
root: None,
path: None,
current_directory: PathBuf::from("."),
allowed_types: Vec::new(),
key_map: KeyMap::default(),
files: Vec::new(),
show_permissions: true,
show_size: true,
show_hidden: false,
dir_allowed: false,
file_allowed: true,
selected: 0,
selected_stack: Vec::new(),
min: 0,
max: 0,
min_stack: Vec::new(),
max_stack: Vec::new(),
height: 0,
auto_height: true,
cursor_char: ">".to_string(),
styles: Styles::default(),
}
}
#[must_use]
pub fn id(&self) -> u64 {
self.id
}
#[must_use]
pub fn current_directory(&self) -> &Path {
&self.current_directory
}
pub fn set_root(&mut self, root: impl AsRef<Path>) {
self.root = Some(root.as_ref().to_path_buf());
if let Some(root) = &self.root
&& !self.current_directory.starts_with(root)
{
self.current_directory = root.clone();
}
}
pub fn set_current_directory(&mut self, path: impl AsRef<Path>) {
let path = path.as_ref();
if let Some(root) = &self.root
&& !path.starts_with(root)
{
self.current_directory = root.clone();
return;
}
self.current_directory = path.to_path_buf();
}
pub fn set_height(&mut self, height: usize) {
self.height = height;
self.clamp_viewport();
}
pub fn set_allowed_types(&mut self, types: Vec<String>) {
self.allowed_types = types;
}
#[must_use]
pub fn selected_path(&self) -> Option<&Path> {
self.path.as_deref()
}
#[must_use]
pub fn highlighted_entry(&self) -> Option<&DirEntry> {
self.files.get(self.selected)
}
#[must_use]
pub fn init(&self) -> Option<Cmd> {
Some(self.read_dir_cmd())
}
fn read_dir_cmd(&self) -> Cmd {
let id = self.id;
let path = self.current_directory.clone();
let show_hidden = self.show_hidden;
Cmd::new(move || match read_directory(&path, show_hidden) {
Ok(entries) => Message::new(ReadDirMsg { id, entries }),
Err(e) => Message::new(ReadDirErrMsg {
id,
error: e.to_string(),
}),
})
}
fn can_select(&self, name: &str) -> bool {
if self.allowed_types.is_empty() {
return true;
}
self.allowed_types.iter().any(|ext| name.ends_with(ext))
}
fn is_selectable(&self, entry: &DirEntry) -> bool {
if entry.is_dir {
self.dir_allowed
} else {
self.file_allowed && self.can_select(&entry.name)
}
}
fn clamp_viewport(&mut self) {
let len = self.files.len();
if len == 0 {
self.selected = 0;
self.min = 0;
self.max = 0;
return;
}
if self.selected >= len {
self.selected = len.saturating_sub(1);
}
let height = self.height.max(1);
self.min = self.min.min(self.selected);
self.max = self.min + height.saturating_sub(1);
if self.max >= len {
self.max = len.saturating_sub(1);
self.min = self.max.saturating_sub(height.saturating_sub(1));
}
}
fn push_view(&mut self) {
self.selected_stack.push(self.selected);
self.min_stack.push(self.min);
self.max_stack.push(self.max);
}
fn pop_view(&mut self) -> Option<(usize, usize, usize)> {
if let (Some(sel), Some(min), Some(max)) = (
self.selected_stack.pop(),
self.min_stack.pop(),
self.max_stack.pop(),
) {
Some((sel, min, max))
} else {
None
}
}
pub fn did_select_file(&self, msg: &Message) -> Option<PathBuf> {
if let Some(key) = msg.downcast_ref::<KeyMsg>() {
let key_str = key.to_string();
if matches(&key_str, &[&self.key_map.select])
&& let Some(entry) = self.files.get(self.selected)
&& self.is_selectable(entry)
{
return Some(entry.path.clone());
}
}
None
}
pub fn did_select_disabled_file(&self, msg: &Message) -> Option<PathBuf> {
if let Some(key) = msg.downcast_ref::<KeyMsg>() {
let key_str = key.to_string();
if matches(&key_str, &[&self.key_map.select])
&& let Some(entry) = self.files.get(self.selected)
&& !self.is_selectable(entry)
{
return Some(entry.path.clone());
}
}
None
}
pub fn update(&mut self, msg: Message) -> Option<Cmd> {
if let Some(read_msg) = msg.downcast_ref::<ReadDirMsg>() {
if read_msg.id != self.id {
return None;
}
self.files = read_msg.entries.clone();
self.clamp_viewport();
return None;
}
if let Some(size) = msg.downcast_ref::<WindowSizeMsg>() {
if self.auto_height {
self.height = (size.height as usize).saturating_sub(5);
}
self.clamp_viewport();
return None;
}
if let Some(key) = msg.downcast_ref::<KeyMsg>() {
let key_str = key.to_string();
if matches(&key_str, &[&self.key_map.goto_top]) {
self.selected = 0;
self.min = 0;
self.max = self.height.saturating_sub(1);
} else if matches(&key_str, &[&self.key_map.goto_last]) {
self.selected = self.files.len().saturating_sub(1);
self.min = self.files.len().saturating_sub(self.height);
self.max = self.files.len().saturating_sub(1);
} else if matches(&key_str, &[&self.key_map.down]) {
if self.selected < self.files.len().saturating_sub(1) {
self.selected += 1;
if self.selected > self.max {
self.min += 1;
self.max += 1;
}
}
} else if matches(&key_str, &[&self.key_map.up]) {
if self.selected > 0 {
self.selected -= 1;
if self.selected < self.min {
self.min = self.min.saturating_sub(1);
self.max = self.max.saturating_sub(1);
}
}
} else if matches(&key_str, &[&self.key_map.page_down]) {
self.selected =
(self.selected + self.height).min(self.files.len().saturating_sub(1));
self.min += self.height;
self.max += self.height;
if self.max >= self.files.len() {
self.max = self.files.len().saturating_sub(1);
self.min = self.max.saturating_sub(self.height);
}
} else if matches(&key_str, &[&self.key_map.page_up]) {
self.selected = self.selected.saturating_sub(self.height);
self.min = self.min.saturating_sub(self.height);
self.max = self.max.saturating_sub(self.height);
if self.min == 0 {
self.max = self
.height
.saturating_sub(1)
.min(self.files.len().saturating_sub(1));
}
} else if matches(&key_str, &[&self.key_map.back]) {
let at_root = if let Some(root) = &self.root {
self.current_directory == *root
} else {
false
};
if !at_root {
if let Some(parent) = self.current_directory.parent() {
self.current_directory = parent.to_path_buf();
}
if let Some((sel, min, max)) = self.pop_view() {
self.selected = sel;
self.min = min;
self.max = max;
} else {
self.selected = 0;
self.min = 0;
self.max = self.height.saturating_sub(1);
}
return Some(self.read_dir_cmd());
}
} else {
let is_select = matches(&key_str, &[&self.key_map.select]);
let is_open = matches(&key_str, &[&self.key_map.open]);
if !is_select && !is_open {
return None;
}
if self.files.is_empty() {
return None;
}
let entry = &self.files[self.selected];
let is_dir = entry.is_dir;
if is_select {
self.path = None;
}
if is_select && self.is_selectable(entry) {
self.path = Some(entry.path.clone());
}
if is_open && is_dir {
self.current_directory = entry.path.clone();
self.push_view();
self.selected = 0;
self.min = 0;
self.max = self.height.saturating_sub(1);
return Some(self.read_dir_cmd());
}
}
}
None
}
#[must_use]
pub fn view(&self) -> String {
if self.files.is_empty() {
return self.styles.empty_directory.render("No files found.");
}
let mut lines = Vec::new();
for (i, entry) in self.files.iter().enumerate() {
if i < self.min || i > self.max {
continue;
}
let disabled = !self.is_selectable(entry);
if i == self.selected {
let mut parts = Vec::new();
if self.show_permissions {
parts.push(format!(" {}", entry.mode));
}
if self.show_size {
parts.push(format!("{:>7}", format_size(entry.size)));
}
parts.push(format!(" {}", entry.name));
if entry.is_symlink {
parts.push(" →".to_string());
}
let content = parts.join("");
if disabled {
lines.push(format!(
"{}{}",
self.styles.disabled_selected.render(&self.cursor_char),
self.styles.disabled_selected.render(&content)
));
} else {
lines.push(format!(
"{}{}",
self.styles.cursor.render(&self.cursor_char),
self.styles.selected.render(&content)
));
}
} else {
let style = if entry.is_dir {
&self.styles.directory
} else if entry.is_symlink {
&self.styles.symlink
} else if disabled {
&self.styles.disabled_file
} else {
&self.styles.file
};
let mut parts = vec![" ".to_string()];
if self.show_permissions {
parts.push(format!(" {}", self.styles.permission.render(&entry.mode)));
}
if self.show_size {
parts.push(
self.styles
.file_size
.render(&format!("{:>7}", format_size(entry.size))),
);
}
parts.push(format!(" {}", style.render(&entry.name)));
if entry.is_symlink {
parts.push(" →".to_string());
}
lines.push(parts.join(""));
}
}
while lines.len() < self.height {
lines.push(String::new());
}
lines.join("\n")
}
}
impl Model for FilePicker {
fn init(&self) -> Option<Cmd> {
FilePicker::init(self)
}
fn update(&mut self, msg: Message) -> Option<Cmd> {
FilePicker::update(self, msg)
}
fn view(&self) -> String {
FilePicker::view(self)
}
}
fn read_directory(path: &Path, show_hidden: bool) -> std::io::Result<Vec<DirEntry>> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !show_hidden && name.starts_with('.') {
continue;
}
let metadata = entry.metadata()?;
let file_type = entry.file_type()?;
let is_symlink = file_type.is_symlink();
let mode = format_mode(&metadata, is_symlink);
entries.push(DirEntry {
name,
path: entry.path(),
is_dir: file_type.is_dir(),
is_symlink: file_type.is_symlink(),
size: metadata.len(),
mode,
});
}
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
Ok(entries)
}
#[cfg(unix)]
fn format_mode(metadata: &std::fs::Metadata, is_symlink: bool) -> String {
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode();
let file_type = if metadata.is_dir() {
'd'
} else if is_symlink {
'l'
} else {
'-'
};
let user = format!(
"{}{}{}",
if mode & 0o400 != 0 { 'r' } else { '-' },
if mode & 0o200 != 0 { 'w' } else { '-' },
if mode & 0o100 != 0 { 'x' } else { '-' }
);
let group = format!(
"{}{}{}",
if mode & 0o040 != 0 { 'r' } else { '-' },
if mode & 0o020 != 0 { 'w' } else { '-' },
if mode & 0o010 != 0 { 'x' } else { '-' }
);
let other = format!(
"{}{}{}",
if mode & 0o004 != 0 { 'r' } else { '-' },
if mode & 0o002 != 0 { 'w' } else { '-' },
if mode & 0o001 != 0 { 'x' } else { '-' }
);
format!("{}{}{}{}", file_type, user, group, other)
}
#[cfg(not(unix))]
fn format_mode(metadata: &std::fs::Metadata, is_symlink: bool) -> String {
let file_type = if metadata.is_dir() {
'd'
} else if is_symlink {
'l'
} else {
'-'
};
let readonly = if metadata.permissions().readonly() {
"r--"
} else {
"rw-"
};
format!("{}{}{}{}", file_type, readonly, readonly, readonly)
}
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}G", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.1}M", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.1}K", size as f64 / KB as f64)
} else {
format!("{}B", size)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_filepicker_new() {
let fp = FilePicker::new();
assert!(fp.allowed_types.is_empty());
assert!(fp.show_permissions);
assert!(fp.show_size);
assert!(!fp.show_hidden);
assert!(fp.file_allowed);
assert!(!fp.dir_allowed);
}
#[test]
fn test_filepicker_unique_ids() {
let fp1 = FilePicker::new();
let fp2 = FilePicker::new();
assert_ne!(fp1.id(), fp2.id());
}
#[test]
fn test_filepicker_set_current_directory() {
let mut fp = FilePicker::new();
fp.set_current_directory("/tmp");
assert_eq!(fp.current_directory(), Path::new("/tmp"));
}
#[test]
fn test_filepicker_set_height() {
let mut fp = FilePicker::new();
fp.set_height(20);
assert_eq!(fp.height, 20);
}
#[test]
fn test_filepicker_allowed_types() {
let mut fp = FilePicker::new();
fp.set_allowed_types(vec![".txt".to_string(), ".md".to_string()]);
assert!(fp.can_select("readme.txt"));
assert!(fp.can_select("notes.md"));
assert!(!fp.can_select("image.png"));
}
#[test]
fn test_filepicker_all_types_allowed() {
let fp = FilePicker::new();
assert!(fp.can_select("anything.xyz"));
}
#[test]
fn test_format_size() {
assert_eq!(format_size(0), "0B");
assert_eq!(format_size(512), "512B");
assert_eq!(format_size(1024), "1.0K");
assert_eq!(format_size(1536), "1.5K");
assert_eq!(format_size(1048576), "1.0M");
assert_eq!(format_size(1073741824), "1.0G");
}
#[test]
fn test_filepicker_navigation_stack() {
let mut fp = FilePicker::new();
fp.selected = 5;
fp.min = 2;
fp.max = 10;
fp.push_view();
fp.selected = 0;
fp.min = 0;
fp.max = 5;
let (sel, min, max) = fp.pop_view().unwrap();
assert_eq!(sel, 5);
assert_eq!(min, 2);
assert_eq!(max, 10);
}
#[test]
fn test_filepicker_view_empty() {
let fp = FilePicker::new();
let view = fp.view();
assert!(view.contains("No files"));
}
#[test]
fn test_keymap_default() {
let km = KeyMap::default();
assert!(!km.up.get_keys().is_empty());
assert!(!km.down.get_keys().is_empty());
assert!(!km.open.get_keys().is_empty());
}
#[test]
fn test_dir_entry() {
let entry = DirEntry {
name: "test.txt".to_string(),
path: PathBuf::from("/tmp/test.txt"),
is_dir: false,
is_symlink: false,
size: 1024,
mode: "-rw-r--r--".to_string(),
};
assert_eq!(entry.name, "test.txt");
assert!(!entry.is_dir);
assert_eq!(entry.size, 1024);
}
#[test]
fn test_model_init_returns_cmd() {
let fp = FilePicker::new();
let cmd = Model::init(&fp);
assert!(cmd.is_some());
}
#[test]
fn test_model_view_matches_filepicker_view() {
let fp = FilePicker::new();
let model_view = Model::view(&fp);
let filepicker_view = FilePicker::view(&fp);
assert_eq!(model_view, filepicker_view);
}
#[test]
fn test_filepicker_satisfies_model_bounds() {
fn requires_model<T: Model + Send + 'static>() {}
requires_model::<FilePicker>();
}
#[test]
fn test_model_update_handles_navigation() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
fp.files = vec![
DirEntry {
name: "file1.txt".to_string(),
path: PathBuf::from("/tmp/file1.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
DirEntry {
name: "file2.txt".to_string(),
path: PathBuf::from("/tmp/file2.txt"),
is_dir: false,
is_symlink: false,
size: 200,
mode: "-rw-r--r--".to_string(),
},
];
fp.max = 10;
fp.selected = 0;
let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
let _ = Model::update(&mut fp, down_msg);
assert_eq!(
fp.selected, 1,
"FilePicker should navigate down on Down key"
);
}
#[test]
fn test_model_update_handles_read_dir_msg() {
use bubbletea::Message;
let mut fp = FilePicker::new();
let id = fp.id();
assert!(fp.files.is_empty());
let read_msg = ReadDirMsg {
id,
entries: vec![DirEntry {
name: "test.txt".to_string(),
path: PathBuf::from("/tmp/test.txt"),
is_dir: false,
is_symlink: false,
size: 42,
mode: "-rw-r--r--".to_string(),
}],
};
let _ = Model::update(&mut fp, Message::new(read_msg));
assert_eq!(
fp.files.len(),
1,
"FilePicker should populate files from ReadDirMsg"
);
assert_eq!(fp.files[0].name, "test.txt");
}
#[test]
fn test_filepicker_read_dir_clamps_selection() {
use bubbletea::Message;
let mut fp = FilePicker::new();
fp.height = 5;
fp.selected = 10;
fp.min = 8;
fp.max = 12;
let read_msg = ReadDirMsg {
id: fp.id(),
entries: vec![
DirEntry {
name: "file1.txt".to_string(),
path: PathBuf::from("/tmp/file1.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
DirEntry {
name: "file2.txt".to_string(),
path: PathBuf::from("/tmp/file2.txt"),
is_dir: false,
is_symlink: false,
size: 200,
mode: "-rw-r--r--".to_string(),
},
],
};
let _ = Model::update(&mut fp, Message::new(read_msg));
assert!(
fp.selected < fp.files.len(),
"Selection should clamp to list"
);
assert!(fp.min <= fp.selected && fp.selected <= fp.max);
assert!(fp.max < fp.files.len());
}
#[test]
fn test_model_update_ignores_wrong_id() {
use bubbletea::Message;
let mut fp = FilePicker::new();
assert!(fp.files.is_empty());
let read_msg = ReadDirMsg {
id: fp.id() + 1, entries: vec![DirEntry {
name: "test.txt".to_string(),
path: PathBuf::from("/tmp/test.txt"),
is_dir: false,
is_symlink: false,
size: 42,
mode: "-rw-r--r--".to_string(),
}],
};
let _ = Model::update(&mut fp, Message::new(read_msg));
assert!(
fp.files.is_empty(),
"FilePicker should ignore ReadDirMsg with wrong ID"
);
}
#[test]
fn test_model_update_navigate_up_moves_cursor() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
fp.files = vec![
DirEntry {
name: "file1.txt".to_string(),
path: PathBuf::from("/tmp/file1.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
DirEntry {
name: "file2.txt".to_string(),
path: PathBuf::from("/tmp/file2.txt"),
is_dir: false,
is_symlink: false,
size: 200,
mode: "-rw-r--r--".to_string(),
},
];
fp.max = 10;
fp.selected = 1;
let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
let _ = Model::update(&mut fp, up_msg);
assert_eq!(fp.selected, 0, "FilePicker should navigate up on Up key");
}
#[test]
fn test_filepicker_toggle_hidden_files() {
let mut fp = FilePicker::new();
assert!(!fp.show_hidden, "Hidden files should be hidden by default");
fp.show_hidden = true;
assert!(fp.show_hidden, "Hidden files should be shown after toggle");
fp.show_hidden = false;
assert!(!fp.show_hidden, "Hidden files should be hidden again");
}
#[test]
fn test_filepicker_filter_files() {
let mut fp = FilePicker::new();
fp.set_allowed_types(vec![".txt".to_string()]);
assert!(fp.can_select("readme.txt"));
assert!(!fp.can_select("image.png"));
assert!(!fp.can_select("document.pdf"));
}
#[test]
fn test_filepicker_select_respects_allowed_types() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
fp.set_allowed_types(vec![".txt".to_string()]);
fp.files = vec![DirEntry {
name: "image.png".to_string(),
path: PathBuf::from("/tmp/image.png"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
}];
fp.selected = 0;
let msg = Message::new(KeyMsg::from_type(KeyType::Enter));
let _ = Model::update(&mut fp, msg);
assert!(
fp.selected_path().is_none(),
"Disallowed file should not be selected"
);
assert_eq!(
fp.did_select_disabled_file(&Message::new(KeyMsg::from_type(KeyType::Enter))),
Some(PathBuf::from("/tmp/image.png")),
"Selecting a disallowed file should be reported as disabled"
);
}
#[test]
fn test_filepicker_select_dir_when_disallowed_reports_disabled() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
fp.dir_allowed = false;
fp.files = vec![DirEntry {
name: "subdir".to_string(),
path: PathBuf::from("/tmp/subdir"),
is_dir: true,
is_symlink: false,
size: 4096,
mode: "drwxr-xr-x".to_string(),
}];
fp.selected = 0;
let msg = Message::new(KeyMsg::from_type(KeyType::Enter));
let _ = Model::update(&mut fp, msg);
assert!(
fp.selected_path().is_none(),
"Disallowed dir should not be selected"
);
assert_eq!(
fp.did_select_disabled_file(&Message::new(KeyMsg::from_type(KeyType::Enter))),
Some(PathBuf::from("/tmp/subdir")),
"Selecting a disallowed dir should be reported as disabled"
);
}
#[test]
fn test_filepicker_view_shows_current_path() {
let mut fp = FilePicker::new();
fp.set_current_directory("/tmp");
fp.files = vec![DirEntry {
name: "test.txt".to_string(),
path: PathBuf::from("/tmp/test.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
}];
fp.max = 10;
let view = fp.view();
assert!(view.contains("test") || !view.is_empty());
}
#[test]
fn test_filepicker_symlink_entry() {
let entry = DirEntry {
name: "link".to_string(),
path: PathBuf::from("/tmp/link"),
is_dir: false,
is_symlink: true,
size: 0,
mode: "lrwxrwxrwx".to_string(),
};
assert!(entry.is_symlink, "Entry should be marked as symlink");
assert!(!entry.is_dir, "Symlink should not be marked as directory");
}
#[test]
fn test_filepicker_directory_entry() {
let entry = DirEntry {
name: "subdir".to_string(),
path: PathBuf::from("/tmp/subdir"),
is_dir: true,
is_symlink: false,
size: 4096,
mode: "drwxr-xr-x".to_string(),
};
assert!(entry.is_dir, "Entry should be marked as directory");
assert!(!entry.is_symlink);
}
#[test]
fn test_filepicker_cursor_boundary_top() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
fp.files = vec![DirEntry {
name: "file1.txt".to_string(),
path: PathBuf::from("/tmp/file1.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
}];
fp.max = 10;
fp.selected = 0;
let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
let _ = Model::update(&mut fp, up_msg);
assert_eq!(fp.selected, 0, "Cursor should not go below 0");
}
#[test]
fn test_filepicker_cursor_boundary_bottom() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
fp.files = vec![
DirEntry {
name: "file1.txt".to_string(),
path: PathBuf::from("/tmp/file1.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
DirEntry {
name: "file2.txt".to_string(),
path: PathBuf::from("/tmp/file2.txt"),
is_dir: false,
is_symlink: false,
size: 200,
mode: "-rw-r--r--".to_string(),
},
];
fp.max = 10;
fp.selected = 1;
let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
let _ = Model::update(&mut fp, down_msg);
assert_eq!(fp.selected, 1, "Cursor should not exceed file count");
}
#[test]
fn test_filepicker_empty_navigation() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
assert!(fp.files.is_empty());
assert_eq!(fp.selected, 0);
let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
let _ = Model::update(&mut fp, down_msg);
assert_eq!(fp.selected, 0, "Empty filepicker cursor should stay at 0");
let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
let _ = Model::update(&mut fp, up_msg);
assert_eq!(fp.selected, 0);
}
#[test]
fn test_filepicker_j_k_navigation() {
use bubbletea::{KeyMsg, Message};
let mut fp = FilePicker::new();
fp.files = vec![
DirEntry {
name: "a.txt".to_string(),
path: PathBuf::from("/tmp/a.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
DirEntry {
name: "b.txt".to_string(),
path: PathBuf::from("/tmp/b.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
];
fp.max = 10;
fp.selected = 0;
let j_msg = Message::new(KeyMsg::from_char('j'));
let _ = Model::update(&mut fp, j_msg);
assert_eq!(fp.selected, 1, "'j' should move cursor down");
let k_msg = Message::new(KeyMsg::from_char('k'));
let _ = Model::update(&mut fp, k_msg);
assert_eq!(fp.selected, 0, "'k' should move cursor up");
}
#[test]
fn test_filepicker_page_navigation() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
fp.files = (0..20)
.map(|i| DirEntry {
name: format!("file{}.txt", i),
path: PathBuf::from(format!("/tmp/file{}.txt", i)),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
})
.collect();
fp.height = 5;
fp.max = fp.height;
fp.selected = 0;
let pgdown_msg = Message::new(KeyMsg::from_type(KeyType::PgDown));
let _ = Model::update(&mut fp, pgdown_msg);
assert!(fp.selected > 0, "PageDown should move cursor down");
}
#[test]
fn test_filepicker_set_show_permissions() {
let mut fp = FilePicker::new();
assert!(fp.show_permissions, "Permissions shown by default");
fp.show_permissions = false;
assert!(!fp.show_permissions);
}
#[test]
fn test_filepicker_set_show_size() {
let mut fp = FilePicker::new();
assert!(fp.show_size, "Size shown by default");
fp.show_size = false;
assert!(!fp.show_size);
}
#[test]
fn test_filepicker_dir_allowed() {
let mut fp = FilePicker::new();
assert!(fp.file_allowed, "Files allowed by default");
assert!(!fp.dir_allowed, "Directories not allowed by default");
fp.dir_allowed = true;
fp.file_allowed = false;
assert!(fp.dir_allowed);
assert!(!fp.file_allowed);
}
#[test]
fn test_filepicker_selected_file() {
let mut fp = FilePicker::new();
fp.files = vec![
DirEntry {
name: "first.txt".to_string(),
path: PathBuf::from("/tmp/first.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
DirEntry {
name: "second.txt".to_string(),
path: PathBuf::from("/tmp/second.txt"),
is_dir: false,
is_symlink: false,
size: 200,
mode: "-rw-r--r--".to_string(),
},
];
fp.max = 10;
fp.selected = 0;
if let Some(entry) = fp.files.get(fp.selected) {
assert_eq!(entry.name, "first.txt");
}
fp.selected = 1;
if let Some(entry) = fp.files.get(fp.selected) {
assert_eq!(entry.name, "second.txt");
}
}
#[test]
fn test_filepicker_select_key_independent_of_open() {
use bubbletea::{KeyMsg, Message};
let mut fp = FilePicker::new();
fp.key_map.select = Binding::new().keys(&["s"]);
fp.key_map.open = Binding::new().keys(&["enter"]);
fp.files = vec![DirEntry {
name: "selected.txt".to_string(),
path: PathBuf::from("/tmp/selected.txt"),
is_dir: false,
is_symlink: false,
size: 10,
mode: "-rw-r--r--".to_string(),
}];
fp.max = 10;
fp.selected = 0;
let msg = Message::new(KeyMsg::from_char('s'));
let _ = Model::update(&mut fp, msg);
assert_eq!(
fp.selected_path(),
Some(Path::new("/tmp/selected.txt")),
"Select key should set path even when open key differs"
);
}
#[test]
fn test_filepicker_current_directory_accessor() {
let mut fp = FilePicker::new();
let initial_dir = fp.current_directory().to_path_buf();
fp.set_current_directory("/home");
assert_eq!(fp.current_directory(), Path::new("/home"));
fp.set_current_directory("/var/log");
assert_eq!(fp.current_directory(), Path::new("/var/log"));
fp.current_directory = initial_dir;
}
#[test]
fn test_filepicker_read_dir_error_updates_state() {
use bubbletea::Message;
let mut fp = FilePicker::new();
let id = fp.id();
let err_msg = ReadDirErrMsg {
id,
error: "Permission denied".to_string(),
};
let cmd = Model::update(&mut fp, Message::new(err_msg));
assert!(cmd.is_none(), "Error handling should not return a command");
}
#[test]
fn test_filepicker_enter_directory_changes_path() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
fp.set_current_directory("/tmp");
fp.files = vec![
DirEntry {
name: "subdir".to_string(),
path: PathBuf::from("/tmp/subdir"),
is_dir: true,
is_symlink: false,
size: 4096,
mode: "drwxr-xr-x".to_string(),
},
DirEntry {
name: "file.txt".to_string(),
path: PathBuf::from("/tmp/file.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
];
fp.max = 10;
fp.selected = 0;
let enter_msg = Message::new(KeyMsg::from_type(KeyType::Enter));
let cmd = Model::update(&mut fp, enter_msg);
assert_eq!(
fp.current_directory(),
Path::new("/tmp/subdir"),
"Enter on directory should change current path"
);
assert!(
cmd.is_some(),
"Entering directory should return read_dir command"
);
}
#[test]
fn test_filepicker_backspace_goes_parent() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
fp.set_current_directory("/tmp/subdir");
fp.files = vec![DirEntry {
name: "file.txt".to_string(),
path: PathBuf::from("/tmp/subdir/file.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
}];
fp.max = 10;
let back_msg = Message::new(KeyMsg::from_type(KeyType::Backspace));
let cmd = Model::update(&mut fp, back_msg);
assert_eq!(
fp.current_directory(),
Path::new("/tmp"),
"Backspace should navigate to parent directory"
);
assert!(
cmd.is_some(),
"Going to parent should return read_dir command"
);
}
#[test]
fn test_filepicker_view_highlights_selected() {
let mut fp = FilePicker::new();
fp.files = vec![
DirEntry {
name: "first.txt".to_string(),
path: PathBuf::from("/tmp/first.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
DirEntry {
name: "second.txt".to_string(),
path: PathBuf::from("/tmp/second.txt"),
is_dir: false,
is_symlink: false,
size: 200,
mode: "-rw-r--r--".to_string(),
},
];
fp.max = 10;
fp.selected = 0;
let view = fp.view();
assert!(
view.contains(&fp.cursor_char),
"View should show cursor on selected item"
);
assert!(
view.contains("first.txt"),
"View should show the first file"
);
}
#[test]
#[allow(clippy::useless_vec)]
fn test_filepicker_view_shows_directories_first() {
let mut entries = vec![
DirEntry {
name: "zebra.txt".to_string(),
path: PathBuf::from("/tmp/zebra.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
DirEntry {
name: "apple_dir".to_string(),
path: PathBuf::from("/tmp/apple_dir"),
is_dir: true,
is_symlink: false,
size: 4096,
mode: "drwxr-xr-x".to_string(),
},
DirEntry {
name: "banana.txt".to_string(),
path: PathBuf::from("/tmp/banana.txt"),
is_dir: false,
is_symlink: false,
size: 200,
mode: "-rw-r--r--".to_string(),
},
];
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
assert!(entries[0].is_dir, "Directories should come first");
assert_eq!(entries[0].name, "apple_dir");
assert_eq!(entries[1].name, "banana.txt");
assert_eq!(entries[2].name, "zebra.txt");
}
#[test]
fn test_filepicker_root_directory_no_parent() {
use bubbletea::{KeyMsg, KeyType, Message};
let mut fp = FilePicker::new();
fp.set_current_directory("/");
fp.files = vec![DirEntry {
name: "etc".to_string(),
path: PathBuf::from("/etc"),
is_dir: true,
is_symlink: false,
size: 4096,
mode: "drwxr-xr-x".to_string(),
}];
fp.max = 10;
let back_msg = Message::new(KeyMsg::from_type(KeyType::Backspace));
let _ = Model::update(&mut fp, back_msg);
let current = fp.current_directory();
assert!(
current == Path::new("/") || current == Path::new(""),
"Should stay at or near root when trying to go up from root"
);
}
#[test]
fn test_filepicker_highlighted_entry() {
let mut fp = FilePicker::new();
fp.files = vec![
DirEntry {
name: "first.txt".to_string(),
path: PathBuf::from("/tmp/first.txt"),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
},
DirEntry {
name: "second.txt".to_string(),
path: PathBuf::from("/tmp/second.txt"),
is_dir: false,
is_symlink: false,
size: 200,
mode: "-rw-r--r--".to_string(),
},
];
fp.selected = 0;
let entry = fp
.highlighted_entry()
.expect("Should have highlighted entry");
assert_eq!(entry.name, "first.txt");
fp.selected = 1;
let entry = fp
.highlighted_entry()
.expect("Should have highlighted entry");
assert_eq!(entry.name, "second.txt");
}
#[test]
fn test_filepicker_window_size_msg() {
use bubbletea::{Message, WindowSizeMsg};
let mut fp = FilePicker::new();
fp.auto_height = true;
assert_eq!(fp.height, 0);
let size_msg = WindowSizeMsg {
width: 80,
height: 24,
};
let _ = Model::update(&mut fp, Message::new(size_msg));
assert_eq!(fp.height, 19, "Height should be terminal height minus 5");
}
#[test]
fn test_filepicker_goto_top_and_last() {
use bubbletea::{KeyMsg, Message};
let mut fp = FilePicker::new();
fp.files = (0..10)
.map(|i| DirEntry {
name: format!("file{}.txt", i),
path: PathBuf::from(format!("/tmp/file{}.txt", i)),
is_dir: false,
is_symlink: false,
size: 100,
mode: "-rw-r--r--".to_string(),
})
.collect();
fp.height = 5;
fp.max = fp.height;
fp.selected = 5;
let g_msg = Message::new(KeyMsg::from_char('g'));
let _ = Model::update(&mut fp, g_msg);
assert_eq!(fp.selected, 0, "'g' should go to first item");
let shift_g_msg = Message::new(KeyMsg::from_char('G'));
let _ = Model::update(&mut fp, shift_g_msg);
assert_eq!(fp.selected, 9, "'G' should go to last item");
}
}