mod app;
mod bulk_rename;
mod fs_ops;
mod job;
mod logging;
mod menu;
mod pane;
mod preview;
mod state;
mod ui;
use std::env;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::ExecutableCommand;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Position;
use signal_hook::consts::TERM_SIGNALS;
use signal_hook::flag;
use app::{App, Dialog, ExternalRequest, SettingItem, StartDirs};
use menu::{Action, FN_KEYS, MENU_BAR};
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = io::stdout().execute(DisableMouseCapture);
let _ = io::stdout().execute(LeaveAlternateScreen);
}
#[derive(Debug, PartialEq, Eq)]
enum CliRequest {
Run {
left: Option<String>,
right: Option<String>,
},
Version,
Help,
Invalid(String),
}
fn parse_args<I: IntoIterator<Item = String>>(args: I) -> CliRequest {
let mut dirs = Vec::new();
let mut invalid = None;
for arg in args {
match arg.as_str() {
"-V" | "-v" | "--version" | "-version" => return CliRequest::Version,
"-h" | "--help" => return CliRequest::Help,
option if option.len() > 1 && option.starts_with('-') => {
invalid.get_or_insert(format!("unknown option '{option}'"));
}
_ => dirs.push(arg),
}
}
if let Some(message) = invalid {
return CliRequest::Invalid(message);
}
if dirs.len() > 2 {
return CliRequest::Invalid(format!(
"too many directories: expected at most 2 (left and right pane), got {}",
dirs.len()
));
}
let mut dirs = dirs.into_iter();
CliRequest::Run {
left: dirs.next(),
right: dirs.next(),
}
}
fn resolve_dir(arg: &str) -> std::result::Result<PathBuf, String> {
let path = Path::new(arg);
let resolved = path
.canonicalize()
.map_err(|err| format!("cannot open '{arg}': {err}"))?;
if !resolved.is_dir() {
return Err(format!("'{arg}' is not a directory"));
}
Ok(resolved)
}
fn version_text() -> String {
format!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
}
fn usage_text() -> String {
let name = env!("CARGO_PKG_NAME");
format!(
"{}\n{}\n\n\
Usage:\n \
{name} Start with the panes as usual (below)\n \
{name} LEFT_DIR Open the left pane in LEFT_DIR\n \
{name} LEFT_DIR RIGHT_DIR Open the left and right pane\n \
{name} -h, --help Print this help\n \
{name} -V, -v, --version Print the version (-version works too)\n\n\
As usual, the left pane starts in the current directory, or\n\
where you left it with \"Restore last session\" (Options > Settings);\n\
the right pane starts where you left it. First run: both in the\n\
current directory.\n",
version_text(),
env!("CARGO_PKG_DESCRIPTION"),
)
}
fn main() -> Result<()> {
let start_dirs = match parse_args(env::args().skip(1)) {
CliRequest::Run { left, right } => {
let resolve = |arg: Option<String>| arg.as_deref().map(resolve_dir).transpose();
match resolve(left).and_then(|left| {
Ok(StartDirs {
left,
right: resolve(right)?,
})
}) {
Ok(dirs) => dirs,
Err(message) => {
eprintln!("error: {message}");
std::process::exit(2);
}
}
}
CliRequest::Version => {
println!("{}", version_text());
return Ok(());
}
CliRequest::Help => {
print!("{}", usage_text());
return Ok(());
}
CliRequest::Invalid(message) => {
eprint!("error: {message}\n\n{}", usage_text());
std::process::exit(2);
}
};
let default_panic_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
logging::log_error(&format!("panic: {info}"));
default_panic_hook(info);
}));
let should_exit = Arc::new(AtomicBool::new(false));
for &signal in TERM_SIGNALS {
flag::register(signal, Arc::clone(&should_exit))?;
}
let mouse_capture_enabled = state::load_settings()
.get(SettingItem::MouseCapture.key())
.copied()
.unwrap_or(true);
enable_raw_mode().context("enable_raw_mode")?;
let mut stdout = io::stdout();
stdout
.execute(EnterAlternateScreen)
.context("EnterAlternateScreen")?;
if mouse_capture_enabled {
stdout
.execute(EnableMouseCapture)
.context("EnableMouseCapture")?;
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("Terminal::new")?;
let result = run(
&mut terminal,
&should_exit,
mouse_capture_enabled,
start_dirs,
);
restore_terminal();
if let Err(err) = &result {
logging::log_error(&format!("fatal: {err:?}"));
}
result
}
fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
should_exit: &AtomicBool,
mouse_capture_enabled: bool,
start_dirs: StartDirs,
) -> Result<()> {
let mut app = App::with_start_dirs(start_dirs)?;
let mut mouse_capture_applied = mouse_capture_enabled;
while !app.should_quit && !should_exit.load(Ordering::Relaxed) {
if app.mouse_capture != mouse_capture_applied {
set_mouse_capture(app.mouse_capture)?;
mouse_capture_applied = app.mouse_capture;
}
terminal.draw(|frame| ui::draw(frame, &mut app))?;
let poll_timeout = if app.job.is_some() { 100 } else { 200 };
if event::poll(Duration::from_millis(poll_timeout))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
handle_key(&mut app, key.code, key.modifiers)?;
}
Event::Mouse(mouse) => handle_mouse(&mut app, mouse)?,
_ => {}
}
}
app.sync_preview_scroll();
app.reap_finished_children();
app.poll_job()?;
if let Some(request) = app.external_request.take() {
run_external(terminal, request, &mut app)?;
mouse_capture_applied = app.mouse_capture;
}
}
app.cancel_job_and_wait();
app.save_state();
Ok(())
}
fn set_mouse_capture(enabled: bool) -> Result<()> {
if enabled {
io::stdout()
.execute(EnableMouseCapture)
.context("EnableMouseCapture")?;
} else {
let _ = io::stdout().execute(DisableMouseCapture);
}
Ok(())
}
fn run_external(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
request: ExternalRequest,
app: &mut App,
) -> Result<()> {
match request {
ExternalRequest::View(path) => {
let default_pager = if cfg!(windows) { "notepad" } else { "less" };
run_pager_or_editor(terminal, "PAGER", default_pager, &path, app).map(|_| ())
}
ExternalRequest::Edit(path) => {
run_pager_or_editor(terminal, "EDITOR", default_editor(), &path, app).map(|_| ())
}
ExternalRequest::RenameSelected {
list_file,
dir,
names,
} => {
let editor_ok =
run_pager_or_editor(terminal, "EDITOR", default_editor(), &list_file, app)?;
app.finish_bulk_rename(&list_file, dir, &names, editor_ok);
Ok(())
}
ExternalRequest::Shell { command, cwd } => run_shell_command(terminal, &command, &cwd, app),
ExternalRequest::RevealTerminal => reveal_terminal(terminal, app),
}
}
fn default_editor() -> &'static str {
if cfg!(windows) { "notepad" } else { "vi" }
}
fn run_pager_or_editor(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
env_var: &str,
default_program: &str,
path: &Path,
app: &mut App,
) -> Result<bool> {
let command_line = env::var(env_var).unwrap_or_else(|_| default_program.to_string());
let mut parts =
shell_words::split(&command_line).unwrap_or_else(|_| vec![command_line.clone()]);
if parts.is_empty() {
parts.push(default_program.to_string());
}
let program = parts.remove(0);
let args = parts;
restore_terminal();
let status = Command::new(&program).args(&args).arg(path).status();
enable_raw_mode().context("enable_raw_mode")?;
io::stdout()
.execute(EnterAlternateScreen)
.context("EnterAlternateScreen")?;
set_mouse_capture(app.mouse_capture)?;
terminal.clear()?;
let succeeded = matches!(&status, Ok(status) if status.success());
match status {
Ok(status) if !status.success() => {
app.set_error(format!("{program} exited with {status}"));
}
Err(err) => {
app.set_error(format!("Failed to launch {program}: {err}"));
}
Ok(_) => {}
}
app.left.reload()?;
app.right.reload()?;
Ok(succeeded)
}
fn run_shell_command(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
command: &str,
cwd: &Path,
app: &mut App,
) -> Result<()> {
let (shell, shell_flag) = if cfg!(windows) {
(
env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()),
"/C",
)
} else {
(
env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
"-c",
)
};
let wait = app.wait_after_shell_command;
restore_terminal();
println!("$ {command}");
let mut cmd = Command::new(&shell);
cmd.arg(shell_flag);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.raw_arg(command);
}
#[cfg(not(windows))]
{
cmd.arg(command);
}
let status = cmd.current_dir(cwd).status();
if wait {
match &status {
Ok(status) if !status.success() => println!("\n[exited with {status}]"),
Err(err) => println!("\n[failed to launch {shell}: {err}]"),
Ok(_) => {}
}
println!("\nPress Enter to continue...");
let mut discard = String::new();
let _ = io::stdin().read_line(&mut discard);
}
enable_raw_mode().context("enable_raw_mode")?;
io::stdout()
.execute(EnterAlternateScreen)
.context("EnterAlternateScreen")?;
set_mouse_capture(app.mouse_capture)?;
terminal.clear()?;
match status {
Ok(status) if !status.success() => {
app.set_error(format!("Command exited with {status}"));
}
Err(err) => {
app.set_error(format!("Failed to launch {shell}: {err}"));
}
Ok(_) => {}
}
app.left.reload()?;
app.right.reload()?;
Ok(())
}
fn reveal_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &App) -> Result<()> {
let _ = io::stdout().execute(DisableMouseCapture);
io::stdout()
.execute(LeaveAlternateScreen)
.context("LeaveAlternateScreen")?;
loop {
if let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
let is_ctrl_o =
key.code == KeyCode::Char('o') && key.modifiers.contains(KeyModifiers::CONTROL);
if is_ctrl_o || key.code == KeyCode::Esc {
break;
}
}
}
io::stdout()
.execute(EnterAlternateScreen)
.context("EnterAlternateScreen")?;
set_mouse_capture(app.mouse_capture)?;
terminal.clear()?;
Ok(())
}
fn side_at(app: &App, column: u16, row: u16) -> Option<app::Side> {
let pos = Position::new(column, row);
if app.left_pane_area.contains(pos) {
Some(app::Side::Left)
} else if app.right_pane_area.contains(pos) {
Some(app::Side::Right)
} else {
None
}
}
fn handle_mouse(app: &mut App, mouse: MouseEvent) -> Result<()> {
if app.error_dialog.is_some()
|| app.job_visible
|| app.help_open
|| app.logs_open
|| app.about_open
|| app.dialog_is_text_input()
|| app.dialog_is_settings()
|| !matches!(app.dialog, Dialog::None)
{
return Ok(());
}
match mouse.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !app.menu_open => {
if app.debounced_scroll() {
return Ok(());
}
if let Some(side) = side_at(app, mouse.column, mouse.row) {
let is_preview_side = app.quick_view && side != app.active;
let is_list_side = side == app.active;
let scroll_up = mouse.kind == MouseEventKind::ScrollUp;
const WHEEL_PREVIEW_LINES: usize = 3;
match (is_preview_side, is_list_side, scroll_up) {
(true, _, true) => {
for _ in 0..WHEEL_PREVIEW_LINES {
app.scroll_preview_up();
}
}
(true, _, false) => {
for _ in 0..WHEEL_PREVIEW_LINES {
app.scroll_preview_down();
}
}
(false, true, true) => app.pane_mut(side).move_up(),
(false, true, false) => app.pane_mut(side).move_down(),
(false, false, _) => {}
}
}
}
MouseEventKind::Down(MouseButton::Left) => {
handle_mouse_click(app, mouse.column, mouse.row)?
}
_ => {}
}
Ok(())
}
fn handle_mouse_click(app: &mut App, column: u16, row: u16) -> Result<()> {
let pos = Position::new(column, row);
let bar_category = app
.menu_bar_tiles
.iter()
.find(|(rect, _)| rect.contains(pos))
.map(|&(_, category)| category);
if let Some(category) = bar_category {
app.open_menu_at(category);
return Ok(());
}
if app.menu_open {
let item = app
.menu_item_tiles
.iter()
.find(|(rect, _)| rect.contains(pos))
.map(|&(_, idx)| idx);
if let Some(idx) = item {
let action = MENU_BAR[app.menu_category].items[idx];
if app.action_enabled(action) {
app.menu_item = idx;
app.confirm_menu_selection()?;
}
} else {
app.close_menu();
}
return Ok(());
}
let clicked = app
.fn_key_tiles
.iter()
.find(|(rect, _)| rect.contains(pos))
.map(|(_, action)| *action);
if let Some(clicked_action) = clicked {
match clicked_action {
Some(action) => app.run_action(action)?,
None => app.open_menu(),
}
}
Ok(())
}
fn menu_category_for(letter: char) -> Option<usize> {
let lower = letter.to_ascii_lowercase();
MENU_BAR
.iter()
.position(|category| category.title.to_ascii_lowercase().starts_with(lower))
}
fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> Result<()> {
if app.error_dialog.is_some() {
app.error_dialog = None;
return Ok(());
}
if app.job_visible {
match code {
KeyCode::Esc => app.hide_job(),
KeyCode::Left | KeyCode::Right | KeyCode::Tab => app.toggle_job_focus(),
KeyCode::Enter => app.activate_job_button(),
_ => {}
}
return Ok(());
}
if app.help_open {
match code {
KeyCode::Left => app.help_page = app.help_page.saturating_sub(1),
KeyCode::Right => app.help_page = (app.help_page + 1).min(ui::HELP_PAGE_COUNT - 1),
_ => app.help_open = false,
}
return Ok(());
}
if app.logs_open {
app.logs_open = false;
return Ok(());
}
if app.about_open {
app.about_open = false;
return Ok(());
}
if app.dialog_is_text_input() {
match code {
KeyCode::Enter => {
if app.dialog_cancel_focused {
app.cancel_dialog();
} else {
app.confirm_dialog()?;
}
}
KeyCode::Esc => app.cancel_dialog(),
KeyCode::Left | KeyCode::Right => app.toggle_dialog_focus(),
KeyCode::Backspace => app.text_input_backspace(),
KeyCode::Char(c) => app.text_input_push(c),
_ => {}
}
return Ok(());
}
if app.dialog_is_settings() {
match code {
KeyCode::Up => app.settings_move_up(),
KeyCode::Down => app.settings_move_down(),
KeyCode::Char(' ') => app.settings_toggle_selected(),
KeyCode::Left => app.settings_select_left(),
KeyCode::Right => app.settings_select_right(),
KeyCode::Enter => app.settings_save(),
KeyCode::Esc | KeyCode::F(9) => app.settings_cancel(),
_ => {}
}
return Ok(());
}
if !matches!(app.dialog, Dialog::None) {
match code {
KeyCode::Esc => app.cancel_dialog(),
KeyCode::Left | KeyCode::Right => app.toggle_dialog_focus(),
KeyCode::Enter => {
if app.dialog_cancel_focused {
app.cancel_dialog();
} else {
app.confirm_dialog()?;
}
}
_ => {}
}
return Ok(());
}
if app.menu_open {
match code {
KeyCode::Esc => app.close_menu(),
KeyCode::Left => app.menu_left(),
KeyCode::Right => app.menu_right(),
KeyCode::Up => app.menu_up(),
KeyCode::Down => app.menu_down(),
KeyCode::Enter => app.confirm_menu_selection()?,
KeyCode::F(9) => app.close_menu(),
KeyCode::Char(c) => {
if let Some(idx) = menu_category_for(c) {
app.open_menu_at(idx);
}
}
_ => {}
}
return Ok(());
}
if app.quick_search.is_some() {
match code {
KeyCode::Char(c)
if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
app.quick_search_push(c);
return Ok(());
}
KeyCode::Backspace => {
app.quick_search_backspace();
return Ok(());
}
KeyCode::Up => {
app.quick_search_step(false);
return Ok(());
}
KeyCode::Down => {
app.quick_search_step(true);
return Ok(());
}
KeyCode::Esc | KeyCode::Enter => {
app.end_quick_search();
return Ok(());
}
_ => app.end_quick_search(),
}
}
match code {
KeyCode::Tab => {
if app.quick_view {
app.toggle_preview_focus();
} else {
app.toggle_active();
}
}
KeyCode::Up if app.quick_view && app.preview_focus => app.scroll_preview_up(),
KeyCode::Down if app.quick_view && app.preview_focus => app.scroll_preview_down(),
KeyCode::Up => app.active_pane().move_up(),
KeyCode::Down => app.active_pane().move_down(),
KeyCode::Insert => app.active_pane().toggle_mark_selected(),
KeyCode::Home if app.quick_view && app.preview_focus => app.scroll_preview_to_top(),
KeyCode::End if app.quick_view && app.preview_focus => app.scroll_preview_to_bottom(),
KeyCode::PageUp if app.quick_view && app.preview_focus => app.scroll_preview_page_up(),
KeyCode::PageDown if app.quick_view && app.preview_focus => app.scroll_preview_page_down(),
KeyCode::Home => app.active_pane().move_to_top(),
KeyCode::End => app.active_pane().move_to_bottom(),
KeyCode::PageUp => {
let page = app.pane_visible_lines;
app.active_pane().move_page_up(page);
}
KeyCode::PageDown => {
let page = app.pane_visible_lines;
app.active_pane().move_page_down(page);
}
KeyCode::Backspace => app.command_line_backspace(),
KeyCode::Esc => app.command_line_clear(),
KeyCode::Enter => {
let preview_has_focus = app.quick_view && app.preview_focus;
if !app.submit_command_line() && !preview_has_focus {
app.open_selected()?;
}
}
KeyCode::F(4) if modifiers.contains(KeyModifiers::SHIFT) => {
app.run_action(Action::NewFile)?;
}
KeyCode::F(8) if modifiers.contains(KeyModifiers::SHIFT) => {
app.run_action(Action::DeletePermanently)?;
}
KeyCode::F(1) if modifiers.contains(KeyModifiers::ALT) => {
app.sync_left_to_right_dir()?;
}
KeyCode::F(2) if modifiers.contains(KeyModifiers::ALT) => {
app.sync_right_to_left_dir()?;
}
KeyCode::Char(c) if modifiers.contains(KeyModifiers::ALT) => {
if let Some(idx) = menu_category_for(c) {
app.open_menu_at(idx);
}
}
KeyCode::Char('o') if modifiers.contains(KeyModifiers::CONTROL) => {
app.request_reveal_terminal();
}
KeyCode::Char('l') if modifiers.contains(KeyModifiers::CONTROL) => {
app.run_action(Action::ShowLogs)?;
}
KeyCode::Char('f') if modifiers.contains(KeyModifiers::CONTROL) => {
app.run_action(Action::QuickSearch)?;
}
KeyCode::Char('b') if modifiers.contains(KeyModifiers::CONTROL) => {
app.run_action(Action::ShowProgress)?;
}
KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => {
app.run_action(Action::Settings)?;
}
KeyCode::Char('q') if modifiers.contains(KeyModifiers::CONTROL) => {
app.run_action(Action::Quit)?;
}
KeyCode::F(n) => {
if let Some(fn_key) = FN_KEYS.iter().find(|k| k.key == format!("F{n}")) {
match fn_key.action {
Some(action) => app.run_action(action)?,
None => app.open_menu(),
}
}
}
KeyCode::Char(c) => app.command_line_push(c),
_ => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
fn run(left: Option<&str>, right: Option<&str>) -> CliRequest {
CliRequest::Run {
left: left.map(String::from),
right: right.map(String::from),
}
}
#[test]
fn no_arguments_starts_the_ui() {
assert_eq!(parse_args(args(&[])), run(None, None));
}
#[test]
fn version_and_help_flags_are_recognised() {
for flag in ["-V", "-v", "--version", "-version"] {
assert_eq!(parse_args(args(&[flag])), CliRequest::Version, "{flag}");
}
assert_eq!(parse_args(args(&["-h"])), CliRequest::Help);
assert_eq!(parse_args(args(&["--help"])), CliRequest::Help);
}
#[test]
fn flags_win_over_directories() {
assert_eq!(parse_args(args(&["~/a", "-v"])), CliRequest::Version);
assert_eq!(parse_args(args(&["--nope", "-h"])), CliRequest::Help);
}
#[test]
fn bare_version_is_a_directory() {
assert_eq!(parse_args(args(&["version"])), run(Some("version"), None));
}
#[test]
fn up_to_two_directories_fill_left_then_right() {
assert_eq!(parse_args(args(&["."])), run(Some("."), None));
assert_eq!(parse_args(args(&["a", "b"])), run(Some("a"), Some("b")));
assert!(matches!(
parse_args(args(&["a", "b", "c"])),
CliRequest::Invalid(_)
));
}
#[test]
fn unknown_options_are_rejected() {
assert_eq!(
parse_args(args(&["-x"])),
CliRequest::Invalid("unknown option '-x'".into())
);
assert_eq!(parse_args(args(&["-"])), run(Some("-"), None));
}
#[test]
fn resolve_dir_accepts_directories_only() {
let tmp = std::env::temp_dir().join(format!("pc-resolve-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let file = tmp.join("file.txt");
std::fs::write(&file, "x").unwrap();
assert_eq!(
resolve_dir(tmp.to_str().unwrap()),
Ok(tmp.canonicalize().unwrap())
);
assert!(
resolve_dir(file.to_str().unwrap())
.unwrap_err()
.contains("is not a directory")
);
assert!(
resolve_dir(tmp.join("missing").to_str().unwrap())
.unwrap_err()
.starts_with("cannot open")
);
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn version_text_matches_the_package() {
assert_eq!(
version_text(),
format!("peter-commander {}", env!("CARGO_PKG_VERSION"))
);
}
}