use anyhow::Result;
use crossbeam_channel::Receiver;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::Frame;
use std::collections::HashSet;
use std::path::PathBuf;
use std::thread::JoinHandle;
use crate::bookmarks::Bookmarks;
use crate::config::Config;
use crate::dir_index::DirIndex;
use crate::disks::Disks;
use crate::event_handler::EventHandler;
use crate::navigation::Navigation;
use crate::quick_jump::QuickJump;
use crate::search::Search;
use crate::ui::UI;
pub struct App {
nav: Navigation,
search: Search,
quick_jump: QuickJump,
ui: UI,
event_handler: EventHandler,
config: Config,
pub bookmarks: Bookmarks,
pub disks: Disks,
dir_index: DirIndex,
index_build_thread: Option<JoinHandle<()>>,
index_build_receiver: Option<Receiver<DirIndex>>,
needs_redraw: bool,
}
impl App {
pub fn new(start_path: PathBuf) -> Result<Self> {
let config = Config::load()?;
let nav = Navigation::new(
start_path,
false,
config.behavior.show_hidden,
config.behavior.follow_symlinks,
)?;
let search = Search::new();
let quick_jump = QuickJump::new();
let ui = UI::new();
let event_handler = EventHandler::new();
let bookmarks = Bookmarks::new()?;
let disks = Disks::new();
let dir_index = DirIndex::empty();
Ok(App {
nav,
search,
quick_jump,
ui,
event_handler,
config,
bookmarks,
disks,
dir_index,
index_build_thread: None,
index_build_receiver: None,
needs_redraw: true,
})
}
pub fn start_background_index(&mut self) {
if !self.config.index.enabled {
return;
}
let Some(index_path) = DirIndex::dir_index_path() else {
return;
};
let roots = self.config.index.roots.clone();
let ignore_dirs: HashSet<String> = self.config.index.ignore_dirs.iter().cloned().collect();
let (handle, receiver) = DirIndex::spawn_load_or_build(
index_path,
self.config.index.refresh_hours,
roots,
ignore_dirs,
);
self.index_build_thread = Some(handle);
self.index_build_receiver = Some(receiver);
}
pub fn poll_dir_index_build(&mut self) -> bool {
let Some(rx) = self.index_build_receiver.as_ref() else {
return false;
};
match rx.try_recv() {
Ok(index) => {
self.dir_index = index;
self.index_build_thread = None;
self.index_build_receiver = None;
true
}
Err(_) => false,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<Option<PathBuf>> {
let result = self.event_handler.handle_key(
key,
&mut self.nav,
&mut self.search,
&mut self.quick_jump,
&mut self.bookmarks,
&mut self.disks,
&self.ui,
&self.config,
&self.dir_index,
);
self.mark_dirty();
result
}
pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Result<()> {
let result = self.event_handler.handle_mouse(
mouse,
&mut self.nav,
&mut self.search,
&mut self.bookmarks,
&mut self.disks,
&mut self.ui,
&self.config,
);
self.mark_dirty();
result
}
pub fn render(&mut self, frame: &mut Frame) {
self.ui.render_compact(
frame,
&self.nav,
&self.search,
&self.quick_jump,
&self.bookmarks,
&self.disks,
&self.config,
);
}
pub fn poll_disks(&mut self) -> bool {
let updated = self.disks.poll_load();
if updated {
self.mark_dirty();
}
updated
}
pub fn poll_search(&mut self) -> bool {
let updated = self.search.poll_results();
if updated {
self.mark_dirty();
}
updated
}
pub fn poll_quick_jump(&mut self) -> bool {
if !self.quick_jump.active {
return false;
}
let nav_root = self.nav.root.borrow().path.clone();
let (has_updates, jump_to) =
self.quick_jump
.tick(&nav_root, self.nav.show_hidden, self.nav.follow_symlinks);
if let Some(path) = jump_to {
let _ = self.nav.expand_path_to_node(&path, false);
}
if has_updates {
self.mark_dirty();
}
has_updates
}
pub fn mark_dirty(&mut self) {
self.needs_redraw = true;
}
pub fn clear_dirty(&mut self) {
self.needs_redraw = false;
}
pub fn needs_redraw(&self) -> bool {
self.needs_redraw
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{backend::TestBackend, Terminal};
use std::time::{Duration, Instant};
#[test]
fn test_bookmark_create_enters_creation_mode() {
let temp_dir = std::env::temp_dir().join("bmrk_test_bm_create");
std::fs::create_dir_all(&temp_dir).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_m = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
let result = app.handle_key(key_m).unwrap();
assert!(result.is_some(), "pressing 'm' should not exit the app");
assert!(app.bookmarks.is_creating);
let key_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let _ = app.handle_key(key_esc);
assert!(!app.bookmarks.is_creating);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_bookmark_creation_ctrl_jk_scrolls_bookmark_list() {
let temp_dir = std::env::temp_dir().join("bmrk_test_bm_create_ctrl_jk");
std::fs::create_dir_all(&temp_dir).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
for i in 0..5 {
app.bookmarks
.add(format!("mark{i}"), temp_dir.clone(), None)
.unwrap();
}
let selected_before = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
let key_m = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
let _ = app.handle_key(key_m).unwrap();
assert!(app.bookmarks.is_creating);
assert_eq!(app.bookmarks.scroll_offset, 0);
let key_ctrl_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL);
let _ = app.handle_key(key_ctrl_j).unwrap();
assert_eq!(
app.bookmarks.scroll_offset, 1,
"Ctrl+j should scroll the bookmark list down"
);
let selected_after = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(
selected_after, selected_before,
"Ctrl+j must not move the underlying tree selection"
);
let key_ctrl_k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
let _ = app.handle_key(key_ctrl_k).unwrap();
assert_eq!(
app.bookmarks.scroll_offset, 0,
"Ctrl+k should scroll the bookmark list back up"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_search_mode_activation() {
let temp_dir = std::env::temp_dir().join("bmrk_test_search");
std::fs::create_dir_all(&temp_dir).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let result = app.handle_key(key_slash).unwrap();
assert!(result.is_some(), "entering search should not exit the app");
assert!(app.search.mode, "search mode should be active");
let key_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
let _ = app.handle_key(key_r);
let key_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let result = app.handle_key(key_enter).unwrap();
assert!(result.is_some(), "search execution should not exit the app");
assert!(app.search.show_results, "search results should be shown");
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_activation_and_jump() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump");
std::fs::create_dir_all(&temp_dir).unwrap();
let target = temp_dir.join("target_folder");
std::fs::create_dir_all(&target).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let original_selected = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let result = app.handle_key(key_tab).unwrap();
assert!(
result.is_some(),
"activating quick jump should not exit the app"
);
assert!(app.quick_jump.active, "quick jump should be active");
for c in ['t', 'a', 'r'] {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
assert_eq!(app.quick_jump.buffer, "tar");
assert!(app.quick_jump.has_match, "buffer should have a match");
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected");
assert_eq!(
selected.borrow().path,
target,
"cursor should jump to the matched folder"
);
let key_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let _ = app.handle_key(key_esc).unwrap();
assert!(
!app.quick_jump.active,
"quick jump should be inactive after Esc"
);
assert_eq!(
app.quick_jump.buffer, "",
"buffer should be cleared after Esc"
);
let selected_after_esc = app
.nav
.get_selected_node()
.expect("a node should be selected");
assert_eq!(
selected_after_esc.borrow().path,
original_selected,
"Esc must restore the original selection from before Tab was pressed"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_esc_collapses_folders_expanded_during_the_session() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_esc_collapse");
std::fs::create_dir_all(&temp_dir).unwrap();
let sub = temp_dir.join("sub");
let target = sub.join("target_leaf");
std::fs::create_dir_all(&target).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
app.dir_index = crate::dir_index::DirIndex::from_paths(vec![target.clone()]);
assert!(
!app.nav.root.borrow().children[0].borrow().is_expanded,
"`sub` must not be expanded before quick jump runs"
);
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in "target_leaf".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(
selected, target,
"quick jump should have expanded down to the target"
);
assert!(
app.nav.root.borrow().children[0].borrow().is_expanded,
"`sub` should be expanded as a side effect of the jump"
);
let key_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let _ = app.handle_key(key_esc).unwrap();
assert!(
!app.nav.root.borrow().children[0].borrow().is_expanded,
"Esc must collapse the folder that only got expanded during the quick-jump session"
);
let selected_after_esc = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(selected_after_esc, temp_dir);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_tab_exit_keeps_expansion_trail() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_tab_exit_keeps_expansion");
std::fs::create_dir_all(&temp_dir).unwrap();
let sub = temp_dir.join("sub");
let target = sub.join("target_leaf");
std::fs::create_dir_all(&target).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
app.dir_index = crate::dir_index::DirIndex::from_paths(vec![target.clone()]);
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in "target_leaf".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
let _ = app.handle_key(key_tab).unwrap();
assert!(!app.quick_jump.active);
assert!(
app.nav.root.borrow().children[0].borrow().is_expanded,
"confirming with Tab must keep the expansion, unlike Esc"
);
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(selected, target);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_slash_narrows_scope() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_slash_narrow");
std::fs::create_dir_all(&temp_dir).unwrap();
let alpha = temp_dir.join("alpha");
let beta = temp_dir.join("beta");
let alpha_target = alpha.join("shared_target");
let beta_target = beta.join("shared_target");
let beta_only = beta.join("beta_only_target");
std::fs::create_dir_all(&alpha_target).unwrap();
std::fs::create_dir_all(&beta_target).unwrap();
std::fs::create_dir_all(&beta_only).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
app.dir_index = crate::dir_index::DirIndex::from_paths(vec![
alpha_target.clone(),
beta_target.clone(),
beta_only.clone(),
]);
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in "alpha".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(selected, alpha, "quick jump should land on `alpha` first");
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let _ = app.handle_key(key_slash).unwrap();
assert_eq!(app.quick_jump.scan_stack, vec![alpha.clone()]);
assert_eq!(
app.quick_jump.buffer, "alpha/",
"narrowing must NOT clear the buffer — it grows into a path breadcrumb"
);
assert!(app.quick_jump.active, "narrowing must not exit quick jump");
for c in "shared".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
assert!(app.quick_jump.has_match);
assert_eq!(
app.quick_jump.matches,
vec![alpha_target.clone()],
"narrowed search must never surface beta's same-named folder"
);
let selected_after_narrow = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(selected_after_narrow, alpha_target);
for _ in 0.."shared".len() {
let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
assert_eq!(app.quick_jump.buffer, "alpha/");
assert_eq!(
app.quick_jump.scan_stack,
vec![alpha.clone()],
"backspacing within a segment must not pop the scope"
);
for c in "beta_only".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
assert!(
!app.quick_jump.has_match,
"a folder that exists only outside the narrowed scope must not match"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_slash_noop_without_match() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_slash_noop");
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::create_dir_all(temp_dir.join("child")).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let result = app.handle_key(key_slash).unwrap();
assert!(result.is_some(), "no-op `/` must not exit the app");
assert!(app.quick_jump.scan_stack.is_empty());
assert_eq!(app.quick_jump.buffer, "");
assert!(app.quick_jump.active, "quick jump must remain active");
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_slash_appends_without_clearing_buffer() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_slash_buffer");
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::create_dir_all(temp_dir.join("src")).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in ['s', 'r'] {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
assert!(app.quick_jump.has_match, "'sr' should match 'src'");
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let _ = app.handle_key(key_slash).unwrap();
assert_eq!(
app.quick_jump.buffer, "sr/",
"`/` must append a single slash to whatever was typed, never clear it"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_slash_physically_expands_matched_folder() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_slash_expands");
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::create_dir_all(temp_dir.join("alpha").join("child")).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in "alpha".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
assert!(
!app.nav.root.borrow().children[0].borrow().is_expanded,
"landing on a match must not expand it by itself (Part 1 behavior, unchanged)"
);
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let _ = app.handle_key(key_slash).unwrap();
assert!(
app.nav.root.borrow().children[0].borrow().is_expanded,
"`/` must physically expand the folder it locks onto"
);
assert!(
!app.nav.root.borrow().children[0]
.borrow()
.children
.is_empty(),
"expanding must load the folder's children"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_backspace_pops_segment_and_recollapses() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_backspace_pop");
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::create_dir_all(temp_dir.join("alpha").join("child")).unwrap();
let alpha = temp_dir.join("alpha");
let mut app = App::new(temp_dir.clone()).unwrap();
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in "alpha".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let _ = app.handle_key(key_slash).unwrap();
assert_eq!(app.quick_jump.scan_stack, vec![alpha.clone()]);
assert!(app.nav.root.borrow().children[0].borrow().is_expanded);
let key_backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
let _ = app.handle_key(key_backspace).unwrap();
assert!(
app.quick_jump.scan_stack.is_empty(),
"Backspace at the segment boundary must pop the locked scope"
);
assert_eq!(
app.quick_jump.buffer, "alpha",
"Backspace must strip exactly the trailing slash, keeping the typed prefix"
);
assert!(
!app.nav.root.borrow().children[0].borrow().is_expanded,
"backing out of a segment must re-collapse the folder `/` expanded"
);
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(
selected, alpha,
"backing out must move the selection back onto the folder itself"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_backspace_does_not_collapse_pre_existing_expansion() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_backspace_pre_expanded");
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::create_dir_all(temp_dir.join("alpha").join("child")).unwrap();
let alpha = temp_dir.join("alpha");
let mut app = App::new(temp_dir.clone()).unwrap();
app.nav.toggle_node(&alpha, false).unwrap();
assert!(app.nav.root.borrow().children[0].borrow().is_expanded);
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in "alpha".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let _ = app.handle_key(key_slash).unwrap();
let key_backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
let _ = app.handle_key(key_backspace).unwrap();
assert!(
app.nav.root.borrow().children[0].borrow().is_expanded,
"backing out must never collapse a folder the user had already opened before Tab"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_esc_after_narrow_restores_full_pre_tab_state() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_esc_after_narrow");
std::fs::create_dir_all(&temp_dir).unwrap();
let sub = temp_dir.join("sub");
let leaf = sub.join("target_leaf");
std::fs::create_dir_all(&leaf).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
app.dir_index = crate::dir_index::DirIndex::from_paths(vec![leaf.clone()]);
let original_selected = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in "sub".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let _ = app.handle_key(key_slash).unwrap();
for c in "target_leaf".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(selected, leaf, "narrowed jump should still reach the leaf");
assert!(app.nav.root.borrow().children[0].borrow().is_expanded);
let key_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let _ = app.handle_key(key_esc).unwrap();
assert!(!app.quick_jump.active);
assert!(
app.quick_jump.scan_stack.is_empty(),
"Esc must reset the scope too"
);
assert!(
!app.nav.root.borrow().children[0].borrow().is_expanded,
"Esc must collapse everything expanded during the whole session, including after `/`"
);
let selected_after_esc = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(
selected_after_esc, original_selected,
"Esc must restore the pre-Tab selection regardless of how many times `/` was pressed"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_shift_tab_cycles_through_matches() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_shift_tab");
std::fs::create_dir_all(&temp_dir).unwrap();
let doc1 = temp_dir.join("documents");
let doc2 = temp_dir.join("docs_backup");
let doc3 = temp_dir.join("docker");
std::fs::create_dir_all(&doc1).unwrap();
std::fs::create_dir_all(&doc2).unwrap();
std::fs::create_dir_all(&doc3).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in ['d', 'o', 'c'] {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
assert_eq!(
app.quick_jump.matches.len(),
3,
"all three 'doc*' folders should be found"
);
let first_target = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(app.quick_jump.current_index, 0);
let key_backtab = KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT);
let _ = app.handle_key(key_backtab).unwrap();
assert!(
app.quick_jump.active,
"Shift+Tab must not exit quick jump, only cycle the match"
);
assert_eq!(app.quick_jump.current_index, 1);
let second_target = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_ne!(
second_target, first_target,
"Shift+Tab should move the selection to a different match"
);
let _ = app.handle_key(key_backtab).unwrap();
assert_eq!(app.quick_jump.current_index, 2);
let _ = app.handle_key(key_backtab).unwrap();
assert_eq!(app.quick_jump.current_index, 0, "cycling must wrap around");
let wrapped_target = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(wrapped_target, first_target);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_letters_j_and_k_are_typeable_not_navigation() {
let temp_dir = std::env::temp_dir().join("bmrk_test_quick_jump_jk");
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::create_dir_all(temp_dir.join("jazz")).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
assert!(app.quick_jump.active);
let key_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
let _ = app.handle_key(key_j).unwrap();
assert!(
app.quick_jump.active,
"quick jump must stay active after 'j'"
);
assert_eq!(app.quick_jump.buffer, "j");
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_app_new_never_spawns_index_build_thread() {
let temp_dir = std::env::temp_dir().join("bmrk_test_no_thread_on_new");
std::fs::create_dir_all(&temp_dir).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
assert!(app.index_build_thread.is_none());
assert!(app.index_build_receiver.is_none());
assert!(
!app.poll_dir_index_build(),
"polling with no build in flight must be a no-op"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_start_background_index_noop_when_disabled() {
let temp_dir = std::env::temp_dir().join("bmrk_test_index_disabled");
std::fs::create_dir_all(&temp_dir).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
app.config.index.enabled = false;
app.start_background_index();
assert!(
app.index_build_thread.is_none(),
"disabled index must never spawn a builder thread"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_index_hit_skips_phase_2() {
let temp_dir = std::env::temp_dir().join("bmrk_test_dir_index_phase15_hit");
let _ = std::fs::remove_dir_all(&temp_dir);
let sub = temp_dir.join("sub");
let deep_target = sub.join("deep_target_xyz");
std::fs::create_dir_all(&deep_target).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
app.dir_index = crate::dir_index::DirIndex::from_paths(vec![deep_target.clone()]);
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in "deep_target".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
assert!(
app.quick_jump.has_match,
"index match should be found via Phase 1.5"
);
assert!(
!app.quick_jump.is_scanning,
"Phase 2 must be skipped when Phase 1.5 already satisfied the lookup"
);
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected");
assert_eq!(selected.borrow().path, deep_target);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_index_miss_falls_back_to_phase_2() {
let temp_dir = std::env::temp_dir().join("bmrk_test_dir_index_phase15_miss");
let _ = std::fs::remove_dir_all(&temp_dir);
let sub = temp_dir.join("sub");
let target = sub.join("nested_target_zzz");
std::fs::create_dir_all(&target).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in "nested_target_zzz".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
assert!(
!app.quick_jump.has_match,
"neither Phase 1 nor the empty index should find a not-yet-loaded nested folder"
);
let start = Instant::now();
loop {
app.poll_quick_jump();
if app.quick_jump.has_match {
break;
}
assert!(
start.elapsed() < Duration::from_secs(5),
"timed out waiting for the Phase 2 fallback scan to find the target"
);
std::thread::sleep(Duration::from_millis(20));
}
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected");
assert_eq!(selected.borrow().path, target);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_quick_jump_merges_phase1_shallow_hit_with_index_deep_match() {
let temp_dir = std::env::temp_dir().join("bmrk_test_merge_phase1_and_index");
let _ = std::fs::remove_dir_all(&temp_dir);
let shallow = temp_dir.join("bmrk_shallow");
let deep = temp_dir.join("sub").join("bmrk_deep");
std::fs::create_dir_all(&shallow).unwrap();
std::fs::create_dir_all(&deep).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
app.dir_index = crate::dir_index::DirIndex::from_paths(vec![shallow.clone(), deep.clone()]);
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
for c in "bmrk".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
assert_eq!(
app.quick_jump.matches.len(),
2,
"both the Phase-1 shallow hit and the index-only deep match must be present"
);
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(
selected, shallow,
"shallowest match still wins the initial jump"
);
let key_backtab = KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT);
let _ = app.handle_key(key_backtab).unwrap();
let selected_after_cycle = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(
selected_after_cycle, deep,
"Shift+Tab must be able to reach the index-only deep match"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_copy_path_key_sets_feedback_and_clears_on_next_key() {
let temp_dir = std::env::temp_dir().join("bmrk_test_copy_path");
std::fs::create_dir_all(&temp_dir).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
assert!(app.nav.copy_feedback.is_none());
let key_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
let result = app.handle_key(key_c).unwrap();
assert!(result.is_some(), "pressing 'c' should not exit the app");
assert!(
app.nav.copy_feedback.is_some(),
"copy action must set some feedback"
);
let key_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
let _ = app.handle_key(key_j).unwrap();
assert!(
app.nav.copy_feedback.is_none(),
"feedback must be cleared by the next keypress"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_search_result_directory_selects_exact_node() {
let temp_dir = std::env::temp_dir().join("bmrk_test_search_result_dir");
std::fs::create_dir_all(&temp_dir).unwrap();
let target = temp_dir.join("needle_dir");
std::fs::create_dir_all(&target).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let _ = app.handle_key(key_slash).unwrap();
for c in "needle".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
let key_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let _ = app.handle_key(key_enter).unwrap();
assert!(app.search.show_results);
assert!(
app.search
.results
.iter()
.any(|r| r.path == target && r.is_dir),
"directory result should be found via Phase 1"
);
app.search.selected = app
.search
.results
.iter()
.position(|r| r.path == target)
.unwrap();
let _ = app.handle_key(key_enter).unwrap();
assert!(!app.search.focus_on_results);
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected");
assert_eq!(
selected.borrow().path,
target,
"cursor should land exactly on the matched directory, not an arbitrary node"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_search_result_file_selects_parent_folder() {
let temp_dir = std::env::temp_dir().join("bmrk_test_search_result_file");
std::fs::create_dir_all(&temp_dir).unwrap();
let sub_dir = temp_dir.join("sub");
std::fs::create_dir_all(&sub_dir).unwrap();
let needle_file = sub_dir.join("needle.txt");
std::fs::write(&needle_file, b"content").unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let _ = app.handle_key(key_slash).unwrap();
for c in "needle".chars() {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = app.handle_key(key).unwrap();
}
let key_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let _ = app.handle_key(key_enter).unwrap();
assert!(app.search.show_results);
let start = Instant::now();
let timeout = Duration::from_secs(5);
loop {
app.poll_search();
if app.search.results.iter().any(|r| r.path == needle_file) {
break;
}
assert!(
start.elapsed() < timeout,
"timed out waiting for file result to appear"
);
std::thread::sleep(Duration::from_millis(20));
}
app.search.selected = app
.search
.results
.iter()
.position(|r| r.path == needle_file)
.unwrap();
let _ = app.handle_key(key_enter).unwrap();
assert!(!app.search.focus_on_results);
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected");
assert_eq!(
selected.borrow().path,
sub_dir,
"selecting a file result should land on its containing folder"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_tab_with_search_results_toggles_focus_not_quick_jump() {
let temp_dir = std::env::temp_dir().join("bmrk_test_tab_search_focus");
std::fs::create_dir_all(&temp_dir).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let _ = app.handle_key(key_slash).unwrap();
let key_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
let _ = app.handle_key(key_r).unwrap();
let key_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let _ = app.handle_key(key_enter).unwrap();
assert!(app.search.show_results);
let focus_before = app.search.focus_on_results;
let key_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let _ = app.handle_key(key_tab).unwrap();
assert!(
!app.quick_jump.active,
"quick jump must not activate while search results are showing"
);
assert_eq!(
app.search.focus_on_results, !focus_before,
"Tab should still toggle search focus"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_bookmark_select_enters_selection_mode() {
let temp_dir = std::env::temp_dir().join("bmrk_test_bm_select");
std::fs::create_dir_all(&temp_dir).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_tick = KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE);
let result = app.handle_key(key_tick).unwrap();
assert!(
result.is_some(),
"bookmark selection should not exit the app"
);
assert!(app.bookmarks.is_selecting);
let key_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let _ = app.handle_key(key_esc);
assert!(!app.bookmarks.is_selecting);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_esc_exits_app() {
let temp_dir = std::env::temp_dir().join("bmrk_test_esc");
std::fs::create_dir_all(&temp_dir).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let key_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let result = app.handle_key(key_esc).unwrap();
assert!(result.is_none(), "Esc in normal mode should signal exit");
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_h_on_leaf_root_goes_to_parent_immediately() {
let temp_dir = std::env::temp_dir().join("bmrk_test_h_leaf_root");
std::fs::create_dir_all(&temp_dir).unwrap();
let leaf = temp_dir.join("leaf");
std::fs::create_dir_all(&leaf).unwrap();
let mut app = App::new(leaf.clone()).unwrap();
assert_eq!(app.nav.root.borrow().path, leaf);
let key_h = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE);
let _ = app.handle_key(key_h).unwrap();
assert_eq!(
app.nav.root.borrow().path,
temp_dir,
"a single `h` press on a childless root must go straight to its parent"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_h_on_expanded_root_with_children_goes_to_parent_in_one_press() {
let temp_dir = std::env::temp_dir().join("bmrk_test_h_expanded_root");
std::fs::create_dir_all(&temp_dir).unwrap();
let child = temp_dir.join("child");
std::fs::create_dir_all(&child).unwrap();
let mut app = App::new(child.clone()).unwrap();
assert_eq!(app.nav.root.borrow().path, child);
let key_h = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE);
let _ = app.handle_key(key_h).unwrap();
assert_eq!(
app.nav.root.borrow().path,
temp_dir,
"a single `h` press on an expanded root must go straight to its parent"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_h_on_non_root_expanded_dir_collapses_first() {
let temp_dir = std::env::temp_dir().join("bmrk_test_h_non_root_collapse");
std::fs::create_dir_all(&temp_dir).unwrap();
let sub = temp_dir.join("sub");
let grandchild = sub.join("grandchild");
std::fs::create_dir_all(&grandchild).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
app.nav.toggle_node(&sub, false).unwrap();
let idx = app
.nav
.flat_list
.iter()
.position(|n| n.borrow().path == sub)
.unwrap();
app.nav.selected = idx;
assert!(app.nav.root.borrow().children[0].borrow().is_expanded);
let key_h = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE);
let _ = app.handle_key(key_h).unwrap();
assert_eq!(
app.nav.root.borrow().path,
temp_dir,
"collapsing a non-root node must not change the root"
);
assert!(
!app.nav.root.borrow().children[0].borrow().is_expanded,
"the first `h` press on an expanded non-root node must collapse it"
);
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(selected, sub, "selection must stay on the collapsed node");
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_shift_q_force_quits_unconditionally_from_any_mode() {
let temp_dir = std::env::temp_dir().join("bmrk_test_shift_q_force_quit");
std::fs::create_dir_all(&temp_dir).unwrap();
let key_shift_q = KeyEvent::new(KeyCode::Char('Q'), KeyModifiers::SHIFT);
let mut app = App::new(temp_dir.clone()).unwrap();
assert_eq!(
app.handle_key(key_shift_q).unwrap(),
None,
"Shift+Q must exit immediately from tree navigation mode"
);
let mut app = App::new(temp_dir.clone()).unwrap();
let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
let _ = app.handle_key(key_slash).unwrap();
assert!(app.search.mode, "search mode should be active");
assert_eq!(
app.handle_key(key_shift_q).unwrap(),
None,
"Shift+Q must exit immediately even while search input is active"
);
let mut app = App::new(temp_dir.clone()).unwrap();
let key_m = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
let _ = app.handle_key(key_m).unwrap();
assert!(
app.bookmarks.is_creating,
"bookmark creation should be active"
);
assert_eq!(
app.handle_key(key_shift_q).unwrap(),
None,
"Shift+Q must exit immediately even while creating a bookmark"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_last_click_time_reset_on_tab_focus_toggle() {
let temp_dir = std::env::temp_dir().join("bmrk_test_last_click_tab_toggle");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
for i in 0..5 {
std::fs::create_dir_all(temp_dir.join(format!("sub{}", i))).unwrap();
}
let mut app = App::new(temp_dir.clone()).unwrap();
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| app.render(f)).unwrap();
for c in ['/', 's', 'u', 'b'] {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(app.search.show_results);
assert!(app.search.focus_on_results);
assert!(
app.search.results.len() >= 4,
"expected at least 4 search results, got {}",
app.search.results.len()
);
terminal.draw(|f| app.render(f)).unwrap();
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
assert!(!app.search.focus_on_results);
terminal.draw(|f| app.render(f)).unwrap();
let tree_top = app.ui.tree_area_top;
let tree_col = app.ui.tree_area_start + 1;
app.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: tree_col,
row: tree_top + 3,
modifiers: KeyModifiers::NONE,
})
.unwrap();
assert!(app.event_handler.last_click_time.is_some());
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
assert!(app.search.focus_on_results);
assert!(
app.event_handler.last_click_time.is_none(),
"last_click_time must be reset by the Tab focus toggle"
);
terminal.draw(|f| app.render(f)).unwrap();
let search_top = app.ui.tree_area_top;
app.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: search_top + 3,
modifiers: KeyModifiers::NONE,
})
.unwrap();
assert!(
app.search.focus_on_results,
"a same-index click after a Tab toggle must not be treated as a double-click"
);
assert_eq!(app.search.selected, 3);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_last_click_time_reset_on_search_result_enter_jump() {
let temp_dir = std::env::temp_dir().join("bmrk_test_last_click_enter_jump");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
for i in 0..5 {
std::fs::create_dir_all(temp_dir.join(format!("sub{}", i))).unwrap();
}
std::fs::create_dir_all(temp_dir.join("sub1").join("child")).unwrap();
let mut app = App::new(temp_dir.clone()).unwrap();
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| app.render(f)).unwrap();
for c in ['/', 's', 'u', 'b'] {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(app.search.show_results);
assert!(app.search.focus_on_results);
assert!(
app.search.results.len() >= 4,
"expected at least 4 search results, got {}",
app.search.results.len()
);
terminal.draw(|f| app.render(f)).unwrap();
let top = app.ui.tree_area_top;
app.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: top + 1,
modifiers: KeyModifiers::NONE,
})
.unwrap();
assert!(app.event_handler.last_click_time.is_some());
let clicked_path = app.search.results[app.search.selected].path.clone();
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(
!app.search.focus_on_results,
"Enter-jump should return focus to the tree"
);
assert!(
app.event_handler.last_click_time.is_none(),
"last_click_time must be reset after an Enter-jump from search results"
);
terminal.draw(|f| app.render(f)).unwrap();
let jumped_idx = app
.nav
.flat_list
.iter()
.position(|n| n.borrow().path == clicked_path)
.expect("jumped-to node must be visible in the tree");
assert!(!app.nav.flat_list[jumped_idx].borrow().is_expanded);
let flat_len_before = app.nav.flat_list.len();
let tree_top = app.ui.tree_area_top;
app.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: app.ui.tree_area_start + 1,
row: tree_top + jumped_idx as u16,
modifiers: KeyModifiers::NONE,
})
.unwrap();
assert!(
!app.nav.flat_list[jumped_idx].borrow().is_expanded,
"a same-index click right after an Enter-jump must not be treated as a stale double-click"
);
assert_eq!(app.nav.flat_list.len(), flat_len_before);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_search_results_focus_blocks_unrelated_tree_hotkeys() {
let temp_dir = std::env::temp_dir().join("bmrk_test_search_focus_blocks_hotkeys");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
for i in 0..3 {
std::fs::create_dir_all(temp_dir.join(format!("sub{}", i))).unwrap();
}
let mut app = App::new(temp_dir.clone()).unwrap();
for c in ['/', 's', 'u', 'b'] {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(app.search.show_results);
assert!(app.search.focus_on_results);
let tree_selection_before = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
for (label, key) in [
(
"copy_path",
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
),
(
"create_bookmark",
KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE),
),
(
"select_bookmark",
KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE),
),
(
"select_disk",
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
),
(
"go_to_parent",
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE),
),
(
"go_back",
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
),
(
"h_left",
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
),
] {
app.handle_key(key).unwrap();
assert!(
app.search.focus_on_results,
"{label} must not change search focus"
);
assert!(
!app.bookmarks.is_creating,
"{label} must not open bookmark creation"
);
assert!(
!app.bookmarks.is_selecting,
"{label} must not open bookmark selection"
);
assert!(
!app.disks.is_selecting,
"{label} must not open disk selection"
);
assert!(
app.nav.copy_feedback.is_none(),
"{label} must not copy the hidden tree selection"
);
let tree_selection_after = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(
tree_selection_after, tree_selection_before,
"{label} must not move the hidden tree selection"
);
}
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_results_filter_slash_narrows_then_enter_jumps() {
let temp_dir = std::env::temp_dir().join("bmrk_test_results_filter_enter");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
for i in 0..5 {
std::fs::create_dir_all(temp_dir.join(format!("sub{}", i))).unwrap();
}
let mut app = App::new(temp_dir.clone()).unwrap();
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| app.render(f)).unwrap();
for c in ['/', 's', 'u', 'b'] {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
let total = app.search.results.len();
assert!(
total >= 4,
"expected the search to find sub0..sub4, got {total}"
);
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
.unwrap();
assert!(
app.search.filtering_results,
"'/' must open the results filter"
);
assert!(!app.search.mode, "'/' here must NOT start a fresh search");
for c in ['s', 'u', 'b', '2'] {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
assert_eq!(app.search.result_filter, "sub2");
assert_eq!(
app.search.results.len(),
1,
"filter 'sub2' must narrow to the single matching result"
);
let target = app.search.results[0].path.clone();
assert!(target.ends_with("sub2"));
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(!app.search.filtering_results);
assert!(!app.search.focus_on_results);
let selected = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_eq!(
selected, target,
"Enter must jump to the filtered result in the tree"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_results_filter_tab_returns_to_navigation_and_esc_clears() {
let temp_dir = std::env::temp_dir().join("bmrk_test_results_filter_tab_esc");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
for i in 0..5 {
std::fs::create_dir_all(temp_dir.join(format!("sub{}", i))).unwrap();
}
let mut app = App::new(temp_dir.clone()).unwrap();
for c in ['/', 's', 'u', 'b'] {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
let total = app.search.results.len();
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
.unwrap();
for c in ['s', 'u', 'b', '3'] {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
assert_eq!(app.search.results.len(), 1);
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
assert!(!app.search.filtering_results, "Tab closes the filter input");
assert!(
app.search.focus_on_results,
"Tab from the filter stays on results"
);
assert_eq!(
app.search.result_filter, "sub3",
"Tab keeps the filter applied"
);
assert_eq!(app.search.results.len(), 1);
app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.search.result_filter, "sub3",
"'j' must not type into the applied filter"
);
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
assert!(!app.search.filtering_results);
assert!(app.search.result_filter.is_empty());
assert_eq!(
app.search.results.len(),
total,
"Esc restores every result that was found"
);
assert!(
app.search.show_results && app.search.focus_on_results,
"Esc on the filter must not close the results panel"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_results_filter_mouse_double_click_jump_leaves_filter_mode() {
let temp_dir = std::env::temp_dir().join("bmrk_test_results_filter_mouse_jump");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
for i in 0..5 {
std::fs::create_dir_all(temp_dir.join(format!("sub{}", i))).unwrap();
}
let mut app = App::new(temp_dir.clone()).unwrap();
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| app.render(f)).unwrap();
for c in ['/', 's', 'u', 'b'] {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
terminal.draw(|f| app.render(f)).unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
.unwrap();
assert!(app.search.filtering_results);
terminal.draw(|f| app.render(f)).unwrap();
let row = app.ui.tree_area_top + 1;
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row,
modifiers: KeyModifiers::NONE,
};
app.handle_mouse(click).unwrap();
app.handle_mouse(click).unwrap();
assert!(
!app.search.focus_on_results,
"double-click jump drops results focus"
);
assert!(
!app.search.filtering_results,
"double-click jump must also leave the filter input, or the tree becomes unresponsive"
);
let sel_before = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE))
.unwrap();
let sel_after = app
.nav
.get_selected_node()
.expect("a node should be selected")
.borrow()
.path
.clone();
assert_ne!(
sel_before, sel_after,
"'j' must move the tree cursor after the jump"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
}