use std::{
io::{self, stdout},
path::PathBuf,
process,
};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
Frame, Terminal,
};
use tui_file_explorer::{
render_dual_pane_themed, DualPane, DualPaneActive, DualPaneOutcome, SortMode, Theme,
};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
enum Editor {
#[default]
None,
Helix,
Neovim,
Vim,
Nano,
Micro,
}
impl Editor {
fn label(&self) -> &'static str {
match self {
Editor::None => "none",
Editor::Helix => "helix",
Editor::Neovim => "nvim",
Editor::Vim => "vim",
Editor::Nano => "nano",
Editor::Micro => "micro",
}
}
fn binary(&self) -> Option<&'static str> {
match self {
Editor::None => None,
Editor::Helix => Some("hx"),
Editor::Neovim => Some("nvim"),
Editor::Vim => Some("vim"),
Editor::Nano => Some("nano"),
Editor::Micro => Some("micro"),
}
}
fn cycle(&self) -> Editor {
match self {
Editor::None => Editor::Helix,
Editor::Helix => Editor::Neovim,
Editor::Neovim => Editor::Vim,
Editor::Vim => Editor::Nano,
Editor::Nano => Editor::Micro,
Editor::Micro => Editor::None,
}
}
}
struct App {
dual: DualPane,
themes: Vec<(&'static str, &'static str, Theme)>,
theme_idx: usize,
show_hidden: bool,
sort_mode: SortMode,
single_pane: bool,
show_options: bool,
editor: Editor,
status: String,
}
impl App {
fn new() -> Self {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
let themes = Theme::all_presets();
Self {
dual: DualPane::builder(start).build(),
themes,
theme_idx: 0,
show_hidden: false,
sort_mode: SortMode::Name,
single_pane: false,
show_options: true,
editor: Editor::default(),
status: String::new(),
}
}
fn theme(&self) -> &Theme {
&self.themes[self.theme_idx].2
}
fn theme_name(&self) -> &'static str {
self.themes[self.theme_idx].0
}
fn cycle_theme(&mut self) {
self.theme_idx = (self.theme_idx + 1) % self.themes.len();
}
fn cycle_sort(&mut self) {
self.sort_mode = self.sort_mode.next();
self.dual.left.set_sort_mode(self.sort_mode);
self.dual.right.set_sort_mode(self.sort_mode);
}
fn toggle_hidden(&mut self) {
self.show_hidden = !self.show_hidden;
self.dual.left.set_show_hidden(self.show_hidden);
self.dual.right.set_show_hidden(self.show_hidden);
}
fn toggle_single_pane(&mut self) {
self.single_pane = !self.single_pane;
self.dual.single_pane = self.single_pane;
}
fn cycle_editor(&mut self) {
self.editor = self.editor.cycle();
}
fn active_label(&self) -> &'static str {
match self.dual.active_side {
DualPaneActive::Left => "left",
DualPaneActive::Right => "right",
}
}
}
fn main() {
match run() {
Ok(Some(path)) => {
println!("{}", path.display());
process::exit(0);
}
Ok(None) => process::exit(1),
Err(e) => {
eprintln!("error: {e}");
process::exit(2);
}
}
}
fn run() -> io::Result<Option<PathBuf>> {
let mut app = App::new();
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = event_loop(&mut terminal, &mut app);
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture,
);
let _ = terminal.show_cursor();
result
}
fn event_loop(
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
app: &mut App,
) -> io::Result<Option<PathBuf>> {
loop {
terminal.draw(|frame| draw(frame, app))?;
let Event::Key(key) = event::read()? else {
continue;
};
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(None);
}
if key.code == KeyCode::Char('O') && key.modifiers.is_empty() {
app.show_options = !app.show_options;
continue;
}
if app.show_options {
match key.code {
KeyCode::Char('h') if key.modifiers.is_empty() => {
app.toggle_hidden();
continue;
}
KeyCode::Char('s') if key.modifiers.is_empty() => {
app.cycle_sort();
continue;
}
KeyCode::Char('w') if key.modifiers.is_empty() => {
app.toggle_single_pane();
continue;
}
KeyCode::Char('t') if key.modifiers.is_empty() => {
app.cycle_theme();
continue;
}
KeyCode::Char('e') if key.modifiers.is_empty() => {
app.cycle_editor();
continue;
}
_ => {}
}
}
if key.code == KeyCode::Char('e') && key.modifiers.is_empty() && !app.show_options {
if let Some(binary) = app.editor.binary() {
let active = match app.dual.active_side {
DualPaneActive::Left => &app.dual.left,
DualPaneActive::Right => &app.dual.right,
};
if let Some(entry) = active.current_entry() {
if !entry.path.is_dir() {
let path = entry.path.clone();
open_in_editor(terminal, binary, &path)?;
app.dual.left.reload();
app.dual.right.reload();
let fname = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
app.status = format!("returned from {} — {fname}", app.editor.label());
continue;
}
}
}
app.status = "No editor set — open Options (O) and press e to pick one".into();
continue;
}
match app.dual.handle_key(key) {
DualPaneOutcome::Selected(path) => {
if !path.is_dir() {
if let Some(binary) = app.editor.binary() {
open_in_editor(terminal, binary, &path)?;
app.dual.left.reload();
app.dual.right.reload();
let fname = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
app.status = format!("returned from {} — {fname}", app.editor.label());
continue;
}
app.status = "No editor set — open Options (O) and press e to pick one".into();
continue;
}
return Ok(Some(path));
}
DualPaneOutcome::Dismissed => return Ok(None),
DualPaneOutcome::MkdirCreated(path) => {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
app.status = format!("📂 Created folder '{name}'");
}
DualPaneOutcome::TouchCreated(path) => {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
app.status = format!("📄 Created file '{name}'");
}
DualPaneOutcome::RenameCompleted(path) => {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
app.status = format!("✏️ Renamed to '{name}'");
}
DualPaneOutcome::Pending | DualPaneOutcome::Unhandled => {}
}
}
}
fn open_in_editor(
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
binary: &str,
path: &std::path::Path,
) -> io::Result<()> {
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let _status = {
#[cfg(unix)]
{
let tty = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty");
let mut cmd = std::process::Command::new(binary);
cmd.arg(path);
if let Ok(tty_file) = tty {
use std::os::unix::io::{FromRawFd, IntoRawFd};
let tty_fd = tty_file.into_raw_fd();
unsafe {
let stdin_tty = std::fs::File::from_raw_fd(libc::dup(tty_fd));
let stdout_tty = std::fs::File::from_raw_fd(libc::dup(tty_fd));
let stderr_tty = std::fs::File::from_raw_fd(tty_fd);
cmd.stdin(stdin_tty).stdout(stdout_tty).stderr(stderr_tty);
}
}
cmd.status()
}
#[cfg(not(unix))]
{
std::process::Command::new(binary).arg(path).status()
}
};
let _ = enable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
);
let _ = terminal.clear();
Ok(())
}
fn draw(frame: &mut Frame, app: &mut App) {
let theme = app.theme().clone();
let area = frame.area();
let (main_area, status_area) = if app.status.is_empty() {
(area, None)
} else {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(area);
(rows[0], Some(rows[1]))
};
let chunks = if app.show_options {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(42)])
.split(main_area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0)])
.split(main_area)
};
render_dual_pane_themed(&mut app.dual, frame, chunks[0], &theme);
if app.show_options {
render_options(frame, chunks[1], app, &theme);
}
if let Some(slot) = status_area {
let status_para = Paragraph::new(Span::styled(
format!(" {}", app.status),
Style::default().fg(theme.success),
))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(status_para, slot);
}
}
fn render_options(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let on_style = Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD);
let off_style = Style::default().fg(theme.dim);
let key_style = Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD);
let label_style = Style::default().fg(theme.fg);
let dim_style = Style::default().fg(theme.dim);
let title_style = Style::default()
.fg(theme.brand)
.add_modifier(Modifier::BOLD);
let slots = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(5), Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), ])
.split(area);
let header = Block::default()
.title(Span::styled(" ⚙ Options ", title_style))
.title_bottom(Line::from(vec![
Span::styled(" Shift + O ", key_style),
Span::styled("close", dim_style),
]))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent));
frame.render_widget(header, slots[0]);
let section_title = |frame: &mut Frame, slot: Rect, label: &str| {
let dashes = "─".repeat((slot.width as usize).saturating_sub(label.len() + 2));
let para = Paragraph::new(Line::from(vec![
Span::styled(format!(" {label} "), dim_style),
Span::styled(dashes, dim_style),
]));
frame.render_widget(para, slot);
};
let option_row = |key: &str, label: &str, value: Span<'static>| -> Line {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{key:<12}"), key_style),
Span::styled(format!("{label:<16}"), label_style),
value,
])
};
let bool_span = |on: bool| -> Span {
if on {
Span::styled("● on", on_style)
} else {
Span::styled("○ off", off_style)
}
};
section_title(frame, slots[2], "View");
let view_rows = vec![
option_row("h", "hidden files", bool_span(app.show_hidden)),
option_row("w", "single pane", bool_span(app.single_pane)),
option_row(
"Tab",
"switch pane",
Span::styled(app.active_label(), on_style),
),
];
let view_cell = Paragraph::new(view_rows).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(view_cell, slots[3]);
section_title(frame, slots[5], "Sort");
let sort_rows = vec![option_row(
"s",
"sort mode",
Span::styled(app.sort_mode.label(), on_style),
)];
let sort_cell = Paragraph::new(sort_rows).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(sort_cell, slots[6]);
section_title(frame, slots[8], "Theme");
let theme_rows = vec![option_row(
"t",
"cycle theme",
Span::styled(app.theme_name(), on_style),
)];
let theme_cell = Paragraph::new(theme_rows).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(theme_cell, slots[9]);
section_title(frame, slots[11], "Editor");
let editor_val_style = if app.editor == Editor::None {
off_style
} else {
on_style
};
let editor_rows = vec![option_row(
"e",
"open with",
Span::styled(app.editor.label(), editor_val_style),
)];
let editor_cell = Paragraph::new(editor_rows).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(editor_cell, slots[12]);
}