fm84 0.37.0

A synthwave dual-pane TUI file manager
mod app;
mod archive;
mod attributes;
mod background;
mod cli;
mod constants;
mod diff;
mod display;
mod find;
mod fs_ops;
mod git;
mod glob;
mod input;
mod options;
mod scroll_bar;
mod search;
mod strip;
mod ui;
mod utils;
mod viewer;

use app::AppState;
use color_eyre::Result;
use crossterm::{
    cursor::{SetCursorStyle, Show},
    event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement},
};
use input::handle_input;
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io::{self, IsTerminal, Stdout, stdout};
use std::sync::{Mutex, OnceLock};
use ui::render_ui;

type Tui = Terminal<CrosstermBackend<Stdout>>;

fn main() -> Result<()> {
    color_eyre::install()?;

    // Before the terminal check, so `fm84 --version` works in a pipe or a
    // script, where there is no terminal to take.
    let (left, right, choosedir) = match cli::parse(std::env::args_os().skip(1)) {
        Ok(cli::Command::Help) => {
            println!("{}", cli::help());
            return Ok(());
        }
        Ok(cli::Command::Version) => {
            println!("{}", cli::version());
            return Ok(());
        }
        Ok(cli::Command::ShellInit(shell)) => {
            println!("{}", cli::shell_init(shell));
            return Ok(());
        }
        Ok(cli::Command::Run { left, right, choosedir }) => (start_dir(left), start_dir(right), choosedir),
        Err(message) => usage_error(&message),
    };

    // Launched from a .desktop entry with no terminal, raw mode fails with a
    // bare ENXIO that nobody sees. Say what is wrong instead.
    if !stdout().is_terminal() {
        eprintln!("fm84 needs a terminal.");
        eprintln!();
        eprintln!("Run it from one, or launch it through one, for example:");
        eprintln!("    kitty -e fm84");
        eprintln!();
        eprintln!("In a .desktop file, set Terminal=true or make Exec start a terminal.");
        std::process::exit(1);
    }

    install_panic_hook();

    let mut terminal = init_terminal()?;
    let run_result = run(&mut terminal, left, right, choosedir);
    let restore_result = restore_terminal();

    // Report what went wrong in the app before any trouble tearing the terminal down.
    run_result?;
    restore_result?;
    Ok(())
}

/// A directory from the command line, checked before the terminal is taken
/// so a mistyped one is said plainly in the shell. Made absolute but not
/// resolved, so a path through a symlink shows as it was typed.
fn start_dir(dir: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
    let dir = dir?;
    match std::path::absolute(&dir) {
        Ok(absolute) if absolute.is_dir() => Some(absolute),
        Ok(_) => usage_error(&format!("not a directory: {}", dir.display())),
        Err(e) => usage_error(&format!("cannot use {}: {}", dir.display(), e)),
    }
}

/// Say what was wrong with the command line, and stop. Exit status 2, as
/// most tools give for a usage error.
fn usage_error(message: &str) -> ! {
    eprintln!("fm84: {message}");
    eprintln!("Try 'fm84 --help' for more information.");
    std::process::exit(2);
}

fn init_terminal() -> io::Result<Tui> {
    take_terminal()?;
    Terminal::new(CrosstermBackend::new(stdout()))
}

/// Whether the terminal speaks kitty's keyboard protocol - kitty, foot,
/// WezTerm, Ghostty and Alacritty among others. Asked once: the answer does
/// not change, and asking again after the external editor would cost a round
/// trip each time.
static ENHANCED_KEYS: OnceLock<bool> = OnceLock::new();

