use crate::key::{self, KeyMap};
use bubbletea_rs::{Cmd, KeyMsg, Model as BubbleTeaModel, Msg};
use lipgloss_extras::prelude::*;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicI64, Ordering};
static LAST_ID: AtomicI64 = AtomicI64::new(0);
fn next_id() -> i64 {
LAST_ID.fetch_add(1, Ordering::SeqCst) + 1
}
#[derive(Debug, Clone)]
pub struct ErrorMsg {
pub err: String,
}
#[derive(Debug, Clone)]
pub struct ReadDirMsg {
pub id: i64,
pub entries: Vec<FileEntry>,
}
const MARGIN_BOTTOM: usize = 5;
const FILE_SIZE_WIDTH: usize = 7;
#[allow(dead_code)]
const PADDING_LEFT: usize = 2;
#[derive(Debug, Clone, Default)]
struct Stack {
items: Vec<usize>,
}
impl Stack {
fn new() -> Self {
Self { items: Vec::new() }
}
fn push(&mut self, item: usize) {
self.items.push(item);
}
fn pop(&mut self) -> Option<usize> {
self.items.pop()
}
#[allow(dead_code)]
fn len(&self) -> usize {
self.items.len()
}
fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct FilepickerKeyMap {
pub go_to_top: key::Binding,
pub go_to_last: key::Binding,
pub down: key::Binding,
pub up: key::Binding,
pub page_up: key::Binding,
pub page_down: key::Binding,
pub back: key::Binding,
pub open: key::Binding,
pub select: key::Binding,
}
impl Default for FilepickerKeyMap {
fn default() -> Self {
use crossterm::event::KeyCode;
Self {
go_to_top: key::Binding::new(vec![KeyCode::Char('g')]).with_help("g", "first"),
go_to_last: key::Binding::new(vec![KeyCode::Char('G')]).with_help("G", "last"),
down: key::Binding::new(vec![KeyCode::Char('j'), KeyCode::Down])
.with_help("j/↓", "down"),
up: key::Binding::new(vec![KeyCode::Char('k'), KeyCode::Up]).with_help("k/↑", "up"),
page_up: key::Binding::new(vec![KeyCode::PageUp, KeyCode::Char('K')])
.with_help("pgup/K", "page up"),
page_down: key::Binding::new(vec![KeyCode::PageDown, KeyCode::Char('J')])
.with_help("pgdn/J", "page down"),
back: key::Binding::new(vec![
KeyCode::Char('h'),
KeyCode::Backspace,
KeyCode::Left,
KeyCode::Esc,
])
.with_help("h/←", "back"),
open: key::Binding::new(vec![KeyCode::Char('l'), KeyCode::Right, KeyCode::Enter])
.with_help("l/→", "open"),
select: key::Binding::new(vec![KeyCode::Enter]).with_help("enter", "select"),
}
}
}
impl KeyMap for FilepickerKeyMap {
fn short_help(&self) -> Vec<&key::Binding> {
vec![&self.up, &self.down, &self.open, &self.back]
}
fn full_help(&self) -> Vec<Vec<&key::Binding>> {
vec![
vec![&self.go_to_top, &self.go_to_last],
vec![&self.up, &self.down],
vec![&self.page_up, &self.page_down],
vec![&self.open, &self.back, &self.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 {
const FILE_SIZE_WIDTH: usize = 7;
const PADDING_LEFT: usize = 2;
Self {
disabled_cursor: Style::new().foreground(Color::from("247")),
cursor: Style::new().foreground(Color::from("212")),
symlink: Style::new().foreground(Color::from("36")),
directory: Style::new().foreground(Color::from("99")),
file: Style::new(),
disabled_file: Style::new().foreground(Color::from("243")),
permission: Style::new().foreground(Color::from("244")),
selected: Style::new().foreground(Color::from("212")).bold(true),
disabled_selected: Style::new().foreground(Color::from("247")),
file_size: Style::new()
.foreground(Color::from("240"))
.width(FILE_SIZE_WIDTH as i32),
empty_directory: Style::new()
.foreground(Color::from("240"))
.padding_left(PADDING_LEFT as i32),
}
}
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
pub is_symlink: bool,
pub size: u64,
pub mode: u32,
pub symlink_target: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct Model {
id: i64,
pub path: String,
pub current_directory: PathBuf,
pub allowed_types: Vec<String>,
pub keymap: FilepickerKeyMap,
files: Vec<FileEntry>,
pub show_permissions: bool,
pub show_size: bool,
pub show_hidden: bool,
pub dir_allowed: bool,
pub file_allowed: bool,
pub file_selected: String,
selected: usize,
selected_stack: Stack,
min: usize,
max: usize,
max_stack: Stack,
min_stack: Stack,
pub height: usize,
pub auto_height: bool,
pub cursor: String,
pub error: Option<String>,
pub styles: Styles,
}
pub fn new() -> Model {
Model::new()
}
impl Model {
pub fn new() -> Self {
Self {
id: next_id(),
path: String::new(),
current_directory: PathBuf::from("."),
allowed_types: Vec::new(),
keymap: FilepickerKeyMap::default(),
files: Vec::new(),
show_permissions: true,
show_size: true,
show_hidden: false,
dir_allowed: false,
file_allowed: true,
file_selected: String::new(),
selected: 0,
selected_stack: Stack::new(),
min: 0,
max: 0,
max_stack: Stack::new(),
min_stack: Stack::new(),
height: 0,
auto_height: true,
cursor: ">".to_string(),
error: None,
styles: Styles::default(),
}
}
pub fn set_height(&mut self, height: usize) {
self.height = height;
if self.max > self.height.saturating_sub(1) {
self.max = self.min + self.height - 1;
}
}
fn push_view(&mut self, selected: usize, minimum: usize, maximum: usize) {
self.selected_stack.push(selected);
self.min_stack.push(minimum);
self.max_stack.push(maximum);
}
fn pop_view(&mut self) -> (usize, usize, usize) {
let selected = self.selected_stack.pop().unwrap_or(0);
let min = self.min_stack.pop().unwrap_or(0);
let max = self.max_stack.pop().unwrap_or(0);
(selected, min, max)
}
pub fn did_select_file(&self, msg: &Msg) -> (bool, String) {
let (did_select, path) = self.did_select_file_internal(msg);
if did_select && self.can_select(&path) {
(true, path)
} else {
(false, String::new())
}
}
pub fn did_select_disabled_file(&self, msg: &Msg) -> (bool, String) {
let (did_select, path) = self.did_select_file_internal(msg);
if did_select && !self.can_select(&path) {
(true, path)
} else {
(false, String::new())
}
}
fn did_select_file_internal(&self, msg: &Msg) -> (bool, String) {
if self.files.is_empty() {
return (false, String::new());
}
if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
if !self.keymap.select.matches(key_msg) {
return (false, String::new());
}
let f = &self.files[self.selected];
let is_dir = f.is_dir;
if (!is_dir && self.file_allowed)
|| (is_dir && self.dir_allowed) && !self.path.is_empty()
{
return (true, self.path.clone());
}
}
(false, String::new())
}
fn can_select(&self, file: &str) -> bool {
if self.allowed_types.is_empty() {
return true;
}
for ext in &self.allowed_types {
if file.ends_with(ext) {
return true;
}
}
false
}
pub fn read_dir(&mut self) {
self.files.clear();
self.error = None;
match std::fs::read_dir(&self.current_directory) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
if !self.show_hidden && is_hidden(&path, &name) {
continue;
}
let (is_dir, is_symlink, size, mode, symlink_target) =
if let Ok(metadata) = entry.metadata() {
let is_symlink = metadata.file_type().is_symlink();
let mut is_dir = metadata.is_dir();
let size = metadata.len();
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode()
};
#[cfg(not(unix))]
let mode = 0;
let symlink_target = if is_symlink {
match std::fs::canonicalize(&path) {
Ok(target) => {
if let Ok(target_meta) = std::fs::metadata(&target) {
if target_meta.is_dir() {
is_dir = true;
}
}
Some(target)
}
Err(_) => None,
}
} else {
None
};
(is_dir, is_symlink, size, mode, symlink_target)
} else {
(path.is_dir(), false, 0, 0, None)
};
self.files.push(FileEntry {
name,
path,
is_dir,
is_symlink,
size,
mode,
symlink_target,
});
}
self.files
.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
self.selected = 0;
self.max = std::cmp::max(self.max, self.height.saturating_sub(1));
}
Err(err) => {
self.error = Some(format!("Failed to read directory: {}", err));
}
}
}
pub fn read_dir_cmd(&self) -> Cmd {
let current_dir = self.current_directory.clone();
let id = self.id;
bubbletea_rs::tick(std::time::Duration::from_nanos(1), move |_| {
let mut entries = Vec::new();
if let Ok(dir_entries) = std::fs::read_dir(¤t_dir) {
for entry in dir_entries.flatten() {
let path = entry.path();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
if name.starts_with('.') {
continue;
}
let (is_dir, is_symlink, size, mode, symlink_target) =
if let Ok(metadata) = entry.metadata() {
let is_symlink = metadata.file_type().is_symlink();
let mut is_dir = metadata.is_dir();
let size = metadata.len();
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode()
};
#[cfg(not(unix))]
let mode = 0;
let symlink_target = if is_symlink {
match std::fs::canonicalize(&path) {
Ok(target) => {
if let Ok(target_meta) = std::fs::metadata(&target) {
if target_meta.is_dir() {
is_dir = true;
}
}
Some(target)
}
Err(_) => None,
}
} else {
None
};
(is_dir, is_symlink, size, mode, symlink_target)
} else {
(path.is_dir(), false, 0, 0, None)
};
entries.push(FileEntry {
name,
path,
is_dir,
is_symlink,
size,
mode,
symlink_target,
});
}
}
entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
Box::new(ReadDirMsg { id, entries }) as Msg
})
}
}
impl Default for Model {
fn default() -> Self {
Self::new()
}
}
pub fn is_hidden_name(name: &str) -> (bool, Option<String>) {
let is_hidden = name.starts_with('.');
(is_hidden, None)
}
#[inline]
fn is_hidden(path: &Path, name: &str) -> bool {
is_hidden_impl(path, name)
}
fn is_hidden_impl(path: &Path, name: &str) -> bool {
#[cfg(target_os = "windows")]
{
if let Ok(metadata) = std::fs::metadata(path) {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
if metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0 {
return true;
}
}
name.starts_with('.')
}
#[cfg(not(target_os = "windows"))]
{
let _ = path; name.starts_with('.')
}
}
impl BubbleTeaModel for Model {
fn init() -> (Self, Option<Cmd>) {
let mut model = Self::new();
model.read_dir();
(model, None)
}
fn update(&mut self, msg: Msg) -> Option<Cmd> {
if let Some(_window_msg) = msg.downcast_ref::<bubbletea_rs::WindowSizeMsg>() {
if self.auto_height {
self.height = (_window_msg.height as usize).saturating_sub(MARGIN_BOTTOM);
}
self.max = if self.files.is_empty() {
self.height.saturating_sub(1)
} else {
std::cmp::min(
self.height.saturating_sub(1),
self.files.len().saturating_sub(1),
)
};
if self.max < self.selected {
self.min = self.selected.saturating_sub(self.height.saturating_sub(1));
self.max = self.selected;
}
return None;
}
if let Some(read_dir_msg) = msg.downcast_ref::<ReadDirMsg>() {
if read_dir_msg.id == self.id {
self.files = read_dir_msg.entries.clone();
if self.files.is_empty() {
self.max = 0;
} else {
let viewport_max = self.height.saturating_sub(1);
let file_max = self.files.len().saturating_sub(1);
self.max = std::cmp::min(viewport_max, file_max);
if self.selected >= self.files.len() {
self.selected = file_max;
}
if self.selected > self.max {
self.min = self.selected.saturating_sub(viewport_max);
self.max = self.selected;
} else if self.selected < self.min {
self.min = self.selected;
self.max = std::cmp::min(self.min + viewport_max, file_max);
}
}
}
return None;
}
if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
match key_msg {
key_msg if self.keymap.go_to_top.matches(key_msg) => {
self.selected = 0;
self.min = 0;
self.max = self.height.saturating_sub(1);
}
key_msg if self.keymap.go_to_last.matches(key_msg) => {
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);
}
key_msg if self.keymap.down.matches(key_msg) => {
if self.selected < self.files.len().saturating_sub(1) {
self.selected += 1;
}
if self.selected > self.max {
self.min += 1;
self.max += 1;
}
}
key_msg if self.keymap.up.matches(key_msg) => {
self.selected = self.selected.saturating_sub(1);
if self.selected < self.min {
self.min = self.min.saturating_sub(1);
self.max = self.max.saturating_sub(1);
}
}
key_msg if self.keymap.page_down.matches(key_msg) => {
self.selected += self.height;
if self.selected >= self.files.len() {
self.selected = 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);
}
}
key_msg if self.keymap.page_up.matches(key_msg) => {
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.min = 0;
self.max = self.min + self.height;
}
}
key_msg if self.keymap.back.matches(key_msg) => {
if let Some(parent) = self.current_directory.parent() {
self.current_directory = parent.to_path_buf();
if !self.selected_stack.is_empty() {
let (selected, min, max) = self.pop_view();
self.selected = selected;
self.min = min;
self.max = max;
} else {
self.selected = 0;
self.min = 0;
self.max = self.height.saturating_sub(1);
}
self.read_dir();
}
}
key_msg if self.keymap.open.matches(key_msg) && !self.files.is_empty() => {
let f = &self.files[self.selected].clone();
let mut is_dir = f.is_dir;
if f.is_symlink {
if let Some(target) = &f.symlink_target {
if target.is_dir() {
is_dir = true;
}
}
}
if ((!is_dir && self.file_allowed) || (is_dir && self.dir_allowed))
&& self.keymap.select.matches(key_msg)
{
self.path = f.path.to_string_lossy().to_string();
}
if is_dir {
self.push_view(self.selected, self.min, self.max);
self.current_directory = f.path.clone();
self.selected = 0;
self.min = 0;
self.max = self.height.saturating_sub(1);
self.read_dir();
} else {
self.path = f.path.to_string_lossy().to_string();
}
}
_ => {}
}
}
None
}
fn view(&self) -> String {
if let Some(error) = &self.error {
return self
.styles
.empty_directory
.clone()
.height(self.height as i32)
.max_height(self.height as i32)
.render(error);
}
if self.files.is_empty() {
return self
.styles
.empty_directory
.clone()
.height(self.height as i32)
.max_height(self.height as i32)
.render("Bummer. No Files Found.");
}
let mut output = String::new();
for (i, f) in self.files.iter().enumerate() {
if i < self.min || i > self.max {
continue;
}
let size = format_file_size(f.size);
let disabled = !self.can_select(&f.name) && !f.is_dir;
if self.selected == i {
let mut selected_line = String::new();
if self.show_permissions {
selected_line.push(' ');
selected_line.push_str(&format_mode(f.mode));
}
if self.show_size {
selected_line.push_str(&format!("{:>width$}", size, width = FILE_SIZE_WIDTH));
}
selected_line.push(' ');
selected_line.push_str(&f.name);
if f.is_symlink {
if let Some(target) = &f.symlink_target {
selected_line.push_str(" → ");
selected_line.push_str(&target.to_string_lossy());
}
}
if disabled {
output.push_str(&self.styles.disabled_cursor.render(&self.cursor));
output.push_str(&self.styles.disabled_selected.render(&selected_line));
} else {
output.push_str(&self.styles.cursor.render(&self.cursor));
output.push_str(&self.styles.selected.render(&selected_line));
}
output.push('\n');
continue;
}
let style = if f.is_dir {
&self.styles.directory
} else if f.is_symlink {
&self.styles.symlink
} else if disabled {
&self.styles.disabled_file
} else {
&self.styles.file
};
let mut file_name = style.render(&f.name);
output.push_str(&self.styles.cursor.render(" "));
if f.is_symlink {
if let Some(target) = &f.symlink_target {
file_name.push_str(" → ");
file_name.push_str(&target.to_string_lossy());
}
}
if self.show_permissions {
output.push(' ');
output.push_str(&self.styles.permission.render(&format_mode(f.mode)));
}
if self.show_size {
output.push_str(&self.styles.file_size.render(&size));
}
output.push(' ');
output.push_str(&file_name);
output.push('\n');
}
let current_height = output.lines().count();
for _ in current_height..=self.height {
output.push('\n');
}
output
}
}
fn format_file_size(size: u64) -> String {
const UNITS: &[&str] = &["B", "kB", "MB", "GB", "TB", "PB"];
if size == 0 {
return "0B".to_string();
}
let mut size_f = size as f64;
let mut unit_index = 0;
while size_f >= 1000.0 && unit_index < UNITS.len() - 1 {
size_f /= 1000.0;
unit_index += 1;
}
if unit_index == 0 {
format!("{}B", size)
} else if size_f >= 100.0 {
format!("{:.0}{}", size_f, UNITS[unit_index])
} else {
format!("{:.1}{}", size_f, UNITS[unit_index])
}
}
#[cfg(unix)]
fn format_mode(mode: u32) -> String {
const S_IFMT: u32 = 0o170000;
const S_IFDIR: u32 = 0o040000;
const S_IFLNK: u32 = 0o120000;
const S_IFBLK: u32 = 0o060000;
const S_IFCHR: u32 = 0o020000;
const S_IFIFO: u32 = 0o010000;
const S_IFSOCK: u32 = 0o140000;
const S_IRUSR: u32 = 0o400;
const S_IWUSR: u32 = 0o200;
const S_IXUSR: u32 = 0o100;
const S_IRGRP: u32 = 0o040;
const S_IWGRP: u32 = 0o020;
const S_IXGRP: u32 = 0o010;
const S_IROTH: u32 = 0o004;
const S_IWOTH: u32 = 0o002;
const S_IXOTH: u32 = 0o001;
let file_type = match mode & S_IFMT {
S_IFDIR => 'd',
S_IFLNK => 'l',
S_IFBLK => 'b',
S_IFCHR => 'c',
S_IFIFO => 'p',
S_IFSOCK => 's',
_ => '-',
};
let owner_perms = format!(
"{}{}{}",
if mode & S_IRUSR != 0 { 'r' } else { '-' },
if mode & S_IWUSR != 0 { 'w' } else { '-' },
if mode & S_IXUSR != 0 { 'x' } else { '-' }
);
let group_perms = format!(
"{}{}{}",
if mode & S_IRGRP != 0 { 'r' } else { '-' },
if mode & S_IWGRP != 0 { 'w' } else { '-' },
if mode & S_IXGRP != 0 { 'x' } else { '-' }
);
let other_perms = format!(
"{}{}{}",
if mode & S_IROTH != 0 { 'r' } else { '-' },
if mode & S_IWOTH != 0 { 'w' } else { '-' },
if mode & S_IXOTH != 0 { 'x' } else { '-' }
);
format!("{}{}{}{}", file_type, owner_perms, group_perms, other_perms)
}
#[cfg(not(unix))]
fn format_mode(_mode: u32) -> String {
"----------".to_string()
}