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},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
Frame, Terminal,
};
use tui_file_explorer::{render_themed, ExplorerOutcome, FileExplorer, Theme};
struct App {
explorer: FileExplorer,
editor: String,
status: String,
last_opened: Option<PathBuf>,
}
impl App {
fn new(editor: String) -> Self {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
Self {
explorer: FileExplorer::builder(start).build(),
status: format!("editor: {editor} Enter/l — open file Esc/q — quit"),
editor,
last_opened: None,
}
}
}
fn resolve_editor() -> String {
if let Some(arg) = std::env::args().nth(1) {
if !arg.is_empty() {
return arg;
}
}
if let Ok(v) = std::env::var("VISUAL") {
if !v.is_empty() {
return v;
}
}
if let Ok(e) = std::env::var("EDITOR") {
if !e.is_empty() {
return e;
}
}
"vi".to_string()
}
fn main() {
let editor = resolve_editor();
match run(editor) {
Ok(()) => process::exit(0),
Err(e) => {
eprintln!("error: {e}");
process::exit(2);
}
}
}
fn run(editor: String) -> io::Result<()> {
let mut app = App::new(editor);
let theme = Theme::default();
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, &theme);
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,
theme: &Theme,
) -> io::Result<()> {
loop {
terminal.draw(|frame| draw(frame, app, theme))?;
let Event::Key(key) = event::read()? else {
continue;
};
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(());
}
match app.explorer.handle_key(key) {
ExplorerOutcome::Selected(path) => {
if path.is_dir() {
continue;
}
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture,
);
let mut parts = app.editor.split_whitespace();
let binary = parts.next().unwrap_or("vi").to_string();
let extra_args: Vec<&str> = parts.collect();
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);
for a in &extra_args {
cmd.arg(a);
}
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))]
{
let mut cmd = std::process::Command::new(&binary);
for a in &extra_args {
cmd.arg(a);
}
cmd.arg(&path).status()
}
};
let _ = enable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture,
);
let _ = terminal.clear();
app.explorer.reload();
let fname = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
app.status = match status {
Ok(s) if s.success() => {
format!("returned from {} — {fname}", app.editor)
}
Ok(s) => format!("editor exited with status {}", s.code().unwrap_or(-1)),
Err(e) => format!("error launching '{}': {e}", app.editor),
};
app.last_opened = Some(path);
}
ExplorerOutcome::Dismissed => return Ok(()),
ExplorerOutcome::MkdirCreated(_)
| ExplorerOutcome::TouchCreated(_)
| ExplorerOutcome::RenameCompleted(_)
| ExplorerOutcome::Pending
| ExplorerOutcome::Unhandled => {}
}
}
}
fn draw(frame: &mut Frame, app: &mut App, theme: &Theme) {
let area = frame.area();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(area);
render_themed(&mut app.explorer, frame, rows[0], theme);
render_status(frame, rows[1], app, theme);
}
fn render_status(frame: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(24)])
.split(area);
let status_colour = if app.status.starts_with("error") {
theme.brand
} else {
theme.success
};
let left = Paragraph::new(Span::styled(
format!(" {}", app.status),
Style::default().fg(status_colour),
))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(left, cols[0]);
let right = Paragraph::new(Line::from(vec![
Span::styled(" editor: ", Style::default().fg(theme.dim)),
Span::styled(
app.editor.clone(),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(right, cols[1]);
}