/// Raw mode, the alternate screen and the mouse: everything fm84 needs from
/// the terminal, and everything restore_terminal gives back.
///
/// Where the terminal can, keys are sent unambiguously too. The usual way
/// sends F3 with a modifier - Shift+F3, Ctrl+F3 - as `ESC [ 1 ; n R`, which
/// is also how a terminal answers where its cursor is, and crossterm takes it
/// for that and drops it. Asked for here, after the alternate screen is
/// entered: kitty keeps a separate setting for each screen.
///
/// The shifted character is asked for as well. Without it a chord with Shift
/// in it is sent as the key unshifted: Alt+* came as Alt+Shift+8, which is
/// Alt+8, and went to the eighth tab instead of inverting the selection.
fn take_terminal() -> io::Result<()> {
    enable_raw_mode()?;
    execute!(stdout(), EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?;
    if *ENHANCED_KEYS.get_or_init(|| supports_keyboard_enhancement().unwrap_or(false)) {
        let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
        execute!(stdout(), PushKeyboardEnhancementFlags(flags))?;
    }
    Ok(())
}

/// The cursor shape last sent to the terminal, None while it has its own. Sent
/// again only when F11 changes it, or once fm84 has the terminal back from the
/// external editor, which sets a shape of its own.
static CURSOR_SENT: Mutex<Option<SetCursorStyle>> = Mutex::new(None);

/// Give the terminal's cursor the shape F11 asks for, if it is not that
/// already. Classic draws its own and leaves the terminal's alone.
fn send_cursor_shape(options: &options::Options) {
    use options::CursorShape;
    let wanted = match (options.cursor, options.cursor_blink) {
        (CursorShape::Classic, _) => None,
        (CursorShape::Bar, true) => Some(SetCursorStyle::BlinkingBar),
        (CursorShape::Bar, false) => Some(SetCursorStyle::SteadyBar),
        (CursorShape::Block, true) => Some(SetCursorStyle::BlinkingBlock),
        (CursorShape::Block, false) => Some(SetCursorStyle::SteadyBlock),
        (CursorShape::Underline, true) => Some(SetCursorStyle::BlinkingUnderScore),
        (CursorShape::Underline, false) => Some(SetCursorStyle::SteadyUnderScore),
    };
    let Ok(mut sent) = CURSOR_SENT.lock() else {
        return;
    };
    if *sent != wanted {
        let _ = execute!(stdout(), wanted.unwrap_or(SetCursorStyle::DefaultUserShape));
        *sent = wanted;
    }
}

/// Undo everything take_terminal did. Runs on normal exit, on error, and from the
/// panic hook, so it can't rely on the Terminal still being alive - a build with
/// panic = "abort" never drops it. Showing the cursor here covers that case.
fn restore_terminal() -> io::Result<()> {
    // Not allowed to stop the rest: this runs from the panic hook too.
    if ENHANCED_KEYS.get() == Some(&true) {
        let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
    }
    // try_lock, as a panic while it is held would otherwise wait for ever.
    if let Ok(mut sent) = CURSOR_SENT.try_lock()
        && sent.take().is_some()
    {
        let _ = execute!(stdout(), SetCursorStyle::DefaultUserShape);
    }
    execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture, DisableBracketedPaste, Show)?;
    disable_raw_mode()
}

/// Put the terminal back before the panic report prints. Without this the report
/// is written in raw mode onto the alternate screen, which the terminal discards
/// on exit - the user sees a mangled shell and no message at all.
fn install_panic_hook() {
    // color_eyre::install() has already set its hook; chain onto it.
    let hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |panic_info| {
        let _ = restore_terminal();
        hook(panic_info);
    }));
}

