mod app;
mod bookmarks;
mod config;
#[cfg(test)]
mod e2e_tests;
mod fileops;
mod git;
mod i18n;
mod keymap;
#[cfg(test)]
mod mem_tests;
mod preview;
mod session;
#[cfg(test)]
mod speed_tests;
#[cfg(test)]
mod test_support;
mod ui;
mod vcs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
};
use ratatui_image::errors::Errors;
use ratatui_image::picker::Picker;
use ratatui_image::thread::{ResizeRequest, ResizeResponse};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use app::{
App, FileOpResult, FilterPoolResult, FsBurstKinds, GitOpResult, IgnoredResult, KittyResult,
MdEncodeRequest, MdEncodeResult, MdImageResult, MediaResult, RemoteFetch, SortKey,
StatusResult,
};
use keymap::{Action, KeyPress, Motion, Resolution, Surface};
type ResizeResult = Result<ResizeResponse, Errors>;
#[derive(Debug, Clone, PartialEq, Eq)]
enum ParsedArgs {
Version,
Help,
Open(Option<PathBuf>),
}
fn parse_args(args: &[String]) -> ParsedArgs {
match args.get(1).map(String::as_str) {
Some("--version") | Some("-V") => ParsedArgs::Version,
Some("--help") | Some("-h") => ParsedArgs::Help,
Some(arg) => ParsedArgs::Open(Some(PathBuf::from(arg))),
None => ParsedArgs::Open(None),
}
}
#[derive(Debug)]
enum Startup {
Version,
Help,
Open(PathBuf),
}
fn resolve_startup(args: &[String]) -> Result<Startup> {
Ok(match parse_args(args) {
ParsedArgs::Version => Startup::Version,
ParsedArgs::Help => Startup::Help,
ParsedArgs::Open(path_arg) => {
let dir = match path_arg {
Some(p) => p,
None => std::env::current_dir().context("could not get the current directory")?,
};
let dir = std::fs::canonicalize(&dir).unwrap_or_else(|_| {
std::env::current_dir()
.map(|cwd| cwd.join(&dir))
.unwrap_or(dir)
});
validate_root(&dir)?;
Startup::Open(dir)
}
})
}
fn validate_root(path: &Path) -> Result<()> {
let meta =
std::fs::metadata(path).with_context(|| format!("cannot open {}", path.display()))?;
if !meta.is_dir() {
anyhow::bail!("cannot open {}: not a directory", path.display());
}
std::fs::read_dir(path).with_context(|| format!("cannot open {}", path.display()))?;
Ok(())
}
fn version_text() -> String {
format!("konoma {}\n", env!("CARGO_PKG_VERSION"))
}
fn help_text() -> String {
format!(
"konoma {version} — a full-screen preview-focused terminal file browser\n\
\n\
Usage: konoma [DIR]\n\
\n\
Opens DIR in the tree view (defaults to the current directory).\n\
\n\
Options:\n\
\x20\x20-h, --help Print this help and exit\n\
\x20\x20-V, --version Print version and exit\n\
\n\
Press ? inside konoma for the full, context-sensitive key reference.\n\
Documentation: https://lesim-co-ltd.github.io/konoma/\n",
version = env!("CARGO_PKG_VERSION"),
)
}
const DEFAULT_PICKER_FONT_SIZE: (u16, u16) = (10, 20);
fn cell_px_from_window_size(
columns: u16,
rows: u16,
width_px: u16,
height_px: u16,
) -> Option<(u16, u16)> {
if columns == 0 || rows == 0 || width_px == 0 || height_px == 0 {
return None;
}
let cell_w = (width_px / columns).max(1);
let cell_h = (height_px / rows).max(1);
Some((cell_w, cell_h))
}
fn fix_picker_font_size(picker: Picker, window_size: Option<(u16, u16, u16, u16)>) -> Picker {
let fs = picker.font_size();
if (fs.width, fs.height) != DEFAULT_PICKER_FONT_SIZE {
return picker;
}
let Some((columns, rows, width_px, height_px)) = window_size else {
return picker;
};
let Some((cell_w, cell_h)) = cell_px_from_window_size(columns, rows, width_px, height_px)
else {
return picker;
};
#[allow(deprecated)]
let mut fixed = Picker::from_fontsize(ratatui_image::FontSize::new(cell_w, cell_h));
fixed.set_protocol_type(picker.protocol_type());
fixed
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let dir = match resolve_startup(&args)? {
Startup::Version => {
print!("{}", version_text());
return Ok(());
}
Startup::Help => {
print!("{}", help_text());
return Ok(());
}
Startup::Open(dir) => dir,
};
let (cfg, cfg_err) = config::Config::load_reporting();
if cfg.ui.syntax_highlight {
let root = dir.clone();
std::thread::spawn(move || preview::code::warm_dir(root));
}
let mut terminal = ratatui::init();
let _ = crossterm::execute!(std::io::stdout(), EnableBracketedPaste);
let picker = Picker::from_query_stdio().ok();
let window_size = crossterm::terminal::window_size()
.ok()
.map(|w| (w.columns, w.rows, w.width, w.height));
let picker = picker.map(|p| fix_picker_font_size(p, window_size));
if picker.is_some() {
std::thread::spawn(preview::svg::warm_fontdb);
if cfg.ui.mermaid != "text" {
std::thread::spawn(preview::markdown::warm_mermaid);
}
}
let (req_tx, req_rx) = unbounded_channel::<ResizeRequest>();
let (resp_tx, resp_rx) = unbounded_channel::<ResizeResult>();
let worker = std::thread::spawn(move || resize_worker(req_rx, resp_tx));
let (media_tx, media_rx) = std::sync::mpsc::channel::<MediaResult>();
let (kitty_tx, kitty_rx) = std::sync::mpsc::channel::<KittyResult>();
let (md_img_tx, md_img_rx) = std::sync::mpsc::channel::<MdImageResult>();
let (md_remote_tx, md_remote_rx) = std::sync::mpsc::channel::<RemoteFetch>();
let (md_enc_tx, md_enc_worker_rx) = std::sync::mpsc::channel::<MdEncodeRequest>();
let (md_enc_res_tx, md_enc_res_rx) = std::sync::mpsc::channel::<MdEncodeResult>();
let (ignored_tx, ignored_rx) = std::sync::mpsc::channel::<IgnoredResult>();
let (status_tx, status_rx) = std::sync::mpsc::channel::<StatusResult>();
let (fileop_tx, fileop_rx) = std::sync::mpsc::channel::<FileOpResult>();
let (gitop_tx, gitop_rx) = std::sync::mpsc::channel::<GitOpResult>();
let (pool_tx, pool_rx) = std::sync::mpsc::channel::<FilterPoolResult>();
let start_dir = dir.clone();
let mut app = App::new(dir, cfg)?;
app.attach_media_loader(media_tx);
app.attach_kitty_loader(kitty_tx);
app.attach_md_image_loader(md_img_tx);
app.attach_remote_md_loader(md_remote_tx);
app.attach_git_loader(ignored_tx);
app.attach_status_loader(status_tx);
app.attach_fileop_runner(fileop_tx);
app.attach_gitop_runner(gitop_tx);
app.attach_filter_pool_loader(pool_tx);
let km_report = app.keymap_report();
app.flash = match (cfg_err, km_report) {
(Some(a), Some(b)) => Some(format!("{a} / {b}")),
(Some(a), None) => Some(a),
(None, b) => b,
};
if let Some(pk) = picker.clone() {
std::thread::spawn(move || app::md_encode_worker(pk, md_enc_worker_rx, md_enc_res_tx));
app.attach_md_encoder(md_enc_tx);
}
if let Some(picker) = picker {
app.attach_image_backend(picker, req_tx);
}
app.attach_session_store(session::SessionStore::load(&start_dir));
app.restore_session();
let result = run(
&mut terminal,
&mut app,
resp_rx,
WorkerRx {
media: media_rx,
kitty: kitty_rx,
md_img: md_img_rx,
md_remote: md_remote_rx,
md_enc: md_enc_res_rx,
ignored: ignored_rx,
status: status_rx,
fileop: fileop_rx,
gitop: gitop_rx,
pool: pool_rx,
},
);
app.save_session();
let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste);
ratatui::restore();
app.detach_image_backend();
drop(app);
let _ = worker.join();
result
}
fn resize_worker(mut rx: UnboundedReceiver<ResizeRequest>, tx: UnboundedSender<ResizeResult>) {
let Ok(rt) = tokio::runtime::Builder::new_current_thread().build() else {
return;
};
rt.block_on(async move {
while let Some(req) = rx.recv().await {
if tx.send(req.resize_encode()).is_err() {
break; }
}
});
}
fn classify_fs_paths(paths: &[PathBuf]) -> (bool, bool) {
if paths.is_empty() {
return (true, false); }
let ignore_rules = paths.iter().any(|p| is_ignore_rule_file(p));
(true, ignore_rules)
}
fn is_content_event(kind: ¬ify::EventKind) -> bool {
use notify::event::{AccessKind, AccessMode};
match kind {
notify::EventKind::Access(AccessKind::Close(AccessMode::Write)) => true,
notify::EventKind::Access(_) => false,
_ => true,
}
}
fn is_structural_event(kind: ¬ify::EventKind) -> bool {
use notify::event::{AccessKind, AccessMode, ModifyKind};
!matches!(
kind,
notify::EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Metadata(_))
| notify::EventKind::Access(AccessKind::Close(AccessMode::Write))
)
}
const MAX_BURST_PATHS: usize = 1024;
#[derive(Default)]
struct BurstPaths {
seen: std::collections::HashSet<PathBuf>,
paths: Vec<PathBuf>,
overflowed: bool,
}
impl BurstPaths {
fn push(&mut self, p: &Path) -> bool {
if self.overflowed {
return false;
}
if self.seen.contains(p) {
return false;
}
if self.paths.len() >= MAX_BURST_PATHS {
self.overflowed = true;
self.seen.clear();
self.paths.clear();
return false;
}
let owned = p.to_path_buf();
self.seen.insert(owned.clone());
self.paths.push(owned);
true
}
fn finish(self) -> Option<Vec<PathBuf>> {
if self.overflowed {
None
} else {
Some(self.paths)
}
}
}
fn follow_candidates(paths: &[PathBuf]) -> Vec<PathBuf> {
paths
.iter()
.filter(|p| !p.components().any(|c| c.as_os_str() == ".git"))
.cloned()
.collect()
}
fn is_ignore_rule_file(p: &Path) -> bool {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
match name {
".gitignore" => !p.components().any(|c| c.as_os_str() == "node_modules"),
"exclude" => {
p.parent()
.and_then(|pp| pp.file_name())
.and_then(|n| n.to_str())
== Some("info")
&& p.parent()
.and_then(|pp| pp.parent())
.and_then(|g| g.file_name())
.and_then(|n| n.to_str())
== Some(".git")
}
_ => false,
}
}
struct WorkerRx {
media: std::sync::mpsc::Receiver<MediaResult>,
kitty: std::sync::mpsc::Receiver<KittyResult>,
md_img: std::sync::mpsc::Receiver<MdImageResult>,
md_remote: std::sync::mpsc::Receiver<RemoteFetch>,
md_enc: std::sync::mpsc::Receiver<MdEncodeResult>,
ignored: std::sync::mpsc::Receiver<IgnoredResult>,
status: std::sync::mpsc::Receiver<StatusResult>,
fileop: std::sync::mpsc::Receiver<FileOpResult>,
gitop: std::sync::mpsc::Receiver<GitOpResult>,
pool: std::sync::mpsc::Receiver<FilterPoolResult>,
}
fn poll_timeout(app: &App) -> Duration {
if app.is_media_loading()
|| app.md_images_loading()
|| app.kitty_build_pending()
|| app.filter_pool_scan_in_flight()
{
return Duration::from_millis(16);
}
app.gif_poll_timeout()
.into_iter()
.chain(app.md_gif_poll_timeout())
.min()
.unwrap_or(Duration::from_millis(100))
}
fn run(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
mut resp_rx: UnboundedReceiver<ResizeResult>,
rx: WorkerRx,
) -> Result<()> {
let (fs_tx, fs_rx) = std::sync::mpsc::channel::<(FsBurstKinds, Vec<PathBuf>)>();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(ev) = res {
if !is_content_event(&ev.kind) {
return;
}
let (meaningful, ignore_rules) = classify_fs_paths(&ev.paths);
if meaningful {
let kinds = FsBurstKinds {
ignore_rules_changed: ignore_rules,
structural: is_structural_event(&ev.kind),
};
let _ = fs_tx.send((kinds, follow_candidates(&ev.paths)));
}
}
})
.ok();
let mut watched_root: Option<PathBuf> = None;
let mut attempted_root: Option<PathBuf> = Some(app.tab.root.clone());
if rewatch(watcher.as_mut(), &mut watched_root, &app.tab.root) == WatchOutcome::Failed {
app.flash = Some(watch_failed_flash(app.lang, &app.tab.root));
}
let mut watched_extra: Option<PathBuf> = None;
let mut attempted_extra: Option<PathBuf> = None;
let mut watched_git: Option<PathBuf> = None;
let mut attempted_git: Option<PathBuf> = None;
let (hl_tx, hl_rx) = std::sync::mpsc::channel::<()>();
const FOLLOW_MIN_DWELL: Duration = Duration::from_millis(1000);
let mut pending_follow: Option<PathBuf> = None;
let mut last_follow_jump: Option<std::time::Instant> = None;
let mut deferred_fs = false;
let mut deferred_kinds = FsBurstKinds::default();
let mut needs_redraw = true;
loop {
if needs_redraw {
terminal.draw(|frame| ui::render(frame, app))?;
needs_redraw = false;
if app.take_md_overlay_moved() {
terminal.clear()?;
terminal.draw(|frame| ui::render(frame, app))?;
}
}
if let Some((ext, path)) = app.take_warm_job() {
let tx = hl_tx.clone();
std::thread::spawn(move || {
preview::code::warm_file(&ext, &path);
let _ = tx.send(());
});
}
if event::poll(poll_timeout(app))? {
let mut quit = false;
loop {
let ev = event::read()?;
match ev {
Event::Key(key) if key.kind == KeyEventKind::Press => {
let res = handle_key(app, key);
if resolve_key_result(app, res) {
quit = true;
break;
}
needs_redraw = true;
}
Event::Resize(_, _) => needs_redraw = true,
Event::Paste(s) if paste_accepted(app.surface()) => {
app.handle_paste(s);
needs_redraw = true;
}
_ => {}
}
if !event::poll(Duration::from_millis(0))? {
break;
}
}
if quit {
break;
}
}
if let Some((path, line)) = app.take_pending_edit() {
run_editor(terminal, app, &path, line)?;
needs_redraw = true;
}
if app.take_launch_git_tool() {
run_git_tool(terminal, app)?;
let _ = app.refresh();
needs_redraw = true;
}
while hl_rx.try_recv().is_ok() {
app.clear_highlight_pending();
needs_redraw = true;
}
if (app.is_highlight_pending() && app.loading_is_indicator())
|| app.is_media_loading()
|| app.busy_indicator_active()
{
app.tick_spinner();
needs_redraw = true;
}
while let Ok(resp) = resp_rx.try_recv() {
if app.apply_image_resize(resp) {
needs_redraw = true;
}
}
while let Ok(result) = rx.media.try_recv() {
if app.apply_media(result) {
needs_redraw = true;
}
}
while let Ok(result) = rx.kitty.try_recv() {
if app.apply_kitty(result) {
needs_redraw = true;
}
}
while let Ok(result) = rx.md_img.try_recv() {
if app.apply_md_image(result) {
needs_redraw = true;
}
}
while let Ok(result) = rx.md_remote.try_recv() {
if app.apply_remote_fetch(result) {
needs_redraw = true;
}
}
while let Ok(result) = rx.md_enc.try_recv() {
if app.apply_md_encode(result) {
needs_redraw = true;
}
}
while let Ok(result) = rx.ignored.try_recv() {
if app.apply_ignored(result) {
needs_redraw = true;
}
}
while let Ok(result) = rx.status.try_recv() {
if app.apply_statuses(result) {
needs_redraw = true;
}
}
while let Ok(result) = rx.fileop.try_recv() {
if app.apply_file_op(result) {
needs_redraw = true;
}
}
while let Ok(result) = rx.gitop.try_recv() {
if app.apply_git_op(result) {
needs_redraw = true;
}
}
while let Ok(result) = rx.pool.try_recv() {
if app.apply_filter_pool(result) {
needs_redraw = true;
}
}
if app.advance_gif_if_due() {
needs_redraw = true;
}
if app.advance_md_gifs_if_due() {
needs_redraw = true;
}
let mut fs_changed = false;
let mut burst_kinds = FsBurstKinds::default();
let mut burst = BurstPaths::default();
let defer_fs = app.should_defer_fs_events();
while let Ok((kinds, paths)) = fs_rx.try_recv() {
if defer_fs {
deferred_fs = true;
deferred_kinds.merge(kinds);
continue;
}
fs_changed = true;
burst_kinds.merge(kinds);
for p in paths {
if burst.push(&p) && app.follow_enabled() && app.follow_note_change(&p) {
pending_follow = Some(p);
}
}
}
let changed_paths = burst.finish().unwrap_or_default();
if !defer_fs && deferred_fs {
deferred_fs = false;
fs_changed = true;
burst_kinds.merge(deferred_kinds);
deferred_kinds = FsBurstKinds::default();
}
if fs_changed && !app.fs_burst_is_build_churn(&changed_paths, burst_kinds) {
app.refresh_fs_watched(burst_kinds.ignore_rules_changed, &changed_paths);
needs_redraw = true;
}
if pending_follow.is_some() && !app.follow_enabled() {
pending_follow = None; }
if let Some(p) = pending_follow.clone() {
if app.tab.preview_path.as_deref() == Some(p.as_path()) {
pending_follow = None;
} else if last_follow_jump.is_none_or(|t| t.elapsed() >= FOLLOW_MIN_DWELL) {
pending_follow = None;
app.follow_jump(&p);
last_follow_jump = Some(std::time::Instant::now());
needs_redraw = true;
}
}
if watch_target_changed(attempted_root.as_deref(), Some(app.tab.root.as_path())) {
attempted_root = Some(app.tab.root.clone());
if rewatch(watcher.as_mut(), &mut watched_root, &app.tab.root) == WatchOutcome::Failed {
app.flash = Some(watch_failed_flash(app.lang, &app.tab.root));
needs_redraw = true;
}
}
let want_extra = app.out_of_root_watch_dir();
if watch_target_changed(attempted_extra.as_deref(), want_extra.as_deref()) {
attempted_extra = want_extra.clone();
if set_extra_watch(watcher.as_mut(), &mut watched_extra, want_extra.as_deref())
== WatchOutcome::Failed
{
if let Some(dir) = want_extra.as_deref() {
app.flash = Some(watch_failed_flash(app.lang, dir));
needs_redraw = true;
}
}
}
let want_git = app.git_dir_watch();
if watch_target_changed(attempted_git.as_deref(), want_git.as_deref()) {
attempted_git = want_git.clone();
if set_extra_watch(watcher.as_mut(), &mut watched_git, want_git.as_deref())
== WatchOutcome::Failed
{
if let Some(dir) = want_git.as_deref() {
app.flash = Some(watch_failed_flash(app.lang, dir));
needs_redraw = true;
}
}
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WatchOutcome {
NoWatcher,
Removed,
Watching,
Failed,
}
fn watch_target_changed(
attempted: Option<&std::path::Path>,
target: Option<&std::path::Path>,
) -> bool {
attempted != target
}
fn rewatch(
watcher: Option<&mut notify::RecommendedWatcher>,
watched: &mut Option<PathBuf>,
root: &std::path::Path,
) -> WatchOutcome {
use notify::{RecursiveMode, Watcher};
let Some(w) = watcher else {
return WatchOutcome::NoWatcher;
};
if let Some(old) = watched.take() {
let _ = w.unwatch(&old);
}
if w.watch(root, RecursiveMode::Recursive).is_ok() {
*watched = Some(root.to_path_buf());
WatchOutcome::Watching
} else {
WatchOutcome::Failed
}
}
fn set_extra_watch(
watcher: Option<&mut notify::RecommendedWatcher>,
watched: &mut Option<PathBuf>,
want: Option<&std::path::Path>,
) -> WatchOutcome {
use notify::{RecursiveMode, Watcher};
let Some(w) = watcher else {
return WatchOutcome::NoWatcher;
};
if let Some(old) = watched.take() {
let _ = w.unwatch(&old);
}
let Some(dir) = want else {
return WatchOutcome::Removed;
};
if w.watch(dir, RecursiveMode::NonRecursive).is_ok() {
*watched = Some(dir.to_path_buf());
WatchOutcome::Watching
} else {
WatchOutcome::Failed
}
}
fn watch_failed_flash(lang: crate::i18n::Lang, path: &std::path::Path) -> String {
format!(
"{}{}",
crate::i18n::tr(lang, crate::i18n::Msg::WatchFailedPrefix),
path.display()
)
}
fn run_editor(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
path: &std::path::Path,
line: Option<usize>,
) -> Result<()> {
let argv = app.cfg.editor.resolve(path, line);
let Some((prog, args)) = argv.split_first() else {
return Ok(());
};
let cwd = path.parent().unwrap_or(path).to_path_buf();
let status = run_external(terminal, app, prog, args, &cwd)?;
match status {
Ok(_) => app.reload_preview(), Err(e) => {
app.flash = Some(format!(
"{}{e}",
i18n::tr(app.lang, crate::i18n::Msg::EditorFailed)
))
}
}
Ok(())
}
fn run_git_tool(terminal: &mut ratatui::DefaultTerminal, app: &mut App) -> Result<()> {
let (tmpl, fallback) = match app.git_vcs {
crate::vcs::VcsKind::Git => (app.cfg.git.tool.trim(), "lazygit"),
#[cfg(feature = "git")]
crate::vcs::VcsKind::Jj => (app.cfg.jj.tool.trim(), "lazyjj"),
};
let tmpl = if tmpl.is_empty() { fallback } else { tmpl };
let mut parts = tmpl.split_whitespace().map(|s| s.to_string());
let Some(prog) = parts.next() else {
return Ok(());
};
let args: Vec<String> = parts.collect();
let cwd = git_workdir(&app.tab.root);
let status = run_external(terminal, app, &prog, &args, &cwd)?;
if let Err(e) = status {
app.flash = Some(format!(
"{}{prog}: {e}",
i18n::tr(
app.lang,
match app.git_vcs {
crate::vcs::VcsKind::Git => crate::i18n::Msg::GitToolFailed,
#[cfg(feature = "git")]
crate::vcs::VcsKind::Jj => crate::i18n::Msg::JjToolFailed,
}
)
));
}
Ok(())
}
#[cfg(feature = "git")]
fn git_workdir(root: &std::path::Path) -> PathBuf {
git::workdir(root).unwrap_or_else(|| root.to_path_buf())
}
#[cfg(not(feature = "git"))]
fn git_workdir(root: &std::path::Path) -> PathBuf {
root.to_path_buf()
}
fn run_external(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
prog: &str,
args: &[String],
cwd: &std::path::Path,
) -> Result<std::io::Result<std::process::ExitStatus>> {
use crossterm::event::{DisableMouseCapture, PopKeyboardEnhancementFlags};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, EndSynchronizedUpdate,
EnterAlternateScreen,
};
disable_raw_mode()?;
let status = std::process::Command::new(prog)
.args(args)
.current_dir(cwd)
.status();
enable_raw_mode()?;
let _ = execute!(std::io::stdout(), BeginSynchronizedUpdate);
execute!(std::io::stdout(), EnterAlternateScreen)?;
let _ = terminal.clear(); let _ = terminal.draw(|frame| ui::render(frame, app)); let _ = execute!(std::io::stdout(), EndSynchronizedUpdate);
let _ = execute!(
std::io::stdout(),
PopKeyboardEnhancementFlags,
DisableMouseCapture,
crossterm::event::EnableBracketedPaste,
);
while event::poll(Duration::from_millis(10)).unwrap_or(false) {
let _ = event::read();
}
Ok(status)
}
fn commit_visual_if_needed(app: &mut App, sfc: Surface) {
if sfc == Surface::Visual {
app.exit_visual_commit();
}
}
fn paste_accepted(sfc: Surface) -> bool {
sfc.is_text_input()
|| matches!(
sfc,
Surface::Tree | Surface::PreviewText | Surface::PreviewImage
)
}
fn sort_key_char(k: SortKey) -> char {
match k {
SortKey::Name => 'n',
SortKey::Size => 's',
SortKey::Modified => 'm',
SortKey::Ext => 'e',
}
}
fn dispatch_navigate(app: &mut App, sfc: Surface, m: Motion) {
match sfc {
Surface::Tree | Surface::Visual => match m {
Motion::Up => app.tree_prev(),
Motion::Down => app.tree_next(),
Motion::Top => app.tree_first(),
Motion::Bottom => app.tree_last(),
Motion::PageUp => app.tree_page(-1),
Motion::PageDown => app.tree_page(1),
Motion::HalfUp => app.tree_half_page(-1),
Motion::HalfDown => app.tree_half_page(1),
Motion::Left | Motion::Right | Motion::LineHome | Motion::LineEnd => {}
},
Surface::PreviewText | Surface::PreviewTextVisual if app.fence_pan_motion(m) => {}
Surface::PreviewText | Surface::PreviewTextVisual => match m {
Motion::Up => app.preview_scroll(-1),
Motion::Down => app.preview_scroll(1),
Motion::Top => app.preview_to_top(),
Motion::Bottom => app.preview_to_bottom(),
Motion::PageUp => app.preview_page(-1),
Motion::PageDown => app.preview_page(1),
Motion::HalfUp => app.preview_half_page(-1),
Motion::HalfDown => app.preview_half_page(1),
Motion::Left => app.preview_col_move(-1),
Motion::Right => app.preview_col_move(1),
Motion::LineHome => app.preview_col_home(),
Motion::LineEnd => app.preview_col_end(),
},
Surface::PreviewImage => match m {
Motion::Up => app.image_pan(0.0, -1.0),
Motion::Down => app.image_pan(0.0, 1.0),
Motion::Left => app.image_pan(-1.0, 0.0),
Motion::Right => app.image_pan(1.0, 0.0),
_ => {}
},
Surface::PreviewTable => match m {
Motion::Up => app.table_cursor_move(-1, 0),
Motion::Down => app.table_cursor_move(1, 0),
Motion::Left => app.table_cursor_move(0, -1),
Motion::Right => app.table_cursor_move(0, 1),
Motion::Top => app.table_row_to(false),
Motion::Bottom => app.table_row_to(true),
Motion::PageUp => app.table_page(-1),
Motion::PageDown => app.table_page(1),
Motion::HalfUp => app.table_half_page(-1),
Motion::HalfDown => app.table_half_page(1),
Motion::LineHome => app.table_col_to(false),
Motion::LineEnd => app.table_col_to(true),
},
#[cfg(feature = "git")]
Surface::PreviewGitDiff => match m {
Motion::Up => app.preview_scroll(-1),
Motion::Down => app.preview_scroll(1),
Motion::Top => app.preview_to_top(),
Motion::Bottom => app.preview_to_bottom(),
Motion::PageUp => app.preview_page(-1),
Motion::PageDown => app.preview_page(1),
Motion::HalfUp => app.preview_half_page(-1),
Motion::HalfDown => app.preview_half_page(1),
Motion::Left => app.preview_hscroll(-4),
Motion::Right => app.preview_hscroll(4),
Motion::LineHome => app.preview_hscroll_home(),
Motion::LineEnd => app.preview_hscroll_end(),
},
#[cfg(feature = "git")]
Surface::GitDetail => match m {
Motion::Up => app.git_detail_scroll_by(-1),
Motion::Down => app.git_detail_scroll_by(1),
Motion::Top => app.git_detail_scroll_to(false),
Motion::Bottom => app.git_detail_scroll_to(true),
Motion::PageUp => app.git_detail_scroll_by(-20),
Motion::PageDown => app.git_detail_scroll_by(20),
Motion::HalfUp => app.git_detail_scroll_by(-10),
Motion::HalfDown => app.git_detail_scroll_by(10),
Motion::Left => app.git_detail_hscroll_by(-4),
Motion::Right => app.git_detail_hscroll_by(4),
Motion::LineHome => app.git_detail_hscroll_home(),
Motion::LineEnd => app.git_detail_hscroll_end(),
},
#[cfg(feature = "git")]
Surface::GitChanges => match m {
Motion::Up => app.git_view_move(-1),
Motion::Down => app.git_view_move(1),
Motion::Top => app.git_view_move(i32::MIN),
Motion::Bottom => app.git_view_move(i32::MAX),
_ => {}
},
#[cfg(feature = "git")]
Surface::GitLog => match m {
Motion::Up => app.git_log_move(-1),
Motion::Down => app.git_log_move(1),
Motion::Top => app.git_log_move(i32::MIN),
Motion::Bottom => app.git_log_move(i32::MAX),
_ => {}
},
#[cfg(feature = "git")]
Surface::GitGraph => match m {
Motion::Up => app.git_graph_move(-1),
Motion::Down => app.git_graph_move(1),
Motion::Top => app.git_graph_move(i32::MIN),
Motion::Bottom => app.git_graph_move(i32::MAX),
_ => {}
},
#[cfg(feature = "git")]
Surface::GitGraphPicker => match m {
Motion::Up => app.git_graph_picker_move(-1),
Motion::Down => app.git_graph_picker_move(1),
Motion::Top => app.git_graph_picker_jump(true),
Motion::Bottom => app.git_graph_picker_jump(false),
_ => {}
},
#[cfg(feature = "git")]
Surface::GitBranches => match m {
Motion::Up => app.git_branch_move(-1),
Motion::Down => app.git_branch_move(1),
Motion::Top => app.git_branch_move(i32::MIN),
Motion::Bottom => app.git_branch_move(i32::MAX),
_ => {}
},
#[cfg(feature = "git")]
Surface::GitWorktrees => match m {
Motion::Up => app.git_worktree_move(-1),
Motion::Down => app.git_worktree_move(1),
Motion::Top => app.git_worktree_move(i32::MIN),
Motion::Bottom => app.git_worktree_move(i32::MAX),
_ => {}
},
Surface::Bookmarks => match m {
Motion::Up => app.bookmark_list_move(-1),
Motion::Down => app.bookmark_list_move(1),
_ => {}
},
Surface::Tabs => match m {
Motion::Up => app.tab_list_move(-1),
Motion::Down => app.tab_list_move(1),
_ => {}
},
Surface::Outline => match m {
Motion::Up => app.outline_move(-1),
Motion::Down => app.outline_move(1),
Motion::Top => {
let d = -(app.outline_sel() as i32);
app.outline_move(d);
}
Motion::Bottom => {
let d = (app.md_outline().len() as i32 - 1) - app.outline_sel() as i32;
app.outline_move(d);
}
_ => {}
},
Surface::TableCell => match m {
Motion::Up => app.table_cell_scroll_by(-1),
Motion::Down => app.table_cell_scroll_by(1),
Motion::Top => app.table_cell_scroll_to(false),
Motion::Bottom => app.table_cell_scroll_to(true),
Motion::PageUp => app.table_cell_page(-1),
Motion::PageDown => app.table_cell_page(1),
_ => {}
},
Surface::Help => match m {
Motion::Up => app.help_scroll_by(-1),
Motion::Down => app.help_scroll_by(1),
Motion::Top => app.help_scroll = 0,
Motion::Bottom => app.help_scroll = u16::MAX,
_ => {}
},
_ => {}
}
}
fn dispatch_action(app: &mut App, action: Action, sfc: Surface) -> Result<bool> {
#[cfg(feature = "git")]
if action.writes_repository() && !crate::vcs::caps(&app.tab.root).write {
app.flash = Some(crate::i18n::tr(app.lang, crate::i18n::Msg::VcsReadOnly).to_string());
return Ok(false);
}
match action {
Action::Noop => {}
Action::Navigate(m) => dispatch_navigate(app, sfc, m),
Action::TabNew => app.tab_new()?,
Action::TabClose => app.tab_close(),
Action::TabPrev => app.tab_cycle(-1),
Action::TabNext => app.tab_cycle(1),
Action::TabGoto(i) => app.tab_goto(i as usize),
Action::ToggleHelp => app.toggle_help(),
Action::CopyPath(kind) => app.copy_path(kind),
Action::CopyCodeBlock => app.md_copy_focused_code(),
Action::PasteJump => {
commit_visual_if_needed(app, sfc);
app.paste_jump();
}
Action::Quit => {
if app.request_quit() {
return Ok(false);
}
return Ok(true);
}
Action::CloseTabOrQuit => {
if app.tab_count() > 1 {
app.tab_close();
} else if app.request_quit() {
return Ok(false);
} else {
return Ok(true);
}
}
Action::FilterStart => app.start_filter(),
Action::TreeDescend => app.tree_descend()?,
Action::TreeActivate => app.tree_activate()?,
Action::TreeLeave => app.tree_leave()?,
Action::ToggleHidden => app.toggle_hidden()?,
Action::ToggleInfo => app.toggle_info(),
Action::RequestEdit => app.request_edit(),
Action::OpenGitView => app.open_git_view(),
Action::Refresh => app.refresh()?,
Action::CyclePathStyle => app.cycle_path_style(),
Action::OpenSortMenu => app.open_sort_menu(),
Action::MarkSet => app.start_mark_set(),
Action::MarkJump => app.open_bookmark_list(),
Action::SetAnchor => app.reanchor_root(),
Action::ResetAnchor => app.reset_anchor(),
Action::OpenGitDiffCursor => app.tree_open_git_diff(),
Action::EnterVisual => app.enter_visual(),
Action::ToggleSelect => app.toggle_select(),
Action::ToggleChangedFilter => app.toggle_changed_filter(),
Action::JumpNextChange => app.jump_changed(1),
Action::JumpPrevChange => app.jump_changed(-1),
Action::ToggleFollow => app.toggle_follow(),
Action::FileCreate => {
commit_visual_if_needed(app, sfc);
app.start_create();
}
Action::FileRename => {
commit_visual_if_needed(app, sfc);
if app.has_selection() {
app.start_batch_rename();
} else {
app.start_rename();
}
}
Action::FileDelete => {
commit_visual_if_needed(app, sfc);
app.start_delete();
}
Action::FileCopy => {
commit_visual_if_needed(app, sfc);
app.copy_selection();
}
Action::FileCut => {
commit_visual_if_needed(app, sfc);
app.cut_selection();
}
Action::FilePaste => {
commit_visual_if_needed(app, sfc);
app.paste()?;
}
Action::FileDuplicate => {
commit_visual_if_needed(app, sfc);
app.duplicate_selection()?;
}
Action::VisualCommit => app.exit_visual_commit(),
Action::VisualSelectSiblings => app.visual_select_scope(false),
Action::VisualSelectAll => app.visual_select_scope(true),
Action::PreviewBack => {
if app.is_git_diff_preview() {
app.close_git_diff();
} else {
app.back_to_tree();
}
}
Action::SearchStart => app.start_search(),
Action::SearchNext => app.search_next(1),
Action::SearchPrev => app.search_next(-1),
Action::PreviewEnterVisual => app.preview_enter_visual(false),
Action::PreviewEnterVisualLine => app.preview_enter_visual(true),
Action::PreviewCopySelection => app.preview_copy_selection(),
Action::PreviewCopySelectionRef => app.preview_copy_selection_ref(),
Action::PreviewExitVisual => app.preview_exit_visual(),
Action::ToggleMarkdownRaw => app.toggle_md_raw(),
Action::LinkFocusNext => app.md_focus_move(1),
Action::LinkFocusPrev => app.md_focus_move(-1),
Action::LinkOpen => app.md_activate_focused()?,
Action::OpenLinkNewTab => app.md_open_focused_link_new_tab()?,
Action::OpenInNewTab => app.tab_new_from_selection()?,
Action::ImageZoomIn => app.image_zoom_by(1.25),
Action::ImageZoomOut => app.image_zoom_by(1.0 / 1.25),
Action::ImageZoomReset => app.image_zoom_reset(),
Action::PdfNextPage => app.pdf_next_page(),
Action::PdfPrevPage => app.pdf_prev_page(),
Action::PreviewFileNext => app.preview_jump_file(1),
Action::PreviewFilePrev => app.preview_jump_file(-1),
Action::TableCopy(kind) => app.table_copy(kind),
Action::SortSet(k) => app.sort_menu_key(sort_key_char(k))?,
Action::SortToggleReverse => app.sort_menu_key('r')?,
Action::SortToggleDirsFirst => app.sort_menu_key('.')?,
Action::ToggleTabList => app.toggle_tab_list(),
Action::TabListClose => app.tab_list_close_selected(),
Action::ToggleOutline => app.toggle_outline(),
Action::BookmarkJump => app.bookmark_list_jump(),
Action::BookmarkEdit => app.bookmark_list_edit(),
Action::BookmarkDelete => app.bookmark_list_delete(),
Action::BookmarkClose => app.close_bookmark_list(),
Action::InfoClose => app.toggle_info(),
Action::ToggleTableCell => app.toggle_table_cell_view(),
#[cfg(feature = "git")]
Action::GitDiffDiscard => app.git_diff_start_discard(),
#[cfg(feature = "git")]
Action::CycleDiffLayout => app.cycle_diff_layout(),
#[cfg(feature = "git")]
Action::ToggleFollowDiffScope => app.toggle_follow_diff_scope(),
#[cfg(feature = "git")]
Action::GitStage => app.git_view_stage(),
#[cfg(feature = "git")]
Action::GitUnstage => app.git_view_unstage(),
#[cfg(feature = "git")]
Action::GitStageAll => app.git_view_stage_all(),
#[cfg(feature = "git")]
Action::GitUnstageAll => app.git_view_unstage_all(),
#[cfg(feature = "git")]
Action::GitDiscard => app.git_view_start_discard(),
#[cfg(feature = "git")]
Action::GitCommit => app.start_git_commit(),
#[cfg(feature = "git")]
Action::GitWorktreeDiff => app.open_worktree_detail(),
#[cfg(feature = "git")]
Action::GitOpenLog => app.open_git_log(),
#[cfg(feature = "git")]
Action::GitOpenGraph => app.open_git_graph(),
#[cfg(feature = "git")]
Action::GitOpenBranches => app.open_git_branches(),
#[cfg(feature = "git")]
Action::GitOpenWorktrees => app.open_git_worktrees(),
#[cfg(feature = "git")]
Action::GitLaunchTool => app.launch_git_tool(),
#[cfg(feature = "git")]
Action::GitOpenSelectedDiff => {
if let Some(p) = app.git_view_selected() {
app.open_git_diff(&p);
}
}
#[cfg(feature = "git")]
Action::GitOpenDetail => match sfc {
Surface::GitLog => app.open_git_commit_detail(),
Surface::GitGraph => app.open_git_graph_detail(),
_ => {}
},
#[cfg(feature = "git")]
Action::GitGraphSetBase => app.git_graph_set_base(),
#[cfg(feature = "git")]
Action::GitGraphClearBase => app.git_graph_clear_base(),
#[cfg(feature = "git")]
Action::GitGraphToggleAll => app.git_graph_toggle_all(),
#[cfg(feature = "git")]
Action::JjSync => app.jj_start_sync(),
#[cfg(feature = "git")]
Action::GitGraphOpenPicker => app.git_graph_open_picker(),
#[cfg(feature = "git")]
Action::GitGraphPickerToggle => app.git_graph_picker_toggle(),
#[cfg(feature = "git")]
Action::GitGraphPickerAll => app.git_graph_picker_all(),
#[cfg(feature = "git")]
Action::GitGraphPickerCurrentOnly => app.git_graph_picker_current_only(),
#[cfg(feature = "git")]
Action::GitGraphPickerMoveUp => app.git_graph_picker_reorder(-1),
#[cfg(feature = "git")]
Action::GitGraphPickerMoveDown => app.git_graph_picker_reorder(1),
#[cfg(feature = "git")]
Action::BranchFilterStart => app.git_branch_start_filter(),
#[cfg(feature = "git")]
Action::BranchCheckout => app.checkout_selected_branch()?,
#[cfg(feature = "git")]
Action::BranchCreate => app.start_create_branch(),
#[cfg(feature = "git")]
Action::BranchDelete => app.start_delete_branch(),
#[cfg(feature = "git")]
Action::WorktreeFilterStart => app.git_worktree_start_filter(),
#[cfg(feature = "git")]
Action::WorktreeGoto => app.worktree_goto(),
#[cfg(feature = "git")]
Action::WorktreeGotoNewTab => app.worktree_goto_new_tab()?,
#[cfg(feature = "git")]
Action::WorktreeClose => app.close_git_worktrees(),
#[cfg(feature = "git")]
Action::WorktreeShowChanges => app.worktree_show_changes(),
#[cfg(feature = "git")]
Action::WorktreeCreate => app.start_create_worktree(),
#[cfg(feature = "git")]
Action::GitCopy(kind) => app.git_copy(kind),
#[cfg(feature = "git")]
Action::CopyBranchName => app.git_copy_branch_name(),
#[cfg(feature = "git")]
Action::GitClose => match sfc {
Surface::GitDetail => app.close_git_detail(),
Surface::GitLog => app.close_git_log(),
Surface::GitGraphPicker => app.git_graph_picker_cancel(),
Surface::GitGraph => app.close_git_graph(),
Surface::GitBranches => app.close_git_branches(),
Surface::GitChanges => app.close_git_view(),
_ => {}
},
}
Ok(false)
}
fn handle_text_input(app: &mut App, sfc: Surface, key: KeyEvent) -> Result<bool> {
let code = key.code;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match sfc {
Surface::DialogInput => match (code, ctrl) {
(KeyCode::Esc, _) => app.dialog_cancel(),
(KeyCode::Enter, _) => app.dialog_submit()?,
(KeyCode::Backspace, _) => app.dialog_input_backspace(),
(KeyCode::Delete, _) => app.dialog_input_delete(),
(KeyCode::Left, _) => app.dialog_cursor_left(),
(KeyCode::Right, _) => app.dialog_cursor_right(),
(KeyCode::Home, _) => app.dialog_cursor_home(),
(KeyCode::End, _) => app.dialog_cursor_end(),
(KeyCode::Char(c), false) => app.dialog_input_push(c),
_ => {}
},
Surface::Filter => match (code, ctrl) {
(KeyCode::Esc, _) => app.filter_clear(),
(KeyCode::Enter, _) => app.filter_commit(),
(KeyCode::Backspace, _) => app.filter_input_backspace(),
(KeyCode::Down, _) => app.tree_next(),
(KeyCode::Up, _) => app.tree_prev(),
(KeyCode::Char(c), false) => app.filter_input_push(c),
_ => {}
},
Surface::Search => match (code, ctrl) {
(KeyCode::Esc, _) => app.search_clear(),
(KeyCode::Enter, _) => app.search_commit(),
(KeyCode::Backspace, _) => app.search_input_backspace(),
(KeyCode::Char(c), false) => app.search_input_push(c),
_ => {}
},
Surface::Mark => match (code, ctrl) {
(KeyCode::Esc, _) => app.cancel_mark(),
(KeyCode::Char(c), false) => app.mark_input(c),
_ => app.cancel_mark(),
},
#[cfg(feature = "git")]
Surface::BranchFilter => match (code, ctrl) {
(KeyCode::Esc, _) => app.git_branch_filter_clear(),
(KeyCode::Enter, _) => app.git_branch_filter_commit(),
(KeyCode::Backspace, _) => app.git_branch_filter_backspace(),
(KeyCode::Down, _) => app.git_branch_move(1),
(KeyCode::Up, _) => app.git_branch_move(-1),
(KeyCode::Char(c), false) => app.git_branch_filter_push(c),
_ => {}
},
#[cfg(feature = "git")]
Surface::WorktreeFilter => match (code, ctrl) {
(KeyCode::Esc, _) => app.git_worktree_filter_clear(),
(KeyCode::Enter, _) => app.git_worktree_filter_commit(),
(KeyCode::Backspace, _) => app.git_worktree_filter_backspace(),
(KeyCode::Down, _) => app.git_worktree_move(1),
(KeyCode::Up, _) => app.git_worktree_move(-1),
(KeyCode::Char(c), false) => app.git_worktree_filter_push(c),
_ => {}
},
_ => {}
}
Ok(false)
}
fn handle_modal_confirm(app: &mut App, sfc: Surface, key: KeyEvent) -> Result<bool> {
match sfc {
Surface::DialogRenamePreview => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => app.dialog_preview_apply()?,
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.dialog_cancel(),
KeyCode::Char('j') | KeyCode::Down => app.dialog_preview_scroll(1),
KeyCode::Char('k') | KeyCode::Up => app.dialog_preview_scroll(-1),
_ => {}
},
Surface::DialogConfirmDrop => match key.code {
KeyCode::Char('c') | KeyCode::Char('C') => app.drop_apply(false)?,
KeyCode::Char('m') | KeyCode::Char('M') => app.drop_apply(true)?,
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.dialog_cancel(),
_ => {}
},
Surface::DialogConfirmDelete => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => app.dialog_confirm(true)?,
KeyCode::Char('!') if app.dialog_allow_permanent() => app.dialog_delete_permanent()?,
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.dialog_confirm(false)?,
_ => {}
},
Surface::DialogConfirmQuit => match key.code {
KeyCode::Char('y')
| KeyCode::Char('Y')
| KeyCode::Char('q')
| KeyCode::Char('Q')
| KeyCode::Enter => return Ok(true),
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.dialog_cancel(),
_ => {}
},
Surface::DialogConfirmBookmark => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => app.dialog_confirm(true)?,
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.dialog_confirm(false)?,
_ => {}
},
_ => {}
}
Ok(false)
}
fn handle_esc(app: &mut App, sfc: Surface) -> bool {
match sfc {
Surface::Help => app.show_help = false,
Surface::Visual => app.exit_visual_cancel(),
Surface::Sort => app.close_sort_menu(),
Surface::Info => app.toggle_info(),
Surface::TableCell => app.toggle_table_cell_view(),
Surface::Bookmarks => app.close_bookmark_list(),
Surface::Tabs => app.toggle_tab_list(),
Surface::Outline => app.toggle_outline(),
Surface::Tree => {
if app.filter_query().is_some() {
app.filter_clear();
} else if app.has_selection() {
app.clear_selection();
}
}
Surface::PreviewText | Surface::PreviewImage | Surface::PreviewTable => {
if app.preview_search_query().is_some() {
app.search_clear();
} else {
app.back_to_tree();
}
}
Surface::PreviewTextVisual => app.preview_exit_visual(),
#[cfg(feature = "git")]
Surface::GitDetail => app.close_git_detail(),
#[cfg(feature = "git")]
Surface::GitLog => app.close_git_log(),
#[cfg(feature = "git")]
Surface::GitGraph => app.close_git_graph(),
#[cfg(feature = "git")]
Surface::GitGraphPicker => app.git_graph_picker_cancel(),
#[cfg(feature = "git")]
Surface::GitBranches => {
if app.git_branch_query().is_empty() {
app.close_git_branches();
} else {
app.git_branch_filter_clear();
}
}
#[cfg(feature = "git")]
Surface::GitWorktrees => {
if app.git_worktree_query().is_empty() {
app.close_git_worktrees();
} else {
app.git_worktree_filter_clear();
}
}
#[cfg(feature = "git")]
Surface::GitChanges => app.close_git_view(),
#[cfg(feature = "git")]
Surface::PreviewGitDiff => app.close_git_diff(),
_ => {}
}
false
}
fn handle_enter(app: &mut App, sfc: Surface) -> Result<bool> {
match sfc {
Surface::Tree => app.tree_activate()?,
Surface::PreviewText => app.md_activate_focused()?,
Surface::Bookmarks => app.bookmark_list_jump(),
Surface::Tabs => app.tab_list_activate(),
Surface::Outline => app.outline_jump(),
Surface::PreviewTable | Surface::TableCell => app.toggle_table_cell_view(),
#[cfg(feature = "git")]
Surface::GitChanges => {
if let Some(p) = app.git_view_selected() {
app.open_git_diff(&p);
}
}
#[cfg(feature = "git")]
Surface::GitLog => app.open_git_commit_detail(),
#[cfg(feature = "git")]
Surface::GitGraph => app.open_git_graph_detail(),
#[cfg(feature = "git")]
Surface::GitGraphPicker => app.git_graph_picker_apply(),
#[cfg(feature = "git")]
Surface::GitBranches => app.checkout_selected_branch()?,
#[cfg(feature = "git")]
Surface::GitWorktrees => app.worktree_goto(),
_ => {}
}
Ok(false)
}
fn handle_fixed_key(app: &mut App, sfc: Surface, key: KeyEvent) -> Result<Option<bool>> {
let done = match key.code {
KeyCode::Up => {
dispatch_navigate(app, sfc, Motion::Up);
false
}
KeyCode::Down => {
dispatch_navigate(app, sfc, Motion::Down);
false
}
KeyCode::Left => {
dispatch_navigate(app, sfc, Motion::Left);
false
}
KeyCode::Right => {
dispatch_navigate(app, sfc, Motion::Right);
false
}
KeyCode::Home => {
dispatch_navigate(app, sfc, Motion::Top);
false
}
KeyCode::End => {
dispatch_navigate(app, sfc, Motion::Bottom);
false
}
KeyCode::Esc => handle_esc(app, sfc),
KeyCode::Enter => handle_enter(app, sfc)?,
KeyCode::Tab => {
if sfc == Surface::PreviewText && !app.is_raw_source() {
app.md_focus_move(1);
}
false
}
KeyCode::BackTab => {
if sfc == Surface::PreviewText && !app.is_raw_source() {
app.md_focus_move(-1);
}
false
}
KeyCode::Char(' ') if sfc == Surface::PreviewText && app.md_focused_task() => {
app.md_toggle_focused_task();
false
}
KeyCode::Char(' ') if sfc == Surface::PreviewText && app.md_focused_details().is_some() => {
if let Some(ord) = app.md_focused_details() {
app.toggle_details(ord);
}
false
}
_ => return Ok(None),
};
Ok(Some(done))
}
fn handle_key(app: &mut App, key: KeyEvent) -> Result<bool> {
app.flash = None;
let sfc = app.surface();
if app.follow_enabled() {
let leaves_follow_view = app.pending_leader.is_none()
&& matches!(
app.keymaps.resolve(sfc, None, KeyPress::norm(&key)),
Resolution::Action(Action::PreviewBack)
);
if sfc.is_text_input() || sfc.is_modal_confirm() || leaves_follow_view {
app.follow_break();
}
}
if sfc.is_text_input() {
return handle_text_input(app, sfc, key);
}
if sfc.is_modal_confirm() {
return handle_modal_confirm(app, sfc, key);
}
let kp = KeyPress::norm(&key);
if let Some(lead) = app.pending_leader.take() {
return match app.keymaps.resolve(sfc, Some(lead), kp) {
Resolution::Action(a) => dispatch_action(app, a, sfc),
_ => Ok(false),
};
}
if let Some(done) = handle_fixed_key(app, sfc, key)? {
return Ok(done);
}
match app.keymaps.resolve(sfc, None, kp) {
Resolution::EnterLeader(id) => {
app.pending_leader = Some(id);
Ok(false)
}
Resolution::Action(a) => dispatch_action(app, a, sfc),
Resolution::Unbound => {
if sfc == Surface::Bookmarks && !kp.ctrl {
if let KeyCode::Char(c) = kp.code {
if c.is_ascii_alphabetic() {
app.bookmark_jump_letter(c);
}
}
}
Ok(false)
}
}
}
fn resolve_key_result(app: &mut App, result: Result<bool>) -> bool {
match result {
Ok(quit) => quit,
Err(e) => {
app.flash = Some(format!(
"{}{e:#}",
i18n::tr(app.lang, crate::i18n::Msg::OperationFailed)
));
false }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::unique_tmp;
use app::Mode;
use config::Config;
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn argv(rest: &[&str]) -> Vec<String> {
let mut v = vec!["konoma".to_string()];
v.extend(rest.iter().map(|s| s.to_string()));
v
}
#[test]
fn parse_args_recognizes_version_flags() {
assert_eq!(parse_args(&argv(&["--version"])), ParsedArgs::Version);
assert_eq!(parse_args(&argv(&["-V"])), ParsedArgs::Version);
}
#[test]
fn parse_args_recognizes_help_flags() {
assert_eq!(parse_args(&argv(&["--help"])), ParsedArgs::Help);
assert_eq!(parse_args(&argv(&["-h"])), ParsedArgs::Help);
}
#[test]
fn parse_args_with_no_argument_falls_back_to_none() {
assert_eq!(parse_args(&argv(&[])), ParsedArgs::Open(None));
}
#[test]
fn parse_args_treats_a_plain_path_as_open() {
assert_eq!(
parse_args(&argv(&["/some/dir"])),
ParsedArgs::Open(Some(PathBuf::from("/some/dir")))
);
assert_eq!(
parse_args(&argv(&["samples"])),
ParsedArgs::Open(Some(PathBuf::from("samples")))
);
}
#[test]
fn parse_args_treats_an_unknown_flag_as_a_path() {
assert_eq!(
parse_args(&argv(&["--not-a-real-flag"])),
ParsedArgs::Open(Some(PathBuf::from("--not-a-real-flag")))
);
}
#[test]
fn parse_args_treats_an_empty_string_as_a_path() {
assert_eq!(
parse_args(&argv(&[""])),
ParsedArgs::Open(Some(PathBuf::from("")))
);
}
#[test]
fn parse_args_ignores_extra_arguments_after_the_first() {
assert_eq!(
parse_args(&argv(&["/some/dir", "extra", "--version"])),
ParsedArgs::Open(Some(PathBuf::from("/some/dir")))
);
}
#[test]
fn validate_root_rejects_a_nonexistent_path_and_names_it() {
let missing = unique_tmp("konoma_validate_root_missing_test");
let err = validate_root(&missing).expect_err("存在しないパスは Err");
let msg = format!("{err:#}");
assert!(
msg.contains(&missing.display().to_string()),
"エラーにパスが含まれる: {msg}"
);
assert!(
msg.to_lowercase().contains("no such file") || msg.to_lowercase().contains("not found"),
"「見つからない」旨が含まれる: {msg}"
);
}
#[test]
fn validate_root_rejects_a_file_as_not_a_directory() {
let dir = unique_tmp("konoma_validate_root_file_test");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("plain.txt");
std::fs::write(&file, b"hi").unwrap();
let err = validate_root(&file).expect_err("ファイルは Err");
let msg = format!("{err:#}");
assert!(
msg.contains(&file.display().to_string()),
"エラーにパスが含まれる: {msg}"
);
assert!(
msg.contains("not a directory"),
"「ディレクトリでない」旨が含まれる: {msg}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn validate_root_accepts_a_real_readable_directory() {
let dir = unique_tmp("konoma_validate_root_ok_test");
std::fs::create_dir_all(&dir).unwrap();
assert!(validate_root(&dir).is_ok(), "実在し読めるディレクトリは Ok");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn validate_root_rejects_an_unreadable_directory() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_tmp("konoma_validate_root_unreadable_test");
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o000)).unwrap();
let readable_as_root = std::fs::read_dir(&dir).is_ok();
if !readable_as_root {
let err = validate_root(&dir).expect_err("読めないディレクトリは Err");
let msg = format!("{err:#}");
assert!(
msg.contains(&dir.display().to_string()),
"エラーにパスが含まれる: {msg}"
);
} else {
eprintln!(
"validate_root_rejects_an_unreadable_directory: root で実行中のためパーミッション検証をスキップ"
);
}
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_startup_reports_version_and_help_without_touching_the_filesystem() {
assert!(matches!(
resolve_startup(&argv(&["--version"])).unwrap(),
Startup::Version
));
assert!(matches!(
resolve_startup(&argv(&["--help"])).unwrap(),
Startup::Help
));
}
#[test]
fn resolve_startup_fails_with_the_path_for_a_bad_directory() {
let missing = unique_tmp("konoma_resolve_startup_missing_test");
let err = resolve_startup(&argv(&[missing.to_str().unwrap()])).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains(&missing.display().to_string()),
"エラーにパスが含まれる: {msg}"
);
}
#[test]
fn resolve_startup_opens_a_real_directory() {
let dir = unique_tmp("konoma_resolve_startup_ok_test");
std::fs::create_dir_all(&dir).unwrap();
match resolve_startup(&argv(&[dir.to_str().unwrap()])).unwrap() {
Startup::Open(got) => assert_eq!(got, std::fs::canonicalize(&dir).unwrap()),
other => panic!("Open を期待したが: {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn version_text_contains_the_crate_version() {
let t = version_text();
assert!(t.contains(env!("CARGO_PKG_VERSION")));
assert!(t.starts_with("konoma "));
}
#[test]
fn help_text_documents_usage_and_points_at_the_docs_site() {
let t = help_text();
assert!(
t.contains("konoma [DIR]"),
"README の Usage 節と表記を揃える: {t}"
);
assert!(t.contains("--help"));
assert!(t.contains("--version"));
assert!(t.contains('?'), "アプリ内ヘルプキー ? への言及: {t}");
assert!(t.contains("https://lesim-co-ltd.github.io/konoma/"));
}
#[test]
fn cell_px_from_window_size_computes_the_real_cell_ratio() {
assert_eq!(cell_px_from_window_size(80, 24, 752, 816), Some((9, 34)));
assert_eq!(cell_px_from_window_size(0, 24, 752, 816), None);
assert_eq!(cell_px_from_window_size(80, 0, 752, 816), None);
assert_eq!(cell_px_from_window_size(80, 24, 0, 816), None);
assert_eq!(cell_px_from_window_size(80, 24, 752, 0), None);
assert_eq!(cell_px_from_window_size(0, 0, 0, 0), None);
assert_eq!(cell_px_from_window_size(100, 10, 5, 5), Some((1, 1)));
}
#[test]
fn fix_picker_font_size_leaves_non_default_font_size_untouched() {
#[allow(deprecated)]
let picker = Picker::from_fontsize(ratatui_image::FontSize::new(8, 16));
let fixed = fix_picker_font_size(picker, Some((80, 24, 752, 816)));
assert_eq!((fixed.font_size().width, fixed.font_size().height), (8, 16));
}
#[test]
fn fix_picker_font_size_leaves_default_untouched_without_a_usable_window_size() {
let picker = Picker::halfblocks();
let fixed = fix_picker_font_size(picker, None);
assert_eq!(
(fixed.font_size().width, fixed.font_size().height),
(10, 20)
);
let picker2 = Picker::halfblocks();
let fixed2 = fix_picker_font_size(picker2, Some((80, 24, 0, 0)));
assert_eq!(
(fixed2.font_size().width, fixed2.font_size().height),
(10, 20)
);
}
#[test]
fn fix_picker_font_size_replaces_default_with_the_real_cell_ratio() {
let picker = Picker::halfblocks();
let fixed = fix_picker_font_size(picker, Some((80, 24, 752, 816)));
assert_eq!((fixed.font_size().width, fixed.font_size().height), (9, 34));
}
#[test]
fn fix_picker_font_size_preserves_protocol_type() {
use ratatui_image::picker::ProtocolType;
#[allow(deprecated)]
let mut picker = Picker::from_fontsize(ratatui_image::FontSize::new(10, 20));
picker.set_protocol_type(ProtocolType::Kitty);
let fixed = fix_picker_font_size(picker, Some((80, 24, 752, 816)));
assert_eq!(fixed.protocol_type(), ProtocolType::Kitty);
assert_eq!((fixed.font_size().width, fixed.font_size().height), (9, 34));
}
#[test]
fn resolve_startup_runs_before_terminal_init_in_main() {
let src = include_str!("main.rs");
let main_start = src
.find("\nfn main() -> Result<()> {")
.expect("fn main が見つからない(自己スキャンが壊れている)");
let main_end = src[main_start..]
.find("\nfn resize_worker(")
.map(|off| main_start + off)
.expect("main の直後の fn resize_worker が見つからない(自己スキャンが壊れている)");
let body = &src[main_start..main_end];
let resolve_at = body
.find("resolve_startup(")
.expect("main の本体が resolve_startup を呼んでいない");
let init_at = body
.find("let mut terminal = ratatui::init();")
.expect("main の本体が ratatui::init を呼んでいない");
assert!(
resolve_at < init_at,
"resolve_startup の呼び出しは ratatui::init() より前でなければならない\
(不正なパスで端末(raw mode + alt screen)を触る前に落とすため)"
);
}
#[test]
fn fs_event_classification_reacts_to_git_locks_and_detects_ignore_rules() {
let pb = |s: &str| vec![PathBuf::from(s)];
assert_eq!(
classify_fs_paths(&pb("/repo/.git/index.lock")),
(true, false)
);
assert_eq!(classify_fs_paths(&pb("/r/.git/HEAD.lock")), (true, false));
assert_eq!(classify_fs_paths(&pb("/repo/.git/HEAD")), (true, false));
assert_eq!(classify_fs_paths(&pb("/repo/.git/index")), (true, false));
assert_eq!(classify_fs_paths(&pb("/repo/src/main.rs")), (true, false));
assert_eq!(classify_fs_paths(&pb("/repo/Cargo.lock")), (true, false));
assert_eq!(classify_fs_paths(&pb("/repo/.gitignore")), (true, true));
assert_eq!(classify_fs_paths(&pb("/repo/sub/.gitignore")), (true, true));
assert_eq!(
classify_fs_paths(&pb("/repo/.git/info/exclude")),
(true, true)
);
assert_eq!(
classify_fs_paths(&pb("/repo/node_modules/x/.gitignore")),
(true, false)
);
assert_eq!(
classify_fs_paths(&[
PathBuf::from("/repo/.git/index.lock"),
PathBuf::from("/repo/.gitignore"),
]),
(true, true)
);
assert_eq!(classify_fs_paths(&[]), (true, false));
}
#[test]
fn follow_candidates_exclude_git_internals() {
let got = follow_candidates(&[
PathBuf::from("/repo/.git/index"),
PathBuf::from("/repo/.git/refs/heads/main"),
PathBuf::from("/repo/src/main.rs"),
PathBuf::from("/repo/README.md"),
]);
assert_eq!(
got,
vec![
PathBuf::from("/repo/src/main.rs"),
PathBuf::from("/repo/README.md"),
]
);
}
#[test]
fn burst_paths_dedupes_and_preserves_order() {
let mut burst = BurstPaths::default();
let a = PathBuf::from("/repo/a");
let b = PathBuf::from("/repo/b");
let c = PathBuf::from("/repo/c");
assert!(burst.push(&a));
assert!(burst.push(&b));
assert!(!burst.push(&a), "重複は false(既にカウント済み)");
assert!(burst.push(&c));
assert_eq!(burst.finish(), Some(vec![a, b, c]));
}
#[test]
fn burst_paths_overflow_reports_unknown() {
let mut burst = BurstPaths::default();
for i in 0..MAX_BURST_PATHS {
assert!(burst.push(&PathBuf::from(format!("/repo/f{i}"))));
}
assert!(
!burst.push(&PathBuf::from("/repo/overflow")),
"上限を超えたら push は false"
);
assert_eq!(
burst.finish(),
None,
"上限超過バーストは「パス不明」として扱われる"
);
}
#[test]
fn is_content_event_ignores_reads_reacts_to_writes() {
use notify::event::{
AccessKind, AccessMode, CreateKind, DataChange, ModifyKind, RemoveKind, RenameMode,
};
use notify::EventKind;
assert!(!is_content_event(&EventKind::Access(AccessKind::Open(
AccessMode::Any
))));
assert!(!is_content_event(&EventKind::Access(AccessKind::Open(
AccessMode::Read
))));
assert!(!is_content_event(&EventKind::Access(AccessKind::Read)));
assert!(!is_content_event(&EventKind::Access(AccessKind::Close(
AccessMode::Read
))));
assert!(!is_content_event(&EventKind::Access(AccessKind::Any)));
assert!(is_content_event(&EventKind::Access(AccessKind::Close(
AccessMode::Write
))));
assert!(is_content_event(&EventKind::Any));
assert!(is_content_event(&EventKind::Create(CreateKind::File)));
assert!(is_content_event(&EventKind::Modify(ModifyKind::Data(
DataChange::Any
))));
assert!(is_content_event(&EventKind::Modify(ModifyKind::Name(
RenameMode::Any
))));
assert!(is_content_event(&EventKind::Remove(RemoveKind::File)));
assert!(is_content_event(&EventKind::Other));
}
#[test]
fn is_structural_event_separates_writes_from_appearing_and_disappearing_entries() {
use notify::event::{
AccessKind, AccessMode, CreateKind, DataChange, MetadataKind, ModifyKind, RemoveKind,
RenameMode,
};
use notify::EventKind;
assert!(!is_structural_event(&EventKind::Modify(ModifyKind::Data(
DataChange::Any
))));
assert!(!is_structural_event(&EventKind::Modify(ModifyKind::Data(
DataChange::Content
))));
assert!(!is_structural_event(&EventKind::Access(AccessKind::Close(
AccessMode::Write
))));
assert!(!is_structural_event(&EventKind::Modify(
ModifyKind::Metadata(MetadataKind::Any)
)));
assert!(!is_structural_event(&EventKind::Modify(
ModifyKind::Metadata(MetadataKind::WriteTime)
)));
assert!(is_structural_event(&EventKind::Create(CreateKind::File)));
assert!(is_structural_event(&EventKind::Create(CreateKind::Folder)));
assert!(is_structural_event(&EventKind::Remove(RemoveKind::File)));
assert!(is_structural_event(&EventKind::Remove(RemoveKind::Folder)));
assert!(is_structural_event(&EventKind::Modify(ModifyKind::Name(
RenameMode::Any
))));
assert!(is_structural_event(&EventKind::Modify(ModifyKind::Name(
RenameMode::From
))));
assert!(is_structural_event(&EventKind::Any));
assert!(is_structural_event(&EventKind::Other));
assert!(is_structural_event(&EventKind::Modify(ModifyKind::Any)));
assert!(is_structural_event(&EventKind::Modify(ModifyKind::Other)));
}
#[test]
fn burst_kinds_merge_is_sticky_across_a_burst() {
let mut kinds = FsBurstKinds::default();
assert!(!kinds.structural && !kinds.ignore_rules_changed);
for _ in 0..3 {
kinds.merge(FsBurstKinds {
ignore_rules_changed: false,
structural: false,
});
}
assert!(!kinds.structural, "書き込みだけを畳んでも structural=false");
kinds.merge(FsBurstKinds {
ignore_rules_changed: false,
structural: true,
});
kinds.merge(FsBurstKinds {
ignore_rules_changed: false,
structural: false,
});
assert!(
kinds.structural,
"1件でも structural があればバースト全体が structural(後続の書き込みで戻らない)"
);
assert!(!kinds.ignore_rules_changed, "他方のフラグは巻き込まれない");
}
#[test]
fn watcher_ignores_reads_but_reports_writes() {
use notify::{RecursiveMode, Watcher};
use std::sync::mpsc;
use std::time::{Duration, Instant};
let dir = unique_tmp("konoma_watch_filter_test");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("f.txt");
std::fs::write(&file, b"hello").unwrap();
let (tx, rx) = mpsc::channel::<()>();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(ev) = res {
if !is_content_event(&ev.kind) {
return;
}
let (meaningful, _) = classify_fs_paths(&ev.paths);
if meaningful {
let _ = tx.send(());
}
}
})
.expect("recommended_watcher");
watcher
.watch(&dir, RecursiveMode::Recursive)
.expect("watch");
let settle_until = Instant::now() + Duration::from_millis(500);
while Instant::now() < settle_until {
let _ = rx.recv_timeout(Duration::from_millis(50));
}
for _ in 0..5 {
let _ = std::fs::read(&file);
}
assert!(
rx.recv_timeout(Duration::from_secs(1)).is_err(),
"reading a file must not be reported as a change \
(fails on Linux without the is_content_event filter; vacuous on macOS)"
);
std::fs::write(&file, b"changed").unwrap();
assert!(
rx.recv_timeout(Duration::from_secs(10)).is_ok(),
"writing a file must still be reported as a change"
);
drop(watcher);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn watcher_reports_a_deletion_as_structural() {
use notify::{RecursiveMode, Watcher};
use std::sync::mpsc;
use std::time::{Duration, Instant};
let dir = unique_tmp("konoma_watch_structural_test");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("doomed.txt");
std::fs::write(&file, b"hello").unwrap();
let (tx, rx) = mpsc::channel::<FsBurstKinds>();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(ev) = res {
if !is_content_event(&ev.kind) {
return;
}
let (meaningful, ignore_rules) = classify_fs_paths(&ev.paths);
if meaningful {
let _ = tx.send(FsBurstKinds {
ignore_rules_changed: ignore_rules,
structural: is_structural_event(&ev.kind),
});
}
}
})
.expect("recommended_watcher");
watcher
.watch(&dir, RecursiveMode::Recursive)
.expect("watch");
let settle_until = Instant::now() + Duration::from_millis(500);
while Instant::now() < settle_until {
let _ = rx.recv_timeout(Duration::from_millis(50));
}
std::fs::remove_file(&file).unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let mut burst = FsBurstKinds::default();
let mut got_any = false;
while Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(kinds) => {
got_any = true;
burst.merge(kinds);
if burst.structural {
break;
}
}
Err(_) if got_any => break, Err(_) => {}
}
}
assert!(got_any, "削除がイベントとして届かない(監視自体の失敗)");
assert!(
burst.structural,
"ファイル削除は structural として届かなければならない\
(届かないと ignored ディレクトリ配下の削除でツリーが更新されない)"
);
drop(watcher);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn watch_target_changed_detects_actual_target_changes_only() {
let a = unique_tmp("konoma_watch_target_a");
let b = unique_tmp("konoma_watch_target_b");
assert!(watch_target_changed(None, Some(a.as_path())));
assert!(!watch_target_changed(Some(a.as_path()), Some(a.as_path())));
assert!(watch_target_changed(Some(a.as_path()), Some(b.as_path())));
assert!(watch_target_changed(Some(a.as_path()), None));
assert!(!watch_target_changed(None, None));
}
#[test]
fn failed_watch_is_not_retried_on_every_idle_tick() {
let mut watcher =
notify::recommended_watcher(|_res: notify::Result<notify::Event>| {}).ok();
let bad_root = unique_tmp("konoma_watch_retry_storm_test");
let mut watched: Option<PathBuf> = None;
let mut attempted: Option<PathBuf> = None;
let mut attempts = 0;
let mut failures = 0;
for _tick in 0..5 {
if watch_target_changed(attempted.as_deref(), Some(bad_root.as_path())) {
attempted = Some(bad_root.clone());
attempts += 1;
if rewatch(watcher.as_mut(), &mut watched, &bad_root) == WatchOutcome::Failed {
failures += 1;
}
}
}
assert_eq!(
attempts, 1,
"同一 root への再試行は初回の1回だけ(リトライ嵐の再発防止)"
);
assert_eq!(failures, 1, "flash も初回の1回だけ立つはず");
assert_eq!(
watched, None,
"失敗した watch は登録されない(unwatch 対象も無い=リークしない)"
);
let dir2 = unique_tmp("konoma_watch_retry_storm_test_ok");
std::fs::create_dir_all(&dir2).unwrap();
assert!(watch_target_changed(
attempted.as_deref(),
Some(dir2.as_path())
));
attempted = Some(dir2.clone());
assert_eq!(
rewatch(watcher.as_mut(), &mut watched, &dir2),
WatchOutcome::Watching,
"root がウォッチ可能な値に変われば再試行して成功する"
);
assert_eq!(watched, Some(dir2.clone()));
assert_eq!(attempted.as_deref(), Some(dir2.as_path()));
drop(watcher);
let _ = std::fs::remove_dir_all(&dir2);
}
#[test]
fn set_extra_watch_reports_removed_and_failed_outcomes() {
let mut watcher =
notify::recommended_watcher(|_res: notify::Result<notify::Event>| {}).ok();
let dir = unique_tmp("konoma_extra_watch_ok");
std::fs::create_dir_all(&dir).unwrap();
let bad = unique_tmp("konoma_extra_watch_bad");
let mut watched: Option<PathBuf> = None;
assert_eq!(
set_extra_watch(watcher.as_mut(), &mut watched, Some(dir.as_path())),
WatchOutcome::Watching
);
assert_eq!(watched, Some(dir.clone()));
assert_eq!(
set_extra_watch(watcher.as_mut(), &mut watched, None),
WatchOutcome::Removed
);
assert_eq!(watched, None);
assert_eq!(
set_extra_watch(watcher.as_mut(), &mut watched, Some(bad.as_path())),
WatchOutcome::Failed
);
assert_eq!(watched, None);
drop(watcher);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn follow_is_sticky_and_breaks_only_on_toggle_or_text_input() {
let dir = unique_tmp("konoma_follow_break_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE),
)
.unwrap();
assert!(app.follow_enabled(), "F で ON");
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE),
)
.unwrap();
assert!(!app.follow_enabled(), "F 再押下で OFF");
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE),
)
.unwrap();
assert!(app.follow_enabled());
handle_key(&mut app, key('j')).unwrap();
assert!(
app.follow_enabled(),
"j のような通常キーでは最大粘着によりフォロー維持"
);
handle_key(&mut app, key('/')).unwrap();
assert!(app.is_filtering());
assert!(
app.follow_enabled(),
"テキスト入力面へ入る瞬間のキーでは未解除"
);
handle_key(&mut app, key('x')).unwrap();
assert!(
!app.follow_enabled(),
"テキスト入力面に居る間のキーでフォロー解除"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn quote_opens_bookmark_list_and_letters_jump() {
let root = unique_tmp("konoma_quote_list_test");
let _ = std::fs::remove_dir_all(&root);
let proj = root.join("proj");
std::fs::create_dir_all(proj.join("sub")).unwrap();
std::fs::write(proj.join("f.txt"), b"x").unwrap();
let proj = proj.canonicalize().unwrap();
let mut app = App::new(proj.clone(), Config::default()).unwrap();
app.bookmarks = bookmarks::Bookmarks::with_base(root.join("cfgbase"), &proj);
app.bookmarks.set('a', proj.join("sub")).unwrap();
app.bookmarks.set('e', proj.join("f.txt")).unwrap();
handle_key(&mut app, key('\'')).unwrap();
assert!(app.is_bookmark_list(), "' 一発で一覧が開く");
assert!(!app.is_marking(), "ジャンプの待ち受け状態は無い");
handle_key(&mut app, key('z')).unwrap();
assert!(app.is_bookmark_list());
assert!(app.flash.is_some(), "未登録は flash");
handle_key(&mut app, key('e')).unwrap();
assert!(!app.is_bookmark_list(), "ジャンプで一覧が閉じる");
assert_eq!(app.tab.mode, Mode::Preview);
assert!(app
.tab
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("f.txt")));
handle_key(&mut app, key('q')).unwrap(); handle_key(&mut app, key('\'')).unwrap();
assert!(app.is_bookmark_list());
handle_key(&mut app, key('\'')).unwrap();
assert!(!app.is_bookmark_list(), "' 再押下で閉じる");
handle_key(&mut app, key('\'')).unwrap();
let before = app.bookmark_list_items().len();
handle_key(&mut app, key('j')).unwrap();
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
)
.unwrap();
assert_eq!(app.bookmark_list_items().len(), before - 1, "Ctrl+D で削除");
assert!(app.bookmarks.get('e').is_none(), "消えたのは選択行の e");
handle_key(&mut app, key('a')).unwrap();
assert_eq!(app.tab.root, proj.join("sub"));
assert!(!app.is_bookmark_list());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn filter_input_captures_literal_keys() {
let dir = unique_tmp("konoma_filter_input_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
handle_key(&mut app, key('/')).unwrap(); assert!(app.is_filtering());
handle_key(&mut app, key('c')).unwrap();
handle_key(&mut app, key('?')).unwrap();
assert_eq!(app.filter_query(), Some("c?"));
assert_eq!(app.pending_leader, None, "コピーリーダーは始まらない");
assert!(!app.show_help, "ヘルプは開かない");
handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap();
assert!(!app.is_filtering() && app.filter_query().is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn help_opens_in_git_view_and_shows_git_keys() {
let dir = unique_tmp("konoma_git_help_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git2::Repository::init(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.open_git_view();
assert!(app.is_git_view());
handle_key(&mut app, key('?')).unwrap();
assert!(app.show_help, "git モードで ? がヘルプを開く");
let lines = ui::help::help_lines(&app);
let text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref().to_string()))
.collect();
assert!(
text.contains("Git changes") || text.contains("変更ハブ"),
"git 節が出る: {text}"
);
assert!(
text.contains("stage") || text.contains("ステージ"),
"ステージ系キーが出る"
);
handle_key(&mut app, key('?')).unwrap();
assert!(!app.show_help, "? で閉じる");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn tab_keys_per_tab_git_mode_and_literal_in_filter() {
let dir = unique_tmp("konoma_tab_gitview_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git2::Repository::init(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
handle_key(&mut app, key('/')).unwrap();
let tc = app.tab_count();
handle_key(&mut app, key('t')).unwrap();
assert_eq!(app.tab_count(), tc, "絞り込み中は t で新タブにしない");
assert_eq!(app.filter_query(), Some("t"));
handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap();
app.open_git_view();
assert!(app.is_git_view());
handle_key(&mut app, key('t')).unwrap();
assert_eq!(app.tab_count(), tc + 1, "git ビュー中でも t で新タブ");
assert!(!app.is_git_view(), "新タブは git ビュー無しで始まる");
handle_key(&mut app, key('1')).unwrap(); assert!(app.is_git_view(), "タブを戻ると git モードのまま");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_leader_sets_then_clears_pending() {
use keymap::LeaderId;
let dir = unique_tmp("konoma_chord_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir, Config::default()).unwrap();
handle_key(&mut app, key('y')).unwrap();
assert_eq!(app.pending_leader, Some(LeaderId::Copy));
handle_key(&mut app, key('x')).unwrap();
assert_eq!(app.pending_leader, None);
}
#[test]
fn file_leader_opens_on_space() {
use keymap::LeaderId;
let dir = unique_tmp("konoma_fileleader_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir, Config::default()).unwrap();
handle_key(&mut app, key('c')).unwrap();
assert_eq!(app.pending_leader, None);
handle_key(&mut app, key(' ')).unwrap();
assert_eq!(app.pending_leader, Some(LeaderId::File));
}
#[test]
fn space_leader_n_opens_create_dialog() {
let dir = unique_tmp("konoma_space_create_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
handle_key(&mut app, key(' ')).unwrap();
handle_key(&mut app, key('n')).unwrap();
assert_eq!(app.pending_leader, None, "リーダーは確定で消える");
assert!(app.is_dialog(), "Space→n で作成ダイアログが開く");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn anchor_keys_a_and_shift_a_dispatch() {
let dir = unique_tmp("konoma_anchor_dispatch_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
handle_key(&mut app, key('a')).unwrap();
let fa = app.flash.clone().unwrap_or_default();
assert!(
fa.contains("root") || fa.contains("ルート"),
"a の flash: {fa}"
);
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT),
)
.unwrap();
let fa2 = app.flash.clone().unwrap_or_default();
assert!(
fa2.contains("start") || fa2.contains("起動"),
"A の flash: {fa2}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn shift_q_opens_quit_confirm_then_qq_quits() {
let dir = unique_tmp("konoma_quit_confirm_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let exit = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('Q'), KeyModifiers::SHIFT),
)
.unwrap();
assert!(!exit, "確認段階ではまだ終了しない");
assert!(
app.is_dialog() && app.confirm_is_quit(),
"終了確認ダイアログが開く"
);
assert_eq!(app.surface(), Surface::DialogConfirmQuit);
let exit2 = handle_key(&mut app, key('q')).unwrap();
assert!(exit2, "qq の2打鍵目で終了");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn quit_confirm_cancel_with_esc_keeps_running() {
let dir = unique_tmp("konoma_quit_cancel_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
handle_key(&mut app, key('q')).unwrap(); assert!(app.is_dialog() && app.confirm_is_quit());
let exit = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap();
assert!(!exit, "Esc では終了しない");
assert!(!app.is_dialog(), "Esc で確認ダイアログが閉じる");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn quit_without_confirm_quits_immediately() {
let dir = unique_tmp("konoma_quit_immediate_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut cfg = Config::default();
cfg.ui.confirm_quit = false;
let mut app = App::new(dir.clone(), cfg).unwrap();
let exit = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('Q'), KeyModifiers::SHIFT),
)
.unwrap();
assert!(exit, "confirm_quit=false なら Q で即終了");
assert!(!app.is_dialog(), "ダイアログは開かない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn shift_q_is_literal_while_filtering() {
let dir = unique_tmp("konoma_quit_filter_literal_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
handle_key(&mut app, key('/')).unwrap();
let exit = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('Q'), KeyModifiers::SHIFT),
)
.unwrap();
assert!(!exit, "絞り込み中の Q では終了しない");
assert!(!app.is_dialog(), "終了確認も出ない");
assert_eq!(app.filter_query(), Some("Q"), "Q は文字として入力される");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn visual_space_d_commits_range_and_confirms_delete() {
let dir = unique_tmp("konoma_visual_spaced_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
std::fs::write(dir.join("b.txt"), b"y").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.tab.selected = 0;
handle_key(&mut app, key('v')).unwrap();
assert!(app.is_visual(), "v でビジュアル");
handle_key(&mut app, key(' ')).unwrap();
handle_key(&mut app, key('d')).unwrap();
assert!(!app.is_visual(), "Space→d で範囲を確定しビジュアルを抜ける");
assert!(app.is_dialog(), "削除確認ダイアログが開く");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn dnd_paste_ignored_while_dialog_or_overlay_open() {
let dir = unique_tmp("konoma_dnd_guard_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
assert_eq!(app.surface(), Surface::Tree);
assert!(paste_accepted(app.surface()), "Tree ではドロップを受ける");
handle_key(&mut app, key(' ')).unwrap();
handle_key(&mut app, key('n')).unwrap();
assert!(app.is_dialog());
assert!(
paste_accepted(app.surface()),
"入力ダイアログは文字として取り込むため通す"
);
let mut app2 = App::new(dir.clone(), Config::default()).unwrap();
handle_key(&mut app2, key('?')).unwrap();
assert!(app2.show_help);
assert!(
!paste_accepted(app2.surface()),
"ヘルプ表示中はドロップを無視する"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn help_toggles_and_swallows_keys() {
let dir = unique_tmp("konoma_help_key_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir, Config::default()).unwrap();
assert!(!app.show_help);
handle_key(&mut app, key('?')).unwrap();
assert!(app.show_help);
handle_key(&mut app, key('j')).unwrap();
assert_eq!(app.help_scroll, 1);
assert!(!handle_key(&mut app, key('q')).unwrap());
assert!(!app.show_help);
}
#[test]
fn tab_keys_work_in_preview_and_preserve_mode() {
let dir = unique_tmp("konoma_tab_in_preview_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir, Config::default()).unwrap();
app.tab.mode = Mode::Preview;
handle_key(&mut app, key('t')).unwrap();
assert_eq!(app.tab_count(), 2, "Preview 中でも新規タブが作れる");
assert_eq!(app.tab.mode, Mode::Tree, "新規タブは Tree から");
let new_tab = app.active_tab_index();
handle_key(&mut app, key('[')).unwrap();
assert_ne!(app.active_tab_index(), new_tab, "タブが切り替わる");
assert_eq!(
app.tab.mode,
Mode::Preview,
"戻ったタブの Preview が復元される"
);
}
#[test]
fn flash_is_cleared_on_next_key() {
let dir = unique_tmp("konoma_flash_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir, Config::default()).unwrap();
app.flash = Some("x".into());
handle_key(&mut app, key('j')).unwrap();
assert_eq!(app.flash, None, "次のキーで flash が消える");
}
#[test]
fn recoverable_key_error_flashes_and_does_not_quit() {
let dir = unique_tmp("konoma_recoverable_err_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let quit = resolve_key_result(&mut app, Err(anyhow::anyhow!("boom: refresh 失敗")));
assert!(!quit, "回復可能な Err でループは終了しない");
let flash = app
.flash
.as_deref()
.expect("Err は握り潰さず flash で見せる");
assert!(
flash.contains("boom"),
"原因メッセージが flash に出る: {flash}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn close_tab_or_quit_closes_tab_when_multiple_else_quits() {
let dir = unique_tmp("konoma_close_tab_or_quit_test");
std::fs::create_dir_all(&dir).unwrap();
let mut cfg = Config::default();
cfg.ui.confirm_quit = false; let mut app = App::new(dir.clone(), cfg).unwrap();
app.tab_new().unwrap();
assert_eq!(app.tab_count(), 2);
let quit = dispatch_action(&mut app, Action::CloseTabOrQuit, Surface::Tree).unwrap();
assert!(!quit, "複数タブでは終了しない");
assert_eq!(app.tab_count(), 1, "現在タブが閉じて1枚に戻る");
let quit = dispatch_action(&mut app, Action::CloseTabOrQuit, Surface::Tree).unwrap();
assert!(quit, "最後の1枚では終了する");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_key_result_passes_through_quit_and_continue() {
let dir = unique_tmp("konoma_resolve_passthrough_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
assert!(
resolve_key_result(&mut app, Ok(true)),
"Ok(true) は終了要求を通す"
);
assert_eq!(app.flash, None, "終了要求では flash を立てない");
assert!(!resolve_key_result(&mut app, Ok(false)), "Ok(false) は継続");
assert_eq!(app.flash, None, "継続では flash を立てない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn poll_is_fast_while_a_filter_pool_walk_is_in_flight() {
let dir = unique_tmp("konoma_poll_pool");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub/f.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let idle = poll_timeout(&app);
assert!(
idle > Duration::from_millis(16),
"土台: 何も走っていなければ待ちは長い ({idle:?})"
);
app::set_filter_scan_budget(Some(Some(Duration::ZERO)));
let (tx, rx) = std::sync::mpsc::channel();
app.attach_filter_pool_loader(tx);
app.start_filter();
assert!(app.filter_pool_scan_in_flight(), "土台: 受け渡しが起きた");
assert_eq!(
poll_timeout(&app),
Duration::from_millis(16),
"走査中は速く poll する"
);
let res = rx.recv_timeout(Duration::from_secs(10)).unwrap();
app.apply_filter_pool(res);
assert!(!app.filter_pool_scan_in_flight());
assert_eq!(poll_timeout(&app), idle, "着地したら元の待ちに戻る");
app::set_filter_scan_budget(None);
std::fs::remove_dir_all(&dir).ok();
}
}