#![allow(clippy::too_many_arguments)]
use crate::dir_index::DirIndex;
use crate::tree_node::TreeNodeRef;
use crossbeam_channel::{unbounded, Receiver, Sender};
use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
const PHASE2_DEBOUNCE_MS: u64 = 150;
pub(crate) const MAX_MATCHES: usize = 20;
enum QuickJumpMessage {
Found(PathBuf),
MoreFound(PathBuf),
Progress(usize),
Done,
}
pub struct QuickJump {
pub active: bool,
pub buffer: String,
pub is_scanning: bool,
pub scanned_count: usize,
pub has_match: bool,
pub matches: Vec<PathBuf>,
pub current_index: usize,
pub scan_stack: Vec<PathBuf>,
scan_thread: Option<JoinHandle<()>>,
cancel_flag: Option<Arc<AtomicBool>>,
result_receiver: Option<Receiver<QuickJumpMessage>>,
pending_scan_at: Option<Instant>,
}
impl Default for QuickJump {
fn default() -> Self {
Self::new()
}
}
impl QuickJump {
pub fn new() -> Self {
Self {
active: false,
buffer: String::new(),
is_scanning: false,
scanned_count: 0,
has_match: true,
matches: Vec::new(),
current_index: 0,
scan_stack: Vec::new(),
scan_thread: None,
cancel_flag: None,
result_receiver: None,
pending_scan_at: None,
}
}
pub fn activate(&mut self) {
self.active = true;
self.buffer.clear();
self.has_match = true;
self.matches.clear();
self.current_index = 0;
self.scan_stack.clear();
self.cancel_scan();
self.pending_scan_at = None;
}
pub fn deactivate(&mut self) {
self.active = false;
self.buffer.clear();
self.has_match = true;
self.matches.clear();
self.current_index = 0;
self.scan_stack.clear();
self.cancel_scan();
self.pending_scan_at = None;
}
pub fn push_segment(&mut self, path: PathBuf) {
self.scan_stack.push(path);
self.buffer.push('/');
self.has_match = true;
self.matches.clear();
self.current_index = 0;
self.cancel_scan();
self.pending_scan_at = None;
}
pub fn pop_segment(&mut self) -> Option<PathBuf> {
if !self.buffer.ends_with('/') {
return None;
}
self.buffer.pop();
let popped = self.scan_stack.pop();
self.has_match = true;
self.matches.clear();
self.current_index = 0;
self.cancel_scan();
self.pending_scan_at = None;
popped
}
pub fn active_prefix(&self) -> String {
self.buffer
.rsplit('/')
.next()
.unwrap_or_default()
.to_lowercase()
}
pub fn resolve_sync_matches(
&self,
tree_root: &TreeNodeRef,
dir_index: &DirIndex,
show_hidden: bool,
) -> Vec<PathBuf> {
let prefix_lower = self.active_prefix();
let scoped_path = self.scan_stack.last();
let mut matches = match scoped_path {
Some(path) => find_node_by_path(tree_root, path)
.map(|node| find_in_loaded_nodes(&node, &prefix_lower, show_hidden))
.unwrap_or_default(),
None => find_in_loaded_nodes(tree_root, &prefix_lower, show_hidden),
};
let root_path = scoped_path
.cloned()
.unwrap_or_else(|| tree_root.borrow().path.clone());
for path in dir_index.prefix_matches(&root_path, &prefix_lower, show_hidden, MAX_MATCHES) {
if !matches.contains(&path) {
matches.push(path);
}
}
matches.sort_by_key(|p| p.components().count());
matches.truncate(MAX_MATCHES);
matches
}
pub fn cycle_next(&mut self) -> Option<PathBuf> {
if self.matches.len() < 2 {
return None;
}
self.current_index = (self.current_index + 1) % self.matches.len();
self.matches.get(self.current_index).cloned()
}
pub fn add_char(&mut self, c: char) {
self.buffer.push(c);
}
pub fn backspace(&mut self) {
self.buffer.pop();
}
pub fn schedule_scan(&mut self) {
self.cancel_scan();
self.pending_scan_at = Some(Instant::now() + Duration::from_millis(PHASE2_DEBOUNCE_MS));
}
pub fn clear_pending(&mut self) {
self.cancel_scan();
self.pending_scan_at = None;
}
fn cancel_scan(&mut self) {
if let Some(flag) = self.cancel_flag.take() {
flag.store(true, Ordering::Relaxed);
}
self.scan_thread = None;
self.result_receiver = None;
self.is_scanning = false;
self.scanned_count = 0;
}
fn start_scan(
&mut self,
root_path: PathBuf,
prefix_lower: String,
show_hidden: bool,
follow_symlinks: bool,
) {
self.cancel_scan();
let (result_tx, result_rx) = unbounded();
let cancelled = Arc::new(AtomicBool::new(false));
let cancelled_thread = Arc::clone(&cancelled);
let handle = thread::spawn(move || {
deep_scan_bfs(
&root_path,
&prefix_lower,
show_hidden,
follow_symlinks,
&result_tx,
&cancelled_thread,
);
});
self.scan_thread = Some(handle);
self.cancel_flag = Some(cancelled);
self.result_receiver = Some(result_rx);
self.is_scanning = true;
}
pub fn tick(
&mut self,
nav_root: &Path,
show_hidden: bool,
follow_symlinks: bool,
) -> (bool, Option<PathBuf>) {
let mut has_updates = false;
if let Some(at) = self.pending_scan_at {
if Instant::now() >= at {
self.pending_scan_at = None;
let prefix_lower = self.active_prefix();
let scan_root = self
.scan_stack
.last()
.cloned()
.unwrap_or_else(|| nav_root.to_path_buf());
self.start_scan(scan_root, prefix_lower, show_hidden, follow_symlinks);
has_updates = true;
}
}
let (found, updated) = self.poll_results();
if updated {
has_updates = true;
}
(has_updates, found)
}
fn poll_results(&mut self) -> (Option<PathBuf>, bool) {
let mut found = None;
let mut done = false;
let mut has_updates = false;
if let Some(ref rx) = self.result_receiver {
while let Ok(msg) = rx.try_recv() {
has_updates = true;
match msg {
QuickJumpMessage::Found(path) => {
found = Some(path.clone());
self.matches.push(path);
self.current_index = 0;
}
QuickJumpMessage::MoreFound(path) => {
self.matches.push(path);
}
QuickJumpMessage::Progress(count) => {
self.scanned_count = count;
}
QuickJumpMessage::Done => {
done = true;
}
}
}
}
if done {
self.scan_thread = None;
self.cancel_flag = None;
self.result_receiver = None;
self.is_scanning = false;
self.has_match = !self.matches.is_empty();
}
(found, has_updates)
}
}
impl Drop for QuickJump {
fn drop(&mut self) {
self.cancel_scan();
}
}
pub fn find_in_loaded_nodes(
root: &TreeNodeRef,
prefix_lower: &str,
show_hidden: bool,
) -> Vec<PathBuf> {
if prefix_lower.is_empty() {
return Vec::new();
}
let mut matches: Vec<PathBuf> = Vec::new();
let mut queue: VecDeque<TreeNodeRef> = VecDeque::new();
{
let root_borrowed = root.borrow();
let children_count = root_borrowed.children.len();
drop(root_borrowed);
for i in 0..children_count {
let child = Rc::clone(&root.borrow().children[i]);
queue.push_back(child);
}
}
while let Some(node) = queue.pop_front() {
let node_borrowed = node.borrow();
if !show_hidden && crate::tree_node::is_hidden_name(&node_borrowed.name) {
continue;
}
if node_borrowed.is_dir && node_borrowed.name.to_lowercase().starts_with(prefix_lower) {
matches.push(node_borrowed.path.clone());
if matches.len() >= MAX_MATCHES {
break;
}
}
let children_count = node_borrowed.children.len();
drop(node_borrowed);
for i in 0..children_count {
let child = Rc::clone(&node.borrow().children[i]);
queue.push_back(child);
}
}
matches
}
pub(crate) fn find_node_by_path(root: &TreeNodeRef, target: &Path) -> Option<TreeNodeRef> {
let root_path = root.borrow().path.clone();
if root_path == target {
return Some(Rc::clone(root));
}
if !target.starts_with(&root_path) {
return None;
}
let children_count = root.borrow().children.len();
for i in 0..children_count {
let child = Rc::clone(&root.borrow().children[i]);
if let Some(found) = find_node_by_path(&child, target) {
return Some(found);
}
}
None
}
fn deep_scan_bfs(
root_path: &Path,
prefix_lower: &str,
show_hidden: bool,
follow_symlinks: bool,
result_tx: &Sender<QuickJumpMessage>,
cancelled: &Arc<AtomicBool>,
) {
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut queue: VecDeque<PathBuf> = VecDeque::new();
queue.push_back(root_path.to_path_buf());
let mut scanned = 0usize;
let mut matches_found = 0usize;
while let Some(dir) = queue.pop_front() {
if cancelled.load(Ordering::Relaxed) {
return;
}
if follow_symlinks {
let key = dir.canonicalize().unwrap_or_else(|_| dir.clone());
if !visited.insert(key) {
continue;
}
}
scanned += 1;
if scanned.is_multiple_of(50) {
let _ = result_tx.send(QuickJumpMessage::Progress(scanned));
}
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
let mut subdirs = Vec::new();
for entry in entries.flatten() {
if cancelled.load(Ordering::Relaxed) {
return;
}
let path = entry.path();
if !follow_symlinks {
if let Ok(metadata) = std::fs::symlink_metadata(&path) {
if metadata.is_symlink() {
continue;
}
}
}
if !path.is_dir() {
continue;
}
if !show_hidden {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if crate::tree_node::is_hidden_name(name) {
continue;
}
}
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.to_lowercase().starts_with(prefix_lower) {
matches_found += 1;
let msg = if matches_found == 1 {
QuickJumpMessage::Found(path.clone())
} else {
QuickJumpMessage::MoreFound(path.clone())
};
let _ = result_tx.send(msg);
if matches_found >= MAX_MATCHES {
let _ = result_tx.send(QuickJumpMessage::Done);
return;
}
}
}
subdirs.push(path);
}
queue.extend(subdirs);
}
let _ = result_tx.send(QuickJumpMessage::Done);
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::time::Duration as StdDuration;
use tempfile::TempDir;
fn node(path: PathBuf, depth: usize) -> TreeNodeRef {
Rc::new(RefCell::new(
crate::tree_node::TreeNode::new(path, depth).unwrap(),
))
}
#[test]
fn activate_resets_buffer_and_state() {
let mut qj = QuickJump::new();
qj.buffer = "stale".to_string();
qj.has_match = false;
qj.activate();
assert!(qj.active);
assert_eq!(qj.buffer, "");
assert!(qj.has_match);
}
#[test]
fn push_segment_appends_slash_and_resets_match_state() {
let mut qj = QuickJump::new();
qj.buffer = "sr".to_string();
qj.matches = vec![PathBuf::from("/a"), PathBuf::from("/b")];
qj.current_index = 1;
qj.has_match = true;
let target = PathBuf::from("/a/src");
qj.push_segment(target.clone());
assert_eq!(qj.scan_stack, vec![target]);
assert_eq!(
qj.buffer, "sr/",
"buffer must NOT be cleared, only appended to"
);
assert!(qj.matches.is_empty());
assert_eq!(qj.current_index, 0);
assert!(qj.has_match);
assert!(!qj.is_scanning);
}
#[test]
fn push_segment_cancels_pending_and_in_flight_scan() {
let tmp = std::env::temp_dir().join("bmrk_test_quick_jump_push_segment_cancel");
std::fs::create_dir_all(&tmp).unwrap();
let mut qj = QuickJump::new();
qj.activate();
qj.start_scan(tmp.clone(), "zzz_no_match".to_string(), false, false);
assert!(qj.is_scanning);
qj.push_segment(tmp.clone());
assert!(!qj.is_scanning);
assert!(qj.pending_scan_at.is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn pop_segment_strips_trailing_slash_and_pops_stack() {
let mut qj = QuickJump::new();
qj.buffer = "alpha/".to_string();
qj.scan_stack = vec![PathBuf::from("/root/alpha")];
qj.matches = vec![PathBuf::from("/root/alpha/x")];
qj.current_index = 0;
let popped = qj.pop_segment();
assert_eq!(popped, Some(PathBuf::from("/root/alpha")));
assert_eq!(qj.buffer, "alpha");
assert!(qj.scan_stack.is_empty());
assert!(qj.matches.is_empty());
assert!(qj.has_match);
}
#[test]
fn pop_segment_is_noop_when_buffer_does_not_end_with_slash() {
let mut qj = QuickJump::new();
qj.buffer = "alpha/shared".to_string();
qj.scan_stack = vec![PathBuf::from("/root/alpha")];
assert_eq!(qj.pop_segment(), None);
assert_eq!(
qj.buffer, "alpha/shared",
"buffer must be untouched on a no-op pop"
);
assert_eq!(qj.scan_stack, vec![PathBuf::from("/root/alpha")]);
}
#[test]
fn activate_and_deactivate_clear_scan_stack() {
let mut qj = QuickJump::new();
qj.push_segment(PathBuf::from("/a/src"));
assert!(!qj.scan_stack.is_empty());
qj.activate();
assert!(
qj.scan_stack.is_empty(),
"a fresh Tab press must reset any prior scope"
);
qj.push_segment(PathBuf::from("/a/src"));
qj.deactivate();
assert!(
qj.scan_stack.is_empty(),
"leaving quick jump must reset the scope"
);
}
#[test]
fn deactivate_clears_buffer_and_active_flag() {
let mut qj = QuickJump::new();
qj.activate();
qj.add_char('a');
qj.add_char('b');
qj.deactivate();
assert!(!qj.active);
assert_eq!(qj.buffer, "");
}
#[test]
fn add_char_and_backspace_round_trip() {
let mut qj = QuickJump::new();
qj.add_char('s');
qj.add_char('r');
qj.add_char('c');
assert_eq!(qj.buffer, "src");
qj.backspace();
assert_eq!(qj.buffer, "sr");
qj.backspace();
qj.backspace();
assert_eq!(qj.buffer, "");
qj.backspace();
assert_eq!(qj.buffer, "");
}
#[test]
fn phase1_finds_direct_child_by_prefix() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let child_path = tmp.path().join("documents");
std::fs::create_dir(&child_path).unwrap();
root.borrow_mut().children.push(node(child_path.clone(), 1));
let matches = find_in_loaded_nodes(&root, "doc", false);
assert_eq!(matches, vec![child_path]);
}
#[test]
fn phase1_prefix_does_not_match_substring() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let child_path = tmp.path().join("xdocs");
std::fs::create_dir(&child_path).unwrap();
root.borrow_mut().children.push(node(child_path, 1));
let matches = find_in_loaded_nodes(&root, "docs", false);
assert!(matches.is_empty());
}
#[test]
fn phase1_lists_multiple_matches_beyond_the_first() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let doc1 = tmp.path().join("documents");
std::fs::create_dir(&doc1).unwrap();
root.borrow_mut().children.push(node(doc1.clone(), 1));
let doc2 = tmp.path().join("docs_backup");
std::fs::create_dir(&doc2).unwrap();
root.borrow_mut().children.push(node(doc2, 1));
let doc3 = tmp.path().join("docker");
std::fs::create_dir(&doc3).unwrap();
root.borrow_mut().children.push(node(doc3, 1));
let other = tmp.path().join("alpha");
std::fs::create_dir(&other).unwrap();
root.borrow_mut().children.push(node(other, 1));
let matches = find_in_loaded_nodes(&root, "doc", false);
assert_eq!(
matches.first(),
Some(&doc1),
"shallowest/first-encountered match wins the jump"
);
assert_eq!(matches.len(), 3, "all three 'doc*' folders must be listed");
}
#[test]
fn phase1_returns_shallowest_match_first() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let a_path = tmp.path().join("a");
std::fs::create_dir(&a_path).unwrap();
let a = node(a_path, 1);
let deep_path = a.borrow().path.join("match_deep");
std::fs::create_dir(&deep_path).unwrap();
a.borrow_mut().children.push(node(deep_path, 2));
root.borrow_mut().children.push(a);
let shallow_path = tmp.path().join("match_shallow");
std::fs::create_dir(&shallow_path).unwrap();
root.borrow_mut()
.children
.push(node(shallow_path.clone(), 1));
let matches = find_in_loaded_nodes(&root, "match", false);
assert_eq!(matches.first(), Some(&shallow_path));
assert_eq!(matches.len(), 2);
}
#[test]
fn phase1_skips_hidden_folders_and_their_children() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let hidden_path = tmp.path().join(".docs");
std::fs::create_dir(&hidden_path).unwrap();
let hidden = node(hidden_path, 1);
let hidden_child_path = hidden.borrow().path.join("docs_inner");
std::fs::create_dir(&hidden_child_path).unwrap();
hidden
.borrow_mut()
.children
.push(node(hidden_child_path, 2));
root.borrow_mut().children.push(hidden);
assert!(find_in_loaded_nodes(&root, "doc", false).is_empty());
assert!(find_in_loaded_nodes(&root, "docs_inner", false).is_empty());
}
#[test]
fn phase1_matches_hidden_folders_when_show_hidden_true() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let hidden_path = tmp.path().join(".docs");
std::fs::create_dir(&hidden_path).unwrap();
root.borrow_mut()
.children
.push(node(hidden_path.clone(), 1));
let matches = find_in_loaded_nodes(&root, ".doc", true);
assert_eq!(matches, vec![hidden_path]);
}
#[test]
fn phase1_does_not_match_files() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let file_path = tmp.path().join("document.txt");
std::fs::write(&file_path, b"hi").unwrap();
root.borrow_mut().children.push(node(file_path, 1));
assert!(find_in_loaded_nodes(&root, "doc", false).is_empty());
}
#[test]
fn phase1_finds_children_loaded_but_currently_collapsed() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let a_path = tmp.path().join("a");
std::fs::create_dir(&a_path).unwrap();
let a = node(a_path, 1);
let match_path = a.borrow().path.join("matchme");
std::fs::create_dir(&match_path).unwrap();
a.borrow_mut().children.push(node(match_path.clone(), 2));
assert!(!a.borrow().is_expanded);
root.borrow_mut().children.push(a);
let matches = find_in_loaded_nodes(&root, "match", false);
assert_eq!(matches, vec![match_path]);
}
#[test]
fn phase1_empty_prefix_returns_none() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let child_path = tmp.path().join("anything");
std::fs::create_dir(&child_path).unwrap();
root.borrow_mut().children.push(node(child_path, 1));
assert!(find_in_loaded_nodes(&root, "", false).is_empty());
}
#[test]
fn phase1_caps_matches_at_max_matches() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
for i in 0..(MAX_MATCHES + 5) {
let path = tmp.path().join(format!("doc{i:02}"));
std::fs::create_dir(&path).unwrap();
root.borrow_mut().children.push(node(path, 1));
}
let matches = find_in_loaded_nodes(&root, "doc", false);
assert_eq!(matches.len(), MAX_MATCHES);
}
#[test]
fn find_node_by_path_returns_root_itself() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let found = find_node_by_path(&root, tmp.path());
assert!(found.is_some());
assert_eq!(found.unwrap().borrow().path, tmp.path());
}
#[test]
fn find_node_by_path_finds_nested_child() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let a_path = tmp.path().join("a");
std::fs::create_dir(&a_path).unwrap();
let a = node(a_path.clone(), 1);
let b_path = a_path.join("b");
std::fs::create_dir(&b_path).unwrap();
a.borrow_mut().children.push(node(b_path.clone(), 2));
root.borrow_mut().children.push(a);
let found = find_node_by_path(&root, &b_path);
assert!(found.is_some());
assert_eq!(found.unwrap().borrow().path, b_path);
}
#[test]
fn find_node_by_path_returns_none_outside_subtree() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().join("root"), 0);
let unrelated = tmp.path().join("elsewhere");
assert!(find_node_by_path(&root, &unrelated).is_none());
}
#[test]
fn find_node_by_path_returns_none_when_not_yet_loaded() {
let tmp = TempDir::new().unwrap();
let root = node(tmp.path().to_path_buf(), 0);
let a_path = tmp.path().join("a");
std::fs::create_dir(&a_path).unwrap();
let deep_target = a_path.join("not_loaded");
assert!(find_node_by_path(&root, &deep_target).is_none());
}
#[test]
fn cycle_next_wraps_around_and_updates_index() {
let mut qj = QuickJump::new();
qj.matches = vec![
PathBuf::from("/a"),
PathBuf::from("/b"),
PathBuf::from("/c"),
];
qj.current_index = 0;
assert_eq!(qj.cycle_next(), Some(PathBuf::from("/b")));
assert_eq!(qj.current_index, 1);
assert_eq!(qj.cycle_next(), Some(PathBuf::from("/c")));
assert_eq!(qj.current_index, 2);
assert_eq!(
qj.cycle_next(),
Some(PathBuf::from("/a")),
"cycling past the last match must wrap back to the first"
);
assert_eq!(qj.current_index, 0);
}
#[test]
fn cycle_next_returns_none_with_fewer_than_two_matches() {
let mut qj = QuickJump::new();
assert_eq!(qj.cycle_next(), None);
qj.matches = vec![PathBuf::from("/a")];
assert_eq!(qj.cycle_next(), None);
}
#[test]
fn cancel_scan_does_not_block() {
let tmp = std::env::temp_dir().join("bmrk_test_quick_jump_cancel");
std::fs::create_dir_all(&tmp).unwrap();
let mut qj = QuickJump::new();
qj.activate();
qj.start_scan(tmp.clone(), "zzz_no_match".to_string(), false, false);
std::thread::sleep(StdDuration::from_millis(10));
let start = Instant::now();
qj.cancel_scan();
let elapsed = start.elapsed();
assert!(
elapsed < StdDuration::from_millis(50),
"cancel_scan() took too long: {:?}",
elapsed
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn rapid_scan_restart_does_not_hang() {
let tmp = std::env::temp_dir().join("bmrk_test_quick_jump_rapid");
std::fs::create_dir_all(&tmp).unwrap();
let mut qj = QuickJump::new();
qj.activate();
let start = Instant::now();
for i in 0..10 {
qj.start_scan(tmp.clone(), format!("prefix{}", i), false, false);
std::thread::sleep(StdDuration::from_millis(5));
}
let elapsed = start.elapsed();
assert!(
elapsed < StdDuration::from_secs(1),
"Rapid scan restarts took too long: {:?}",
elapsed
);
qj.cancel_scan();
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn phase2_finds_match_via_poll() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("target_folder");
std::fs::create_dir(&target).unwrap();
let mut qj = QuickJump::new();
qj.activate();
qj.buffer = "target".to_string();
qj.start_scan(tmp.path().to_path_buf(), "target".to_string(), false, false);
let start = Instant::now();
let mut found = None;
while start.elapsed() < StdDuration::from_secs(5) {
let (_, jump_to) = qj.tick(tmp.path(), false, false);
if jump_to.is_some() {
found = jump_to;
break;
}
std::thread::sleep(StdDuration::from_millis(10));
}
assert_eq!(found, Some(target));
assert!(qj.has_match);
}
#[test]
fn phase2_collects_further_matches_after_the_first() {
let tmp = TempDir::new().unwrap();
let doc1 = tmp.path().join("documents");
let doc2 = tmp.path().join("docs_backup");
let doc3 = tmp.path().join("docker");
std::fs::create_dir(&doc1).unwrap();
std::fs::create_dir(&doc2).unwrap();
std::fs::create_dir(&doc3).unwrap();
let mut qj = QuickJump::new();
qj.activate();
qj.buffer = "doc".to_string();
qj.start_scan(tmp.path().to_path_buf(), "doc".to_string(), false, false);
let start = Instant::now();
while qj.is_scanning && start.elapsed() < StdDuration::from_secs(5) {
qj.tick(tmp.path(), false, false);
std::thread::sleep(StdDuration::from_millis(10));
}
assert!(!qj.is_scanning);
assert!(qj.has_match);
assert_eq!(
qj.matches.len(),
3,
"scan must keep going past the first match to collect the rest, up to the cap"
);
let mut sorted = qj.matches.clone();
sorted.sort();
let mut expected = vec![doc1, doc2, doc3];
expected.sort();
assert_eq!(sorted, expected);
}
#[test]
fn phase2_not_found_sets_has_match_false() {
let tmp = TempDir::new().unwrap();
let mut qj = QuickJump::new();
qj.activate();
qj.buffer = "zzz_does_not_exist".to_string();
qj.start_scan(
tmp.path().to_path_buf(),
"zzz_does_not_exist".to_string(),
false,
false,
);
let start = Instant::now();
while qj.is_scanning && start.elapsed() < StdDuration::from_secs(5) {
qj.tick(tmp.path(), false, false);
std::thread::sleep(StdDuration::from_millis(10));
}
assert!(!qj.is_scanning);
assert!(!qj.has_match);
}
#[test]
fn active_prefix_strips_locked_segments() {
let mut qj = QuickJump::new();
qj.buffer = "abc".to_string();
assert_eq!(
qj.active_prefix(),
"abc",
"no locked segment: the whole buffer is the prefix"
);
qj.buffer = "alpha/".to_string();
assert_eq!(
qj.active_prefix(),
"",
"immediately after '/' with nothing typed yet: prefix is empty"
);
qj.buffer = "alpha/sh".to_string();
assert_eq!(qj.active_prefix(), "sh", "one locked segment");
qj.buffer = "alpha/beta/sh".to_string();
assert_eq!(
qj.active_prefix(),
"sh",
"multiple locked segments: only the text after the last one is the prefix"
);
qj.buffer = "ALPHA/SH".to_string();
assert_eq!(qj.active_prefix(), "sh", "prefix is lowercased");
}
#[test]
fn phase2_scan_prefix_excludes_locked_segments() {
let tmp = TempDir::new().unwrap();
let alpha = tmp.path().join("alpha");
let shared = alpha.join("shared");
std::fs::create_dir_all(&shared).unwrap();
let mut qj = QuickJump::new();
qj.activate();
qj.buffer = "alpha".to_string();
qj.push_segment(alpha.clone());
assert_eq!(qj.buffer, "alpha/");
qj.add_char('s');
qj.add_char('h');
assert_eq!(qj.buffer, "alpha/sh");
assert_eq!(
qj.active_prefix(),
"sh",
"the locked 'alpha/' segment must not leak into the search prefix"
);
qj.schedule_scan();
let start = Instant::now();
let mut found = None;
while start.elapsed() < StdDuration::from_secs(5) {
let (_, jump_to) = qj.tick(tmp.path(), false, false);
if jump_to.is_some() {
found = jump_to;
break;
}
if !qj.is_scanning && qj.pending_scan_at.is_none() {
break;
}
std::thread::sleep(StdDuration::from_millis(10));
}
assert_eq!(
found,
Some(shared),
"Phase 2 must find 'shared' under the locked 'alpha' scope using the stripped prefix"
);
assert!(qj.has_match);
}
#[test]
fn symlink_cycle_does_not_hang() {
let test_dir = std::env::temp_dir().join("bmrk_test_quick_jump_symlink_cycle");
let _ = std::fs::remove_dir_all(&test_dir);
std::fs::create_dir_all(&test_dir).unwrap();
let dir_a = test_dir.join("dir_a");
std::fs::create_dir_all(&dir_a).unwrap();
let dir_b = test_dir.join("dir_b");
#[cfg(unix)]
let r1 = std::os::unix::fs::symlink(&dir_a, &dir_b);
#[cfg(windows)]
let r1 = std::os::windows::fs::symlink_dir(&dir_a, &dir_b);
#[cfg(not(any(unix, windows)))]
let r1: std::io::Result<()> = Err(std::io::Error::other("unsupported platform"));
if r1.is_err() {
eprintln!(
"symlink_cycle_does_not_hang: SKIPPED (symlink creation failed: {:?})",
r1
);
let _ = std::fs::remove_dir_all(&test_dir);
return;
}
let link_in_a = dir_a.join("link_to_b");
#[cfg(unix)]
let r2 = std::os::unix::fs::symlink(&dir_b, &link_in_a);
#[cfg(windows)]
let r2 = std::os::windows::fs::symlink_dir(&dir_b, &link_in_a);
#[cfg(not(any(unix, windows)))]
let r2: std::io::Result<()> = Err(std::io::Error::other("unsupported platform"));
if r2.is_err() {
eprintln!(
"symlink_cycle_does_not_hang: SKIPPED (second symlink failed: {:?})",
r2
);
let _ = std::fs::remove_dir_all(&test_dir);
return;
}
let mut qj = QuickJump::new();
qj.activate();
qj.buffer = "zzz_no_match".to_string();
qj.start_scan(
test_dir.clone(),
"zzz_no_match".to_string(),
true,
true, );
let start = Instant::now();
while qj.is_scanning && start.elapsed() < StdDuration::from_secs(5) {
qj.tick(&test_dir, true, true);
std::thread::sleep(StdDuration::from_millis(20));
}
let elapsed = start.elapsed();
assert!(
!qj.is_scanning,
"Scan did not complete — likely infinite loop in cyclic symlinks"
);
assert!(
elapsed < StdDuration::from_secs(5),
"Scan took too long ({:?}): possible infinite loop",
elapsed
);
let _ = std::fs::remove_dir_all(&test_dir);
}
}