/// `left` and `right` are the directories the command line named, which win
/// over the remembered ones. `choosedir` is where to say which directory
/// fm84 was left in, for `cd84` to go to.
fn run(terminal: &mut Tui, left: Option<std::path::PathBuf>, right: Option<std::path::PathBuf>, choosedir: Option<std::path::PathBuf>) -> io::Result<()> {
    let mut app_state = AppState::new();
    display::apply(&app_state.options);

    // A directory gone since then is handled by the reload, which climbs to
    // the nearest one still there.
    if app_state.options.remember_dirs
        && let Some((left, right)) = options::load_session()
    {
        app_state.dir_left = left.dir().to_path_buf();
        app_state.dir_right = right.dir().to_path_buf();
        app_state.tabs_left = app::Tabs::new(left.tabs, left.active);
        app_state.tabs_right = app::Tabs::new(right.tabs, right.active);
    }
    // A directory on the command line takes the place of the tab that would
    // have shown, and the others stay.
    if let Some(left) = left {
        app_state.dir_left = left;
    }
    if let Some(right) = right {
        app_state.dir_right = right;
    }
    app_state.show_preview = app_state.options.preview_on_start;

    app_state.reload_panel(true, None);
    app_state.reload_panel(false, None);

    loop {
        // Both before the draw, so the frame shows the newest it could: a cursor
        // move shows its preview, and a transfer its latest count, rather than
        // whatever they were an iteration ago.
        app_state.poll_listings();
        app_state.poll_watchers();
        app_state.refresh_cursor_detail();
        app_state.refresh_preview();
        app_state.poll_transfer();
        app_state.poll_branch_switch();
        app_state.poll_find_files();
        app_state.poll_dir_sizes();
        app_state.image_cache.collect();
        send_cursor_shape(&app_state.options);
        render_ui(terminal, &mut app_state);
        // An image fits the width the viewer was just drawn at, so a new width
        // means drawing again now rather than on the next event.
        // Lines wrap to the width just drawn at too.
        let refitted = app_state.fit_viewer_image() | app_state.fit_viewer_wrap();
        if refitted {
            render_ui(terminal, &mut app_state);
        }
        app_state.refresh_stale_panels();
        if !handle_input(&mut app_state)? {
            break;
        }
        if let Some(path) = app_state.external_edit.take() {
            run_external_editor(terminal, &mut app_state, &path)?;
        }
    }

    // Nowhere to report a failure by now, and nothing lost by it but a
    // starting point.
    if app_state.options.remember_dirs {
        let panel = |(tabs, active)| options::PanelSession { tabs, active };
        let _ = options::save_session(&panel(app_state.tab_dirs(true)), &panel(app_state.tab_dirs(false)));
    }

    crate::archive::clean_scratch();
    if let Some(file) = choosedir {
        // Inside an archive, which a shell cannot cd into: the directory the
        // archive is in.
        let dir = if app_state.panel_archive(app_state.is_left_active).is_some() {
            app_state.panel_real_dir(app_state.is_left_active)
        } else if app_state.is_left_active {
            app_state.dir_left.clone()
        } else {
            app_state.dir_right.clone()
        };
        write_chosen_dir(&file, &dir)?;
    }
    Ok(())
}

/// The directory for `cd84` to go to, as the bytes of its name and nothing
/// after them: a name can end in a newline, and one written after it would
/// be taken for part of the name. Not UTF-8 on Unix, if the name is not.
/// The directory on show, not one a panel is still on its way to: the shell
/// would wait on a dead mount the way the panel did.
fn write_chosen_dir(file: &std::path::Path, dir: &std::path::Path) -> io::Result<()> {
    std::fs::write(file, dir.as_os_str().as_encoded_bytes()).map_err(|e| io::Error::new(e.kind(), format!("cannot write the directory to {}: {e}", file.display())))
}

/// Give the terminal to the editor F11 names until it exits, then take it
/// back. The command is split on whitespace; `{}` stands for the file, which
/// otherwise goes on the end.
fn run_external_editor(terminal: &mut Tui, app_state: &mut AppState, path: &std::path::Path) -> io::Result<()> {
    let command = app_state.options.editor.clone();
    let mut parts: Vec<std::ffi::OsString> = command.split_whitespace().map(|part| utils::substitute(part, path.as_os_str())).collect();
    if !command.contains("{}") {
        parts.push(path.as_os_str().to_owned());
    }
    let program = parts.remove(0);

    restore_terminal()?;
    let result = std::process::Command::new(&program).args(&parts).current_dir(path.parent().unwrap_or(std::path::Path::new("."))).status();
    take_terminal()?;
    // The editor drew over everything; forget what ratatui thinks is there.
    terminal.clear()?;

    match result {
        Ok(status) if !status.success() => app_state.display_error(format!("{} exited with {}", program.display(), status)),
        Ok(_) => {}
        Err(e) => app_state.display_error(format!("Cannot start {}: {}", program.display(), e)),
    }
    // The file has likely changed size, and the editor may have left a backup
    // or swap file beside it.
    app_state.reload_panel(true, None);
    app_state.reload_panel(false, None);
    Ok(())
}