mod app;
mod bookmarks;
mod config;
#[cfg(test)]
mod e2e_tests;
mod fileops;
mod git;
mod i18n;
mod keymap;
mod preview;
#[cfg(test)]
mod speed_tests;
mod ui;
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, IgnoredResult, MdEncodeRequest, MdEncodeResult, MdImageResult, MediaResult, RemoteFetch,
SortKey,
};
use keymap::{Action, KeyPress, Motion, Resolution, Surface};
type ResizeResult = Result<ResizeResponse, Errors>;
fn main() -> Result<()> {
let dir = match std::env::args().nth(1) {
Some(arg) => PathBuf::from(arg),
None => std::env::current_dir().context("カレントディレクトリを取得できません")?,
};
let dir = std::fs::canonicalize(&dir).unwrap_or_else(|_| {
std::env::current_dir()
.map(|cwd| cwd.join(&dir))
.unwrap_or(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();
if picker.is_some() {
std::thread::spawn(preview::svg::warm_fontdb);
}
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 (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 mut app = App::new(dir, cfg)?;
app.attach_media_loader(media_tx);
app.attach_md_image_loader(md_img_tx);
app.attach_remote_md_loader(md_remote_tx);
app.attach_git_loader(ignored_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);
}
let result = run(
&mut terminal,
&mut app,
resp_rx,
WorkerRx {
media: media_rx,
md_img: md_img_rx,
md_remote: md_remote_rx,
md_enc: md_enc_res_rx,
ignored: ignored_rx,
},
);
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 mut meaningful = false;
let mut ignore_rules = false;
for p in paths {
if is_git_lock(p) {
continue; }
meaningful = true;
if is_ignore_rule_file(p) {
ignore_rules = true;
}
}
(meaningful, ignore_rules)
}
fn follow_candidates(paths: &[PathBuf]) -> Vec<PathBuf> {
paths
.iter()
.filter(|p| !p.components().any(|c| c.as_os_str() == ".git"))
.cloned()
.collect()
}
fn is_git_lock(p: &Path) -> bool {
let is_lock = p
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".lock"))
.unwrap_or(false);
is_lock && p.components().any(|c| c.as_os_str() == ".git")
}
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>,
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>,
}
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::<(bool, Vec<PathBuf>)>();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(ev) = res {
let (meaningful, ignore_rules) = classify_fs_paths(&ev.paths);
if meaningful {
let _ = fs_tx.send((ignore_rules, follow_candidates(&ev.paths)));
}
}
})
.ok();
let mut watched_root: Option<PathBuf> = None;
rewatch(watcher.as_mut(), &mut watched_root, &app.root);
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 needs_redraw = true;
loop {
if needs_redraw {
terminal.draw(|frame| ui::render(frame, app))?;
needs_redraw = false;
}
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(());
});
}
let poll_timeout = if app.is_media_loading() || app.md_images_loading() {
Duration::from_millis(16)
} else {
app.gif_poll_timeout().unwrap_or(Duration::from_millis(100))
};
if event::poll(poll_timeout)? {
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.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;
}
}
if app.advance_gif_if_due() {
needs_redraw = true;
}
let mut fs_changed = false;
let mut ignore_rules_changed = false;
while let Ok((b, paths)) = fs_rx.try_recv() {
fs_changed = true;
ignore_rules_changed |= b;
if app.follow_enabled() {
for p in paths {
if app.follow_note_change(&p) {
pending_follow = Some(p);
}
}
}
}
if fs_changed {
let _ = app.refresh_fs(ignore_rules_changed);
needs_redraw = true;
}
if pending_follow.is_some() && !app.follow_enabled() {
pending_follow = None; }
if let Some(p) = pending_follow.clone() {
if app.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 watched_root.as_deref() != Some(app.root.as_path()) {
rewatch(watcher.as_mut(), &mut watched_root, &app.root);
}
}
Ok(())
}
fn rewatch(
watcher: Option<&mut notify::RecommendedWatcher>,
watched: &mut Option<PathBuf>,
root: &std::path::Path,
) {
use notify::{RecursiveMode, Watcher};
let Some(w) = watcher else {
return;
};
if let Some(old) = watched.take() {
let _ = w.unwatch(&old);
}
if w.watch(root, RecursiveMode::Recursive).is_ok() {
*watched = Some(root.to_path_buf());
}
}
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 = app.cfg.git.tool.trim();
let tmpl = if tmpl.is_empty() { "lazygit" } 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.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, crate::i18n::Msg::GitToolFailed)
));
}
Ok(())
}
#[cfg(feature = "git")]
fn git_workdir(root: &std::path::Path) -> PathBuf {
git2::Repository::discover(root)
.ok()
.and_then(|r| r.workdir().map(|w| w.to_path_buf()))
.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 => 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),
_ => {}
},
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::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> {
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 => 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::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::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::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::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(),
#[cfg(feature = "git")]
Action::GitDiffDiscard => app.git_diff_start_discard(),
#[cfg(feature = "git")]
Action::CycleDiffLayout => app.cycle_diff_layout(),
#[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::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::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::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),
_ => {}
},
_ => {}
}
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::Bookmarks => app.close_bookmark_list(),
Surface::Tabs => app.toggle_tab_list(),
Surface::Tree => {
if app.filter_query().is_some() {
app.filter_clear();
} else if app.has_selection() {
app.clear_selection();
}
}
Surface::PreviewText | Surface::PreviewImage => {
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::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(),
#[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()?,
_ => {}
}
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
}
_ => 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 is_follow_key = !sfc.is_text_input()
&& !sfc.is_modal_confirm()
&& app.pending_leader.is_none()
&& matches!(
app.keymaps.resolve(sfc, None, KeyPress::norm(&key)),
Resolution::Action(Action::ToggleFollow)
);
if !is_follow_key {
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 app::Mode;
use config::Config;
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
#[test]
fn fs_event_classification_breaks_loop_and_detects_ignore_rules() {
let pb = |s: &str| vec![PathBuf::from(s)];
assert_eq!(
classify_fs_paths(&pb("/repo/.git/index.lock")),
(false, false)
);
assert_eq!(classify_fs_paths(&pb("/r/.git/HEAD.lock")), (false, 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 any_key_but_follow_toggle_breaks_follow() {
let dir = std::env::temp_dir().join("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(), "F 以外のキーで追尾解除");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn quote_opens_bookmark_list_and_letters_jump() {
let root = std::env::temp_dir().join("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.mode, Mode::Preview);
assert!(app
.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.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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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.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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("konoma_tab_in_preview_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir, Config::default()).unwrap();
app.mode = Mode::Preview;
handle_key(&mut app, key('t')).unwrap();
assert_eq!(app.tab_count(), 2, "Preview 中でも新規タブが作れる");
assert_eq!(app.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.mode, Mode::Preview, "戻ったタブの Preview が復元される");
}
#[test]
fn flash_is_cleared_on_next_key() {
let dir = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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 = std::env::temp_dir().join("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();
}
}