mod shell_init;
use tui_file_explorer::app::Editor;
use std::{
io::{self, stdout, Write},
path::PathBuf,
process,
};
use clap::Parser;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tui_file_explorer::{
draw, load_state, resolve_output_path, resolve_theme_idx, save_state, App, AppOptions,
AppState, Theme,
};
#[derive(Parser)]
#[command(
name = "tfe",
version,
about = "Keyboard-driven two-pane terminal file explorer",
after_help = "\
SHELL INTEGRATION (cd on exit):\n\
Step 1 — enable the feature (persisted across sessions):\n\
tfe --cd\n\
\n\
Step 2 — install the shell wrapper (one time):\n\
tfe --init bash # ~/.bashrc\n\
tfe --init zsh # ~/.zshrc\n\
tfe --init fish # ~/.config/fish/functions/tfe.fish\n\
tfe --init powershell # $PROFILE (Windows / cross-platform PowerShell)\n\
tfe --init nushell # <config-dir>/nushell/config.nu\n\
\n\
After both steps, dismissing tfe with Esc/q cd's your terminal to the\n\
directory you were browsing. Works on macOS, Linux, and Windows.\n\
\n\
Open selected file in $EDITOR: command tfe | xargs -r $EDITOR\n\
NUL-delimited output: command tfe -0 | xargs -0 wc -l"
)]
struct Cli {
#[arg(value_name = "PATH")]
path: Option<PathBuf>,
#[arg(short, long = "ext", value_name = "EXT", action = clap::ArgAction::Append)]
extensions: Vec<String>,
#[arg(short = 'H', long)]
hidden: bool,
#[arg(short, long, value_name = "THEME")]
theme: Option<String>,
#[arg(long)]
list_themes: bool,
#[arg(long)]
show_themes: bool,
#[arg(long)]
single_pane: bool,
#[arg(long)]
print_dir: bool,
#[arg(short = '0', long)]
null: bool,
#[arg(long, value_name = "SHELL")]
init: Option<String>,
#[arg(long = "cd", overrides_with = "no_cd")]
cd_on_exit: bool,
#[arg(long = "no-cd", overrides_with = "cd_on_exit")]
no_cd: bool,
#[arg(long, value_name = "EDITOR")]
editor: Option<String>,
}
fn main() {
if let Err(e) = run() {
eprintln!("tfe: {e}");
process::exit(2);
}
}
fn run() -> io::Result<()> {
let cli = Cli::parse();
if let Some(ref shell_name) = cli.init {
let shell = match shell_init::Shell::from_str(shell_name) {
Some(s) => Some(s),
None => {
eprintln!(
"tfe: unrecognised shell '{shell_name}'. \
Supported: bash, zsh, fish, powershell, nushell"
);
process::exit(2);
}
};
use shell_init::InitOutcome;
match shell_init::install_or_print(shell) {
InitOutcome::Installed(path) => {
eprintln!("tfe: shell integration installed to {}", path.display());
eprintln!(" Activate now : source {}", path.display());
eprintln!(" Or just open a new terminal window.");
}
InitOutcome::AlreadyInstalled(path) => {
eprintln!(
"tfe: shell integration already present in {}",
path.display()
);
}
InitOutcome::PrintedToStdout | InitOutcome::UnknownShell => {
}
}
return Ok(());
}
let auto_install_outcome = shell_init::auto_install();
let themes = Theme::all_presets();
if cli.list_themes {
let max = themes.iter().map(|(n, _, _)| n.len()).max().unwrap_or(0);
println!("{:<width$} DESCRIPTION", "THEME", width = max);
println!("{}", "\u{2500}".repeat(max + 52));
for (name, desc, _) in &themes {
println!("{name:<width$} {desc}", width = max);
}
return Ok(());
}
let saved = load_state();
let theme_name = cli
.theme
.or_else(|| saved.theme.clone())
.unwrap_or_else(|| "default".to_string());
let theme_idx = resolve_theme_idx(&theme_name, &themes);
const KNOWN_EDITORS: &[&str] = &[
"none",
"helix",
"hx",
"nvim",
"vim",
"nano",
"micro",
"emacs",
"vscode",
"code",
"zed",
"xcode",
"android-studio",
"rustrover",
"intellij",
"webstorm",
"pycharm",
"goland",
"clion",
"fleet",
"sublime",
"rubymine",
"phpstorm",
"rider",
"eclipse",
];
let (cli_editor, cli_path) = match (&cli.editor, &cli.path) {
(Some(editor_val), None) => {
let is_known_editor = KNOWN_EDITORS
.iter()
.any(|&name| name.eq_ignore_ascii_case(editor_val));
let exists_as_file = std::path::Path::new(editor_val).is_file();
if !is_known_editor && exists_as_file {
(None, Some(PathBuf::from(editor_val)))
} else {
(Some(editor_val.clone()), None)
}
}
_ => (cli.editor.clone(), cli.path.clone()),
};
let editor = if let Some(ref raw) = cli_editor {
Editor::from_key(raw).unwrap_or_else(|| Editor::Custom(raw.clone()))
} else if let Some(ref raw) = saved.editor {
Editor::from_key(raw).unwrap_or_default()
} else {
Editor::default()
};
let start_dir = match cli_path {
Some(ref p) => {
let c = p.canonicalize().unwrap_or_else(|_| p.clone());
if c.is_dir() {
c
} else if c.is_file() {
open_file_directly(&c, &editor)?;
return Ok(());
} else {
eprintln!("tfe: {:?} — no such file or directory", p);
process::exit(2);
}
}
None => saved
.last_dir
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))),
};
let right_start_dir = saved
.last_dir_right
.clone()
.unwrap_or_else(|| start_dir.clone());
let show_hidden = if cli.hidden {
true
} else {
saved.show_hidden.unwrap_or(false)
};
let single_pane = if cli.single_pane {
true
} else {
saved.single_pane.unwrap_or(false)
};
let sort_mode = saved.sort_mode.unwrap_or_default();
let cd_on_exit = if cli.cd_on_exit {
true
} else if cli.no_cd {
false
} else {
saved.cd_on_exit.unwrap_or(true)
};
let backend = CrosstermBackend::new(io::stderr());
enable_raw_mode()?;
execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(AppOptions {
left_dir: start_dir,
right_dir: right_start_dir,
extensions: cli.extensions,
show_hidden,
theme_idx,
show_theme_panel: cli.show_themes,
single_pane,
sort_mode,
cd_on_exit,
editor,
});
let result = run_loop(&mut terminal, &mut app);
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let _ = terminal.show_cursor();
drop(terminal);
result?;
if let shell_init::InitOutcome::Installed(ref rc_path) = auto_install_outcome {
shell_init::emit_source_directive(rc_path);
eprintln!("tfe: shell integration installed to {}", rc_path.display());
eprintln!(" The wrapper function has been sourced into this session automatically.");
eprintln!(" cd-on-exit is now active — no restart needed.");
}
let last_dir_right = if app.single_pane {
saved.last_dir_right.clone()
} else {
Some(app.right.current_dir.clone())
};
save_state(&AppState {
theme: Some(app.theme_name().to_string()),
last_dir: Some(app.left.current_dir.clone()),
last_dir_right,
sort_mode: Some(app.left.sort_mode),
show_hidden: Some(app.left.show_hidden),
single_pane: Some(app.single_pane),
cd_on_exit: Some(app.cd_on_exit),
editor: Some(app.editor.to_key()),
});
match app.selected {
Some(path) => {
let output = resolve_output_path(path, cli.print_dir);
let mut out = stdout();
write!(out, "{}", output.display())?;
out.write_all(if cli.null { b"\0" } else { b"\n" })?;
out.flush()?;
}
None if app.cd_on_exit => {
let output = app.active_pane().current_dir.clone();
let mut out = stdout();
write!(out, "{}", output.display())?;
out.write_all(if cli.null { b"\0" } else { b"\n" })?;
out.flush()?;
}
None => process::exit(1),
}
Ok(())
}
fn run_loop<W: io::Write>(
terminal: &mut Terminal<CrosstermBackend<W>>,
app: &mut App,
) -> io::Result<()> {
loop {
terminal.draw(|frame| draw(app, frame))?;
if app.handle_event()? {
break;
}
if let Some(path) = app.open_with_editor.take() {
if let Some(binary_str) = app.editor.binary() {
let editor_label = app.editor.label().to_string();
let mut parts = binary_str.split_whitespace();
let binary = parts.next().unwrap_or(&binary_str).to_string();
let extra_args: Vec<&str> = parts.collect();
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let status = {
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("cmd");
cmd.arg("/C").arg(&binary);
for a in &extra_args {
cmd.arg(a);
}
cmd.arg(&path).status()
}
#[cfg(not(windows))]
{
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::IntoRawFd;
let tty_fd = tty_file.into_raw_fd();
unsafe {
use std::os::unix::io::FromRawFd;
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()
}
};
let _ = enable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
);
let _ = terminal.clear();
app.left.reload();
app.right.reload();
match status {
Ok(s) if s.success() => {
let fname = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
app.status_msg = format!("Returned from {editor_label} \u{2014} {fname}");
}
Ok(s) => {
app.status_msg =
format!("Editor exited with status {}", s.code().unwrap_or(-1));
}
Err(e) => {
app.status_msg = format!("Error launching '{binary}': {e}");
}
}
}
}
}
Ok(())
}
fn open_file_directly(path: &std::path::Path, editor: &Editor) -> io::Result<()> {
let binary_str = match editor.binary() {
Some(b) => b,
None => {
let fname = path.file_name().unwrap_or_default().to_string_lossy();
eprintln!("tfe: cannot open '{fname}' — no editor configured.");
eprintln!();
eprintln!(" Set an editor with one of:");
eprintln!(" tfe --editor nvim # use Neovim");
eprintln!(" tfe --editor vim # use Vim");
eprintln!(" tfe --editor helix # use Helix");
eprintln!(" tfe --editor nano # use Nano");
eprintln!(" tfe --editor \"code --wait\" # use VS Code");
eprintln!();
eprintln!(" Or pick one interactively: run tfe, then press Shift+E.");
eprintln!(" The selection is persisted across sessions.");
process::exit(2);
}
};
let fname = path.file_name().unwrap_or_default().to_string_lossy();
let editor_label = editor.label();
eprintln!("tfe: opening '{fname}' in {editor_label}…");
let mut parts = binary_str.split_whitespace();
let binary = parts.next().unwrap_or(&binary_str).to_string();
let extra_args: Vec<&str> = parts.collect();
let status = {
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("cmd");
cmd.arg("/C").arg(&binary);
for a in &extra_args {
cmd.arg(a);
}
cmd.arg(path).status()
}
#[cfg(not(windows))]
{
let mut cmd = std::process::Command::new(&binary);
for a in &extra_args {
cmd.arg(a);
}
cmd.arg(path).status()
}
};
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => {
let code = s.code().unwrap_or(1);
eprintln!("tfe: {editor_label} exited with status {code}");
process::exit(code);
}
Err(e) => {
eprintln!("tfe: failed to launch '{binary}': {e}");
eprintln!();
eprintln!(" Make sure '{binary}' is installed and on your PATH.");
eprintln!(" Change the editor with: tfe --editor <name>");
process::exit(2);
}
}
}