#![forbid(unsafe_code)]
mod actions;
mod app;
mod cli;
mod config;
mod input;
mod metafmt;
mod pathfmt;
mod paths;
mod plugin;
mod preview;
mod session;
mod terminal;
mod ui;
mod version;
mod workers;
use std::io::{self, stdout};
use std::panic;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use anyhow::Result;
use clap::Parser;
use crossterm::event::{self, Event, EventStream};
use crossterm::execute;
use crossterm::terminal::{self as tty, EnterAlternateScreen, LeaveAlternateScreen};
use futures::StreamExt;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tokio::sync::mpsc;
use crate::actions::shell_exec;
use crate::actions::Action;
use crate::app::state::AppState;
use crate::cli::Cli;
use crate::input::InputCtx;
use crate::preview::provider::{PreviewContent, PreviewCtx, PreviewOutcome, PreviewRegistry};
use crate::terminal::KeyOutcome;
use crate::workers::fswatch::FsWatchHandle;
use crate::workers::{GitCache, WorkerMsg};
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
if cli.paths {
println!("{}", paths::report());
return Ok(());
}
let cwd_file = cli.cwd_file.clone();
let default_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let _ = execute!(stdout(), LeaveAlternateScreen);
let _ = tty::disable_raw_mode();
default_hook(info);
}));
if let Ok(log_dir) = std::env::temp_dir().canonicalize() {
let file_appender = tracing_appender::rolling::never(&log_dir, paths::LOG_FILE);
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
let _ = tracing_subscriber::fmt()
.with_writer(non_blocking)
.with_env_filter(
tracing_subscriber::EnvFilter::builder()
.with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into())
.from_env_lossy(),
)
.try_init();
std::mem::forget(_guard);
}
let data_dir = paths::data_dir();
if let Err(e) = std::fs::create_dir_all(&data_dir) {
tracing::debug!("failed to create data dir: {e}");
}
let config_state_file = data_dir.join(config::last_used::STATE_FILE_NAME);
let config_source = config::last_used::resolve(
cli.config.clone(),
config::last_used::remembered(&config_state_file),
cli.no_config,
);
let (config, config_warning) = load_configured(&config_source)?;
if let config::ConfigSource::Explicit(ref path) = config_source {
if let Err(e) = config::last_used::remember(&config_state_file, path) {
tracing::debug!("failed to remember --config path: {e}");
}
}
let mut state = AppState::with_config(cli.start_path, config)
.map_err(|e| anyhow::anyhow!("failed to open start directory: {e}"))?;
state.settings.file = match &config_source {
config::ConfigSource::Explicit(path) => Some(path.clone()),
config::ConfigSource::Remembered(path) if config_warning.is_none() => Some(path.clone()),
_ => None,
};
state.settings.remember_in = Some(config_state_file.clone());
if let Some(warning) = config_warning {
state.set_error(warning);
}
state.bookmark_store =
match plugin::bookmarks::BookmarkStore::open(data_dir.join(paths::BOOKMARKS_FILE)) {
Ok(store) => Some(store),
Err(e) => {
tracing::debug!("failed to load bookmarks: {e}");
None
}
};
state.recent_dirs = session::RecentDirs::load(&data_dir.join(paths::RECENT_DIRS_FILE));
state.recent_dirs.visit(state.cwd.clone());
let mut engine = plugin::PluginEngine::new()
.map_err(|e| anyhow::anyhow!("failed to init plugin engine: {e}"))?;
plugin::load_enabled_plugins(&mut engine, &state.config.plugins.enabled);
state.plugin_engine = Some(engine);
preview::graphics::configure(
&state.config.preview.image_protocol,
(
state.config.preview.image_cell_width,
state.config.preview.image_cell_height,
),
);
let mut registry = PreviewRegistry::new();
preview::register_defaults(&mut registry);
let (worker_tx, worker_rx) = workers::channel();
state.terminal.attach(worker_tx.clone());
state.transfers.attach(worker_tx.clone());
let git_cache = workers::git::new_cache();
refresh_preview(&mut state, ®istry, &worker_tx);
let initial_cwd = state.cwd.clone();
if state.config.general.git_status_enabled {
workers::git::spawn_git_status(initial_cwd.clone(), worker_tx.clone(), git_cache.clone());
}
let mut fs_watch_handle: Option<FsWatchHandle> = workers::fswatch::spawn_fswatch(
initial_cwd,
worker_tx.clone(),
state.config.general.fs_watch_debounce_ms,
);
tty::enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend).expect("failed to initialize terminal");
let run_result = run_event_loop(
&mut terminal,
&mut state,
®istry,
worker_rx,
worker_tx.clone(),
git_cache,
&mut fs_watch_handle,
)
.await;
execute!(stdout(), LeaveAlternateScreen)?;
tty::disable_raw_mode()?;
let cancelled = matches!(&run_result, Err(ref e) if e.to_string() == "cancelled");
if run_result.is_ok() {
if let Some(ref path) = cwd_file {
if let Err(e) = session::write_cwd_file(&state.cwd, path) {
tracing::warn!("failed to write cwd-file: {e}");
}
}
state
.recent_dirs
.save(&data_dir.join(paths::RECENT_DIRS_FILE));
} else if cancelled {
state
.recent_dirs
.save(&data_dir.join(paths::RECENT_DIRS_FILE));
}
if cancelled {
Ok(())
} else {
run_result
}
}
fn load_configured(source: &config::ConfigSource) -> Result<(config::TrailConfig, Option<String>)> {
match source {
config::ConfigSource::Defaults => {
tracing::info!("using built-in default configuration");
let config =
config::load(None).map_err(|e| anyhow::anyhow!("failed to load config: {e}"))?;
Ok((config, None))
}
config::ConfigSource::Explicit(path) => {
tracing::info!(?path, "loading configuration from --config");
let config = config::load(Some(path))
.map_err(|e| anyhow::anyhow!("failed to load config: {e}"))?;
Ok((config, None))
}
config::ConfigSource::Remembered(path) => match config::load(Some(path)) {
Ok(config) => {
tracing::info!(?path, "loading remembered configuration");
Ok((config, None))
}
Err(e) => {
tracing::warn!(
?path,
"remembered config failed to load; falling back to defaults: {e}"
);
let config = config::load(None)
.map_err(|e| anyhow::anyhow!("failed to load config: {e}"))?;
let warning = format!(
"remembered config {} failed to load ({e}); using defaults",
path.display()
);
Ok((config, Some(warning)))
}
},
}
}
fn refresh_preview(state: &mut AppState, registry: &PreviewRegistry, tx: &mpsc::Sender<WorkerMsg>) {
if let Some(entry) = state.selected_entry().cloned() {
if let Some(engine) = &state.plugin_engine {
engine.fire_on_select(&entry.path);
}
let generation = state.preview.begin(&entry.path);
let ctx = PreviewCtx {
show_hidden: state.show_hidden,
worker_tx: tx.clone(),
generation,
text_sync_threshold_bytes: state.config.general.text_sync_threshold_kb * 1024,
max_preview_lines: state.config.preview.max_lines,
};
match registry.preview_for(&entry, &ctx) {
PreviewOutcome::Ready(content) => {
state.preview.content = content;
}
PreviewOutcome::Deferred => {
state.preview.content = PreviewContent::Loading;
}
}
} else {
state.preview.begin(&PathBuf::new());
state.preview.content = PreviewContent::Empty;
}
state.dirty = true;
}
fn resubscribe_fswatch(
new_cwd: PathBuf,
tx: &mpsc::Sender<WorkerMsg>,
handle: &mut Option<FsWatchHandle>,
debounce_ms: u64,
) {
*handle = None;
*handle = workers::fswatch::spawn_fswatch(new_cwd, tx.clone(), debounce_ms);
}
const TERMINAL_FRAME: Duration = Duration::from_millis(16);
async fn frame_timer(wait: Option<Duration>) {
match wait {
Some(wait) => tokio::time::sleep(wait).await,
None => std::future::pending().await,
}
}
#[allow(clippy::too_many_arguments)]
async fn run_event_loop(
terminal: &mut ratatui::Terminal<CrosstermBackend<io::Stdout>>,
state: &mut AppState,
registry: &PreviewRegistry,
mut worker_rx: mpsc::Receiver<WorkerMsg>,
worker_tx: mpsc::Sender<WorkerMsg>,
git_cache: GitCache,
fs_watch_handle: &mut Option<FsWatchHandle>,
) -> Result<()> {
let mut ctx = InputCtx::default();
let mut should_quit = false;
let mut cancelled = false;
ui::render(terminal, state)?;
state.dirty = false;
let mut last_render = Instant::now();
let mut event_stream = EventStream::new();
while !should_quit {
let mut worker_drained = false;
let mut needs_preview_refresh = false;
while let Ok(msg) = worker_rx.try_recv() {
let is_listing_change = matches!(
&msg,
WorkerMsg::Git { .. }
| WorkerMsg::FsChanged { .. }
| WorkerMsg::TransferDone { .. }
);
let prev_cwd = state.cwd.clone();
handle_worker_msg(
msg,
state,
&worker_tx,
&git_cache,
fs_watch_handle,
&prev_cwd,
);
worker_drained = true;
if is_listing_change && state.dirty {
needs_preview_refresh = true;
}
}
if needs_preview_refresh {
refresh_preview(state, registry, &worker_tx);
}
let frame_wait = state
.terminal
.output_pending()
.then(|| TERMINAL_FRAME.saturating_sub(last_render.elapsed()));
if !worker_drained && frame_wait != Some(Duration::ZERO) {
tokio::select! {
maybe_event = event_stream.next() => {
match maybe_event {
Some(Ok(event)) => {
match event {
Event::Key(key) => {
handle_key_event(
key,
terminal,
state,
registry,
&worker_tx,
&git_cache,
fs_watch_handle,
&mut ctx,
&mut should_quit,
&mut cancelled,
);
}
Event::Resize(_, _) => {
if let Err(e) = terminal.clear() {
tracing::debug!("terminal clear after resize failed: {e}");
}
state.dirty = true;
}
_ => {}
}
}
Some(Err(e)) => {
tracing::debug!("terminal event error: {e}");
}
None => {
should_quit = true;
}
}
}
Some(msg) = worker_rx.recv() => {
let prev_cwd = state.cwd.clone();
handle_worker_msg(
msg,
state,
&worker_tx,
&git_cache,
fs_watch_handle,
&prev_cwd,
);
}
() = frame_timer(frame_wait) => {}
}
}
if state.terminal.output_pending() && last_render.elapsed() >= TERMINAL_FRAME {
state.dirty = true;
}
if state.dirty {
ui::render(terminal, state)?;
state.dirty = false;
last_render = Instant::now();
state.terminal.frame_drawn();
}
}
if cancelled {
Err(anyhow::anyhow!("cancelled"))
} else {
Ok(())
}
}
#[allow(clippy::too_many_arguments)]
fn handle_key_event(
key: event::KeyEvent,
terminal: &mut ratatui::Terminal<CrosstermBackend<io::Stdout>>,
state: &mut AppState,
registry: &PreviewRegistry,
worker_tx: &mpsc::Sender<WorkerMsg>,
git_cache: &GitCache,
fs_watch_handle: &mut Option<FsWatchHandle>,
ctx: &mut InputCtx,
should_quit: &mut bool,
cancelled: &mut bool,
) {
let old_selected = state.selected;
let old_selected_path = state.selected_entry().map(|e| e.path.clone());
let old_cwd = state.cwd.clone();
if key.kind == crossterm::event::KeyEventKind::Press {
state.clear_notice();
}
match terminal::handle_key(state, &key) {
KeyOutcome::NotMine => {}
KeyOutcome::Handled => return,
KeyOutcome::Quit { cancel } => {
*should_quit = true;
*cancelled = cancel;
return;
}
}
if let Some(action) = input::dispatch(key, state, ctx) {
if action == Action::Quit || action == Action::Cancel {
let cancel = action == Action::Cancel;
if terminal::allow_quit(state, cancel) {
*should_quit = true;
*cancelled = cancel;
}
return;
}
let is_prefix = matches!(action, Action::SetPendingNavKey(_));
let forces_preview_refresh = matches!(action, Action::Refresh | Action::ToggleHidden);
if let Err(e) = actions::apply(action, state) {
state.set_error(e.to_string());
}
if !is_prefix && state.pending_nav_key.is_some() {
state.pending_nav_key = None;
state.dirty = true;
}
let selected_path = state.selected_entry().map(|e| &e.path);
if state.selected != old_selected
|| selected_path != old_selected_path.as_ref()
|| state.cwd != old_cwd
|| forces_preview_refresh
{
refresh_preview(state, registry, worker_tx);
}
} else if key.kind == crossterm::event::KeyEventKind::Press && state.pending_nav_key.is_some() {
state.pending_nav_key = None;
state.dirty = true;
}
if state.cwd != old_cwd {
resubscribe_fswatch(
state.cwd.clone(),
worker_tx,
fs_watch_handle,
state.config.general.fs_watch_debounce_ms,
);
if state.config.general.git_status_enabled {
workers::git::spawn_git_status(state.cwd.clone(), worker_tx.clone(), git_cache.clone());
}
state.git = None;
}
if let Some(Action::RunExternal { argv, cwd, pause }) = state.pending_external.take() {
let argv_refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
if let Err(e) = shell_exec::run_external(&argv_refs, &cwd, pause) {
state.set_error(format!("exec: {e}"));
}
if let Err(e) = terminal.clear() {
tracing::debug!("terminal clear after run_external failed: {e}");
}
let _ = state.refresh();
refresh_preview(state, registry, worker_tx);
state.dirty = true;
}
}
fn handle_worker_msg(
msg: WorkerMsg,
state: &mut AppState,
worker_tx: &mpsc::Sender<WorkerMsg>,
git_cache: &GitCache,
fs_watch_handle: &mut Option<FsWatchHandle>,
_prev_cwd: &std::path::Path,
) {
match &msg {
WorkerMsg::FsChanged { path } => {
let changed_path = path.clone();
let is_cwd_change = changed_path == state.cwd;
let is_git_head_change = changed_path
.file_name()
.map(|n| n == "HEAD")
.unwrap_or(false)
&& changed_path
.parent()
.and_then(|p| p.file_name())
.map(|n| n == ".git")
.unwrap_or(false);
if is_cwd_change {
workers::git::invalidate(git_cache, &changed_path);
if let Err(e) = state.refresh() {
tracing::warn!("refresh after FsChanged failed: {e}");
resubscribe_fswatch(
state.cwd.clone(),
worker_tx,
fs_watch_handle,
state.config.general.fs_watch_debounce_ms,
);
}
if state.config.general.git_status_enabled {
workers::git::spawn_git_status(
changed_path.clone(),
worker_tx.clone(),
git_cache.clone(),
);
}
tracing::debug!(?changed_path, "refreshed after FsChanged");
} else if is_git_head_change {
workers::git::invalidate(git_cache, &state.cwd);
if state.config.general.git_status_enabled {
workers::git::spawn_git_status(
state.cwd.clone(),
worker_tx.clone(),
git_cache.clone(),
);
}
state.dirty = true;
tracing::debug!(?changed_path, "git branch change detected via .git/HEAD");
}
}
_ => {
workers::merge(msg, state);
}
}
}