use clap::Args;
use crossterm::{
event::{read, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::Rect,
widgets::StatefulWidget,
Terminal,
};
use hefesto_widgets::{FileBrowserPopup, FileBrowserState};
use crate::popup_config::{PopupConfig, PopupConfigurable};
use crate::{keybinds, style};
use std::path::PathBuf;
const FILE_GUIDE: &str = include_str!("../guides/FILE_GUIDE.md");
#[derive(Args)]
pub struct FileArgs {
pub path: Option<PathBuf>,
#[arg(short, long)]
pub dirs: bool,
#[arg(short = 'W', long, default_value = "0")]
pub width: u16,
#[arg(short, long)]
pub multi: bool,
#[arg(short = 'M', long)]
pub max: Option<usize>,
#[arg(short = 'H', long, default_value = "0")]
pub height: u16,
#[arg(short = 's', long)]
pub show_hidden: bool,
#[arg(long)]
pub guide: bool,
}
pub fn run(args: FileArgs) {
if args.guide {
println!("{}", FILE_GUIDE);
return;
}
crate::tty::ensure_terminal_stdin();
let mut tty: Box<dyn std::io::Write> = match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
Ok(f) => Box::new(f),
Err(_) => Box::new(std::io::stdout()),
};
if enable_raw_mode().is_err() || execute!(tty, EnterAlternateScreen, EnableMouseCapture).is_err() {
eprintln!("{} file: el terminal no es interactivo", crate::BIN_NAME);
std::process::exit(1);
}
let mut terminal = Terminal::new(CrosstermBackend::new(tty)).unwrap();
crate::tty::with_terminal_stdout(|| terminal.clear()).unwrap();
crate::tty::with_terminal_stdout(|| terminal.hide_cursor()).unwrap();
let start = args.path.clone().unwrap_or_else(|| std::env::current_dir().unwrap());
let mut state = FileBrowserState {
entries: vec![],
items: vec![],
cwd: PathBuf::new(),
choose_popup_state: hefesto_widgets::ChoosePopupState::default(),
show_hidden: args.show_hidden,
};
state.navigate_to(&start);
let mut result: Option<Vec<PathBuf>> = None;
let mut pending_g: bool = false;
let mut drag = crate::drag::DragState::new();
let mut origin: Option<(u16, u16)> = None;
let mut resize_w: Option<u16> = None;
let mut resize_h: Option<u16> = None;
let mut cfg = PopupConfig::new()
.header();
if args.width > 0 { cfg = cfg.width(args.width); }
if args.height > 0 { cfg = cfg.height(args.height); }
while result.is_none() {
let size = terminal.size().unwrap();
let area = Rect::new(0, 0, size.width, size.height);
let mut popup = FileBrowserPopup::new()
.dir_style(ratatui::style::Style::new().fg(style::ACCENT))
.file_style(ratatui::style::Style::new().fg(style::TEXT))
.with_config(&cfg);
if let Some(max) = args.max {
popup = popup.max_selected(max);
}
if let Some((ox, oy)) = origin {
popup = popup.origin(ox, oy);
}
if let Some(w) = resize_w { popup = popup.width(hefesto_widgets::PopupSize::Fixed(w)); }
if let Some(h) = resize_h { popup = popup.height(hefesto_widgets::PopupSize::Fixed(h)); }
let pr = popup.resolve_rect(area, &state);
if resize_w.is_some() || resize_h.is_some() {
resize_w = Some(pr.width);
resize_h = Some(pr.height);
}
terminal
.draw(|frame| {
StatefulWidget::render(popup.clone(), frame.area(), frame.buffer_mut(), &mut state);
})
.unwrap();
match read().unwrap() {
Event::Key(key) => {
if key.code == keybinds::EMERGENCY && key.modifiers == KeyModifiers::CONTROL {
break;
}
match key.code {
keybinds::UP | keybinds::UP_ALT => { state.previous(); pending_g = false; },
keybinds::DOWN | keybinds::DOWN_ALT => { state.next(); pending_g = false; },
keybinds::TOGGLE_MULTI
if (args.multi || args.dirs) && !state.show_filter() =>
{
if args.multi {
if let Some(entry) = state.selected_entry() {
if args.dirs && entry.is_dir {
state.toggle_cursor();
} else if !args.dirs && !entry.is_dir {
state.toggle_cursor();
}
}
} else if args.dirs {
if let Some(entry) = state.selected_entry() {
if entry.is_dir {
result = Some(vec![entry.path.clone()]);
}
}
}
pending_g = false;
}
keybinds::CONFIRM => {
if args.multi {
if state.chosen_indices().is_empty() {
state.toggle_cursor();
}
result = Some(state.chosen_paths());
} else if args.dirs {
result = Some(vec![state.cwd.clone()]);
} else if let Some(entry) = state.selected_entry() {
if entry.is_dir {
if entry.name == ".." {
state.go_up();
} else {
state.enter_directory();
}
} else {
result = Some(vec![entry.path.clone()]);
}
}
pending_g = false;
}
keybinds::OPEN_DIR if !state.show_filter() => {
if let Some(entry) = state.selected_entry() {
if entry.is_dir {
if entry.name == ".." {
state.go_up();
} else {
state.enter_directory();
}
}
}
pending_g = false;
}
keybinds::FILTER_BACKSPACE => {
if state.show_filter() {
state.delete_before_filter();
} else {
state.go_up();
}
}
keybinds::FILTER_LEFT => {
if state.show_filter() {
state.filter_cursor_left();
} else {
state.go_up();
}
}
keybinds::FILTER_RIGHT if state.show_filter() => {
state.filter_cursor_right();
}
keybinds::FILTER_HOME if state.show_filter() => {
state.filter_cursor_home();
}
keybinds::FILTER_END if state.show_filter() => {
state.filter_cursor_end();
}
keybinds::CANCEL | keybinds::CANCEL_ALT => {
if state.show_filter() {
state.set_show_filter(false);
} else {
break;
}
}
keybinds::FIRST => {
if pending_g {
state.first();
pending_g = false;
} else {
pending_g = true;
}
}
keybinds::LAST => {
state.last();
pending_g = false;
}
KeyCode::Char(c) if state.show_filter() => {
state.insert_filter_char(c);
pending_g = false;
}
keybinds::FILTER_ACTIVATE if !state.show_filter() => {
state.set_show_filter(true);
}
keybinds::TOGGLE_HIDDEN if !state.show_filter() => {
state.set_show_hidden(!state.show_hidden());
let cwd = state.cwd.clone();
state.navigate_to(&cwd);
pending_g = false;
}
_ => pending_g = false,
}
let visible = state.visible_count();
if visible > 0 {
state.choose_popup_state.cursor = state.choose_popup_state.cursor.min(visible.saturating_sub(1));
} else {
state.choose_popup_state.cursor = 0;
}
}
Event::Mouse(mouse) => {
let col = mouse.column;
let row = mouse.row;
match mouse.kind {
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
let zone = crate::drag::default_zone_at(pr, col, row);
drag.begin(pr, col, row, zone);
}
MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
match drag.update(area, col, row) {
crate::drag::DragUpdate::Moved { x, y } => origin = Some((x, y)),
crate::drag::DragUpdate::Resized { x, y, w, h } => {
origin = Some((x, y));
resize_w = Some(w);
resize_h = Some(h);
}
crate::drag::DragUpdate::None => {}
}
}
MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
drag.end();
}
_ => {}
}
}
_ => {}
}
}
disable_raw_mode().unwrap();
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture).unwrap();
terminal.show_cursor().unwrap();
if let Some(paths) = result {
for path in &paths {
println!("{}", path.display());
}
std::process::exit(if paths.is_empty() { 1 } else { 0 });
}
std::process::exit(1);
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
fn state_at(path: &Path) -> FileBrowserState {
let mut s = FileBrowserState {
entries: vec![],
items: vec![],
cwd: PathBuf::new(),
choose_popup_state: hefesto_widgets::ChoosePopupState::default(),
show_hidden: false,
};
s.navigate_to(path);
s
}
#[test]
fn navigate_to_populates_entries() {
let state = state_at(Path::new(env!("CARGO_MANIFEST_DIR")));
assert!(!state.entries.is_empty());
assert!(state.entries.iter().any(|e| e.is_dir));
}
#[test]
fn navigate_to_first_entry_is_parent() {
let state = state_at(Path::new(env!("CARGO_MANIFEST_DIR")));
assert_eq!(state.entries[0].name, "..");
}
#[test]
fn go_up_changes_cwd() {
let mut state = state_at(Path::new(env!("CARGO_MANIFEST_DIR")));
let original = state.cwd.clone();
state.go_up();
assert_ne!(state.cwd, original);
}
#[test]
fn default_state_has_no_selection() {
let state = state_at(Path::new(env!("CARGO_MANIFEST_DIR")));
assert_eq!(state.choose_popup_state.cursor, 0);
assert!(!state.choose_popup_state.show_filter);
assert!(!state.show_hidden);
}
#[test]
fn visible_count_matches_entries() {
let state = state_at(Path::new(env!("CARGO_MANIFEST_DIR")));
assert_eq!(state.visible_count(), state.entries.len());
}
}