use std::cell::RefCell;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ignore::WalkBuilder;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Paragraph, Widget},
};
use crate::localization::{Locale, MessageId, tr};
use crate::palette;
use crate::tui::menu_style;
use crate::tui::views::{
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
render_panel_scroll_rail, render_underwater_surface,
};
use crate::workspace_discovery::{DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery};
const MAX_CANDIDATES: usize = 20_000;
const MAX_RESCAN_HITS: usize = 512;
#[cfg(test)]
const WALK_DEPTH: usize = 10;
const VISIBLE_ROWS: usize = 14;
const MODIFIED_BOOST: i32 = 360;
const MENTIONED_BOOST: i32 = 240;
const TOOL_BOOST: i32 = 160;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FilePickerRelevance {
modified: HashSet<String>,
mentioned: HashSet<String>,
tool: HashSet<String>,
}
impl FilePickerRelevance {
pub fn mark_modified(&mut self, path: impl Into<String>) {
let path = path.into();
if !path.is_empty() {
self.modified.insert(path);
}
}
pub fn mark_mentioned(&mut self, path: impl Into<String>) {
let path = path.into();
if !path.is_empty() {
self.mentioned.insert(path);
}
}
pub fn mark_tool(&mut self, path: impl Into<String>) {
let path = path.into();
if !path.is_empty() {
self.tool.insert(path);
}
}
fn boost_for(&self, path: &str) -> i32 {
let mut boost = 0;
if self.modified.contains(path) {
boost += MODIFIED_BOOST;
}
if self.mentioned.contains(path) {
boost += MENTIONED_BOOST;
}
if self.tool.contains(path) {
boost += TOOL_BOOST;
}
boost
}
fn markers_for(&self, path: &str) -> String {
let mut markers = String::with_capacity(3);
markers.push(if self.modified.contains(path) {
'M'
} else {
' '
});
markers.push(if self.mentioned.contains(path) {
'@'
} else {
' '
});
markers.push(if self.tool.contains(path) { 'T' } else { ' ' });
markers
}
}
pub struct FilePickerView {
candidates: Vec<String>,
relevance: FilePickerRelevance,
filtered: Vec<usize>,
query: String,
selected: usize,
scroll: usize,
last_row_hitboxes: RefCell<Vec<(u16, usize)>>,
locale: Locale,
is_loading: bool,
is_rescanning: bool,
loading_cell: Option<Arc<Mutex<Option<PickerScan>>>>,
workspace_root: PathBuf,
max_depth: Option<usize>,
index_truncated: bool,
rescan_query: Option<String>,
}
struct WorkspaceScan {
candidates: Vec<String>,
modified: Vec<String>,
truncated: bool,
}
enum PickerScan {
Initial(WorkspaceScan),
Targeted { query: String, hits: Vec<String> },
}
struct CandidateWalk {
paths: Vec<String>,
truncated: bool,
}
impl FilePickerView {
#[cfg(test)]
pub fn new_with_relevance(workspace_root: &Path, relevance: FilePickerRelevance) -> Self {
Self::new_with_relevance_and_depth(workspace_root, relevance, WALK_DEPTH, Locale::En)
}
pub fn new_with_relevance_and_depth(
workspace_root: &Path,
relevance: FilePickerRelevance,
walk_depth: usize,
locale: Locale,
) -> Self {
let max_depth = if walk_depth == 0 {
None
} else {
Some(walk_depth)
};
if tokio::runtime::Handle::try_current().is_err() {
let walk = collect_candidates_limited(workspace_root, max_depth, MAX_CANDIDATES);
let mut relevance = relevance;
for path in crate::tui::file_picker_relevance::modified_workspace_paths(workspace_root)
{
relevance.mark_modified(path);
}
let mut view = Self {
candidates: walk.paths,
relevance,
filtered: Vec::new(),
query: String::new(),
selected: 0,
scroll: 0,
last_row_hitboxes: RefCell::new(Vec::new()),
locale,
is_loading: false,
is_rescanning: false,
loading_cell: None,
workspace_root: workspace_root.to_path_buf(),
max_depth,
index_truncated: walk.truncated,
rescan_query: None,
};
view.refilter();
return view;
}
let loading_cell = Arc::new(Mutex::new(None));
let cell = loading_cell.clone();
let root = workspace_root.to_path_buf();
crate::utils::spawn_blocking_supervised("file-picker-scan", move || {
let walk = collect_candidates_limited(&root, max_depth, MAX_CANDIDATES);
let scan = PickerScan::Initial(WorkspaceScan {
candidates: walk.paths,
modified: crate::tui::file_picker_relevance::modified_workspace_paths(&root),
truncated: walk.truncated,
});
if let Ok(mut guard) = cell.lock() {
*guard = Some(scan);
}
});
let mut view = Self {
candidates: Vec::new(),
relevance,
filtered: Vec::new(),
query: String::new(),
selected: 0,
scroll: 0,
last_row_hitboxes: RefCell::new(Vec::new()),
locale,
is_loading: true,
is_rescanning: false,
loading_cell: Some(loading_cell),
workspace_root: workspace_root.to_path_buf(),
max_depth,
index_truncated: false,
rescan_query: None,
};
view.refilter();
view
}
#[cfg(test)]
fn from_preloaded(
workspace_root: &Path,
candidates: Vec<String>,
truncated: bool,
max_depth: Option<usize>,
) -> Self {
let mut view = Self {
candidates,
relevance: FilePickerRelevance::default(),
filtered: Vec::new(),
query: String::new(),
selected: 0,
scroll: 0,
last_row_hitboxes: RefCell::new(Vec::new()),
locale: Locale::En,
is_loading: false,
is_rescanning: false,
loading_cell: None,
workspace_root: workspace_root.to_path_buf(),
max_depth,
index_truncated: truncated,
rescan_query: None,
};
view.refilter();
view
}
fn poll_loading(&mut self) {
if !self.is_loading && !self.is_rescanning {
return;
}
let Some(cell) = self.loading_cell.take() else {
self.is_loading = false;
self.is_rescanning = false;
return;
};
let scan = cell.lock().ok().and_then(|mut guard| guard.take());
match scan {
Some(PickerScan::Initial(scan)) => {
self.candidates = scan.candidates;
self.index_truncated = scan.truncated;
for path in scan.modified {
self.relevance.mark_modified(path);
}
self.is_loading = false;
self.refilter();
}
Some(PickerScan::Targeted { query, hits }) => {
let current = self.query.trim().to_lowercase();
self.is_rescanning = false;
if current == query {
self.merge_rescan_hits(&query, hits);
} else {
self.maybe_rescan();
}
}
None => self.loading_cell = Some(cell),
}
}
fn refilter(&mut self) {
self.refilter_from_index();
self.maybe_rescan();
}
fn refilter_from_index(&mut self) {
let query = self.query.trim().to_lowercase();
let mut scored: Vec<(usize, i32, i32, i32)> = if query.is_empty() {
self.candidates
.iter()
.enumerate()
.map(|(idx, path)| {
let boost = self.relevance.boost_for(path);
(idx, boost, 0, boost)
})
.collect()
} else {
self.candidates
.iter()
.enumerate()
.filter_map(|(idx, path)| {
score(&query, path).map(|fuzzy| {
let boost = self.relevance.boost_for(path);
(idx, fuzzy + boost, fuzzy, boost)
})
})
.collect()
};
scored.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.2.cmp(&a.2))
.then_with(|| b.3.cmp(&a.3))
.then_with(|| self.candidates[a.0].len().cmp(&self.candidates[b.0].len()))
.then_with(|| self.candidates[a.0].cmp(&self.candidates[b.0]))
});
self.filtered = scored.into_iter().map(|(idx, _, _, _)| idx).collect();
if self.filtered.is_empty() {
self.selected = 0;
self.scroll = 0;
} else if self.selected >= self.filtered.len() {
self.selected = self.filtered.len() - 1;
}
self.adjust_scroll();
}
fn maybe_rescan(&mut self) {
if self.is_loading || self.is_rescanning || !self.index_truncated {
return;
}
if !self.filtered.is_empty() {
return;
}
let query = self.query.trim().to_lowercase();
if query.is_empty() {
return;
}
let specific_enough =
query.chars().count() >= 2 || query.contains('/') || query.contains('\\');
if !specific_enough {
return;
}
if self.rescan_query.as_deref() == Some(query.as_str()) {
return;
}
if tokio::runtime::Handle::try_current().is_err() {
let hits = collect_query_matches(
&self.workspace_root,
self.max_depth,
&query,
MAX_RESCAN_HITS,
);
self.merge_rescan_hits(&query, hits);
return;
}
self.is_rescanning = true;
let loading_cell = Arc::new(Mutex::new(None));
let cell = loading_cell.clone();
self.loading_cell = Some(loading_cell);
let root = self.workspace_root.clone();
let max_depth = self.max_depth;
let query_for_scan = query.clone();
crate::utils::spawn_blocking_supervised("file-picker-rescan", move || {
let hits = collect_query_matches(&root, max_depth, &query_for_scan, MAX_RESCAN_HITS);
if let Ok(mut guard) = cell.lock() {
*guard = Some(PickerScan::Targeted {
query: query_for_scan,
hits,
});
}
});
}
fn merge_rescan_hits(&mut self, query: &str, hits: Vec<String>) {
self.rescan_query = Some(query.to_string());
self.is_rescanning = false;
if !hits.is_empty() {
for hit in hits {
if !self.candidates.iter().any(|existing| existing == &hit) {
self.candidates.push(hit);
}
}
}
self.refilter_from_index();
}
fn adjust_scroll(&mut self) {
if self.filtered.is_empty() {
self.scroll = 0;
return;
}
if self.selected < self.scroll {
self.scroll = self.selected;
} else if self.selected >= self.scroll + VISIBLE_ROWS {
self.scroll = self.selected + 1 - VISIBLE_ROWS;
}
}
fn move_selection(&mut self, delta: isize) {
if self.filtered.is_empty() {
return;
}
self.selected = crate::tui::list_nav::wrap_index(self.selected, self.filtered.len(), delta);
self.adjust_scroll();
}
fn selected_path(&self) -> Option<&str> {
let idx = *self.filtered.get(self.selected)?;
self.candidates.get(idx).map(String::as_str)
}
#[cfg(test)]
pub fn visible_count(&self) -> usize {
self.filtered.len()
}
#[cfg(test)]
pub fn query(&self) -> &str {
&self.query
}
#[cfg(test)]
pub fn selected_for_test(&self) -> Option<&str> {
self.selected_path()
}
#[cfg(test)]
pub fn markers_for_test(&self, path: &str) -> String {
self.relevance.markers_for(path)
}
}
impl ModalView for FilePickerView {
fn kind(&self) -> ModalKind {
ModalKind::FilePicker
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => ViewAction::Close,
KeyCode::Enter => {
if let Some(path) = self.selected_path() {
let path = path.to_string();
return ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path });
}
ViewAction::Close
}
KeyCode::Up => {
self.move_selection(-1);
ViewAction::None
}
KeyCode::Down => {
self.move_selection(1);
ViewAction::None
}
KeyCode::PageUp => {
self.move_selection(-(VISIBLE_ROWS as isize));
ViewAction::None
}
KeyCode::PageDown => {
self.move_selection(VISIBLE_ROWS as isize);
ViewAction::None
}
KeyCode::Backspace => {
self.query.pop();
self.selected = 0;
self.scroll = 0;
self.refilter();
ViewAction::None
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.query.clear();
self.selected = 0;
self.scroll = 0;
self.refilter();
ViewAction::None
}
KeyCode::Char(ch)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT)
&& !ch.is_control() =>
{
self.query.push(ch);
self.selected = 0;
self.scroll = 0;
self.refilter();
ViewAction::None
}
_ => ViewAction::None,
}
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
match mouse.kind {
MouseEventKind::ScrollUp => {
self.move_selection(-1);
ViewAction::None
}
MouseEventKind::ScrollDown => {
self.move_selection(1);
ViewAction::None
}
MouseEventKind::Down(MouseButton::Left) => {
let hit = self
.last_row_hitboxes
.borrow()
.iter()
.find_map(|(y, idx)| (*y == mouse.row).then_some(*idx));
let Some(idx) = hit else {
return ViewAction::None;
};
if idx == self.selected {
if let Some(path) = self.selected_path() {
return ViewAction::EmitAndClose(ViewEvent::FilePickerSelected {
path: path.to_string(),
});
}
} else {
self.selected = idx;
self.adjust_scroll();
}
ViewAction::None
}
_ => ViewAction::None,
}
}
fn tick(&mut self) -> ViewAction {
self.poll_loading();
ViewAction::None
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let match_count = self.filtered.len();
let title = if match_count == 1 {
tr(self.locale, MessageId::FilePickerMatchSingular).into_owned()
} else {
tr(self.locale, MessageId::FilePickerMatchesPlural)
.replace("{count}", &match_count.to_string())
};
let inner = render_underwater_surface(area, buf, title);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("↑/↓", "move"),
ActionHint::new("Enter", "insert @path"),
ActionHint::new("Esc", "cancel"),
],
);
let visible = VISIBLE_ROWS.min(content.height.saturating_sub(2) as usize);
let content = render_panel_scroll_rail(
content,
buf,
self.filtered.len(),
self.scroll,
visible,
true,
);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(vec![
Span::styled("> ", Style::default().fg(palette::WHALE_INFO).bold()),
Span::raw(self.query.clone()),
Span::styled(
" ",
Style::default()
.fg(palette::WHALE_BG)
.bg(palette::WHALE_INFO),
),
]));
lines.push(Line::from(""));
let end = (self.scroll + visible).min(self.filtered.len());
self.last_row_hitboxes.borrow_mut().clear();
if self.is_loading || (self.is_rescanning && self.filtered.is_empty()) {
lines.push(Line::from(Span::styled(
format!(" {}", tr(self.locale, MessageId::FilePickerScanning)),
Style::default().fg(palette::TEXT_MUTED),
)));
} else if self.filtered.is_empty() {
lines.push(Line::from(Span::styled(
" No matches",
Style::default().fg(palette::TEXT_MUTED),
)));
} else {
for idx in self.scroll..end {
let path = &self.candidates[self.filtered[idx]];
let selected = idx == self.selected;
let style = if selected {
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let prefix = format!("{} ", crate::tui::glyphs::selection_marker(selected));
let marker_field = if content.width >= 18 {
format!("{} ", self.relevance.markers_for(path))
} else {
String::new()
};
let reserved = prefix.chars().count() + marker_field.chars().count();
let display =
truncate_path(path, (content.width as usize).saturating_sub(reserved));
let mut line = Line::from(format!("{prefix}{marker_field}{display}"));
line.style = style;
let y = content
.y
.saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX));
self.last_row_hitboxes.borrow_mut().push((y, idx));
lines.push(line);
}
}
Paragraph::new(lines)
.style(Style::default().fg(palette::TEXT_PRIMARY))
.render(content, buf);
}
}
fn truncate_path(path: &str, max: usize) -> String {
if max == 0 {
return String::new();
}
if path.chars().count() <= max {
return path.to_string();
}
let take = max.saturating_sub(1);
let truncated: String = path
.chars()
.rev()
.take(take)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("…{truncated}")
}
#[cfg(test)]
fn collect_candidates(root: &Path, max_depth: Option<usize>) -> Vec<String> {
collect_candidates_limited(root, max_depth, MAX_CANDIDATES).paths
}
fn collect_candidates_limited(
root: &Path,
max_depth: Option<usize>,
limit: usize,
) -> CandidateWalk {
let mut out: Vec<String> = Vec::new();
let mut truncated = push_matching_files(
MatchingFileWalk {
walk_root: root,
display_root: root,
max_depth,
honor_gitignore: true,
limit,
matches: &|_| true,
},
&mut out,
None,
);
if !truncated {
for dir in DISCOVERY_ALWAYS_DIRS {
let dot_dir = root.join(dir);
if !dot_dir.is_dir() {
continue;
}
truncated = push_matching_files(
MatchingFileWalk {
walk_root: &dot_dir,
display_root: root,
max_depth: max_depth.map(|d| d.saturating_sub(1)),
honor_gitignore: false,
limit,
matches: &|_| true,
},
&mut out,
None,
);
if truncated {
break;
}
}
}
out.sort();
CandidateWalk {
paths: out,
truncated,
}
}
fn collect_query_matches(
root: &Path,
max_depth: Option<usize>,
query: &str,
limit: usize,
) -> Vec<String> {
let query = query.trim();
if query.is_empty() || limit == 0 {
return Vec::new();
}
let needle = query.to_lowercase();
let matches = |path: &str| score(&needle, path).is_some();
let (start, depth) = targeted_walk_root(root, query, max_depth);
let mut out = Vec::new();
let mut seen = HashSet::new();
let under_always = always_dir_prefix(root, &start).is_some();
let hit_cap = push_matching_files(
MatchingFileWalk {
walk_root: &start,
display_root: root,
max_depth: depth,
honor_gitignore: !under_always,
limit,
matches: &matches,
},
&mut out,
Some(&mut seen),
);
if start.as_path() == root && !hit_cap {
for dir in DISCOVERY_ALWAYS_DIRS {
let dot_dir = root.join(dir);
if !dot_dir.is_dir() {
continue;
}
if push_matching_files(
MatchingFileWalk {
walk_root: &dot_dir,
display_root: root,
max_depth: max_depth.map(|d| d.saturating_sub(1)),
honor_gitignore: false,
limit,
matches: &matches,
},
&mut out,
Some(&mut seen),
) {
break;
}
}
}
out.sort();
out
}
fn targeted_walk_root(
root: &Path,
query: &str,
max_depth: Option<usize>,
) -> (PathBuf, Option<usize>) {
let normalized = query.replace('\\', "/");
let mut dir = root.to_path_buf();
let mut consumed = 0usize;
for component in normalized.split('/') {
if component.is_empty() || component == "." {
continue;
}
if component == ".." {
break;
}
let next = dir.join(component);
if next.is_dir() {
dir = next;
consumed += 1;
} else {
break;
}
}
(dir, max_depth.map(|depth| depth.saturating_sub(consumed)))
}
fn always_dir_prefix(root: &Path, path: &Path) -> Option<&'static str> {
DISCOVERY_ALWAYS_DIRS.iter().copied().find(|dir| {
let always = root.join(dir);
path == always || path.starts_with(&always)
})
}
struct MatchingFileWalk<'a> {
walk_root: &'a Path,
display_root: &'a Path,
max_depth: Option<usize>,
honor_gitignore: bool,
limit: usize,
matches: &'a dyn Fn(&str) -> bool,
}
fn push_matching_files(
walk: MatchingFileWalk<'_>,
out: &mut Vec<String>,
mut seen: Option<&mut HashSet<String>>,
) -> bool {
let MatchingFileWalk {
walk_root,
display_root,
max_depth,
honor_gitignore,
limit,
matches,
} = walk;
if limit == 0 || out.len() >= limit {
return true;
}
let mut builder = WalkBuilder::new(walk_root);
builder
.hidden(true)
.follow_links(false)
.max_depth(max_depth);
if honor_gitignore {
builder.git_ignore(true).git_exclude(true).git_global(true);
} else {
builder.git_ignore(false).ignore(false);
}
for entry in builder.build().flatten() {
if !honor_gitignore && path_is_excluded_from_discovery(display_root, entry.path()) {
continue;
}
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
let rel = path.strip_prefix(display_root).unwrap_or(path);
if rel.as_os_str().is_empty() {
continue;
}
let display = path_to_workspace_string(rel);
if display.is_empty() || !matches(&display) {
continue;
}
if let Some(seen) = seen.as_mut()
&& !seen.insert(display.clone())
{
continue;
}
out.push(display);
if out.len() >= limit {
return true;
}
}
false
}
fn path_to_workspace_string(path: &Path) -> String {
let mut out = String::new();
for (idx, comp) in path.components().enumerate() {
if idx > 0 {
out.push('/');
}
out.push_str(&comp.as_os_str().to_string_lossy());
}
out
}
pub fn score(query: &str, path: &str) -> Option<i32> {
if query.is_empty() {
return Some(0);
}
let q: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();
let p: Vec<char> = path.chars().flat_map(char::to_lowercase).collect();
if q.len() > p.len() {
return None;
}
let mut qi = 0usize;
let mut score: i32 = 0;
let mut last_match: Option<usize> = None;
let mut consecutive = 0i32;
for (i, ch) in p.iter().enumerate() {
if qi >= q.len() {
break;
}
if *ch == q[qi] {
if i == 0 {
score += 25;
if qi == 0 {
score += 10;
}
} else if matches!(p[i - 1], '/' | '_' | '-' | '.' | ' ') {
score += 25;
} else {
score += 1;
}
if last_match == Some(i.saturating_sub(1)) {
consecutive += 1;
score += 5 * consecutive;
} else {
consecutive = 0;
}
if let Some(prev) = last_match {
let gap = i - prev - 1;
score -= gap as i32;
}
last_match = Some(i);
qi += 1;
}
}
if qi == q.len() { Some(score) } else { None }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::Duration;
use tempfile::TempDir;
#[test]
fn score_subsequence_match() {
let a = score("main", "main.rs").unwrap();
let b = score("main", "src/very/deep/main.rs").unwrap();
assert!(a > b, "a={a} b={b}");
}
#[test]
fn score_rejects_non_subsequence() {
assert!(score("zzz", "main.rs").is_none());
assert!(score("xyz", "src/lib.rs").is_none());
}
#[test]
fn score_boundary_bonus_beats_substring() {
let boundary = score("fp", "src/file_picker.rs").unwrap();
let inline = score("fp", "src/filepicker.rs");
if let Some(inline_score) = inline {
assert!(
boundary > inline_score,
"boundary={boundary} inline={inline_score}"
);
}
}
#[test]
fn score_case_insensitive() {
assert!(score("MAIN", "main.rs").is_some());
assert!(score("main", "MAIN.RS").is_some());
}
#[test]
fn score_empty_query_returns_zero() {
assert_eq!(score("", "anything").unwrap(), 0);
}
#[test]
fn picker_typing_narrows_candidates() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "").unwrap();
fs::write(root.join("src/lib.rs"), "").unwrap();
fs::write(root.join("README.md"), "").unwrap();
fs::write(root.join("Cargo.toml"), "").unwrap();
let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
assert_eq!(view.visible_count(), 4, "expected all 4 candidates");
for ch in "main".chars() {
view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(view.query(), "main");
let visible = view.visible_count();
assert_eq!(visible, 1, "expected exactly 1 match for 'main'");
let selected = view.selected_for_test().expect("selected path");
assert!(selected.ends_with("main.rs"), "selected = {selected}");
}
#[test]
fn picker_empty_query_prioritizes_working_set_files() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "").unwrap();
fs::write(root.join("src/lib.rs"), "").unwrap();
fs::write(root.join("README.md"), "").unwrap();
let mut relevance = FilePickerRelevance::default();
relevance.mark_modified("src/lib.rs");
let view = FilePickerView::new_with_relevance(root, relevance);
assert_eq!(view.selected_for_test(), Some("src/lib.rs"));
assert_eq!(view.markers_for_test("src/lib.rs"), "M ");
}
#[test]
fn picker_fuzzy_query_keeps_working_set_boosts() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/alpha.rs"), "").unwrap();
fs::write(root.join("src/zeta.rs"), "").unwrap();
let mut relevance = FilePickerRelevance::default();
relevance.mark_mentioned("src/zeta.rs");
relevance.mark_tool("src/zeta.rs");
let mut view = FilePickerView::new_with_relevance(root, relevance);
for ch in "rs".chars() {
view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(view.selected_for_test(), Some("src/zeta.rs"));
assert_eq!(view.markers_for_test("src/zeta.rs"), " @T");
}
#[test]
fn picker_backspace_widens_candidates() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::write(root.join("a.txt"), "").unwrap();
fs::write(root.join("b.txt"), "").unwrap();
let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert_eq!(view.visible_count(), 1);
view.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(view.visible_count(), 2);
}
#[test]
fn picker_enter_emits_event() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::write(root.join("only.txt"), "").unwrap();
let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match action {
ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path }) => {
assert!(path.ends_with("only.txt"));
}
other => panic!("expected EmitAndClose(FilePickerSelected), got {other:?}"),
}
}
#[test]
fn picker_esc_closes_without_emit() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::write(root.join("only.txt"), "").unwrap();
let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(action, ViewAction::Close));
}
#[test]
fn picker_honors_gitignore() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::write(root.join(".ignore"), "skipme.txt\n").unwrap();
fs::write(root.join("keepme.txt"), "").unwrap();
fs::write(root.join("skipme.txt"), "").unwrap();
let view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default());
let visible: Vec<_> = view
.filtered
.iter()
.map(|i| view.candidates[*i].as_str())
.collect();
assert!(visible.iter().any(|p| p.ends_with("keepme.txt")));
assert!(
!visible.iter().any(|p| p.ends_with("skipme.txt")),
"skipme.txt should be filtered by .ignore: {visible:?}"
);
}
#[test]
fn picker_finds_deeply_nested_files_within_walk_depth() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let nested = root.join("a/b/c/d/e/f");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("deep.rs"), "deep").unwrap();
let deeper = root.join("a/b/c/d/e/f/g/h/i/j/k");
fs::create_dir_all(&deeper).unwrap();
fs::write(deeper.join("very_deep.rs"), "deeper").unwrap();
let shallow = collect_candidates(root, Some(6));
assert!(
!shallow.iter().any(|p| p == "a/b/c/d/e/f/deep.rs"),
"depth-6 cap should miss the depth-7 file: {shallow:?}"
);
let default = collect_candidates(root, Some(WALK_DEPTH));
assert!(
default.iter().any(|p| p == "a/b/c/d/e/f/deep.rs"),
"default walk depth should reach depth-7 files: {default:?}"
);
let unlimited = collect_candidates(root, None);
assert!(
unlimited
.iter()
.any(|p| p == "a/b/c/d/e/f/g/h/i/j/k/very_deep.rs"),
"unlimited walk should reach very deep files: {unlimited:?}"
);
}
#[test]
fn picker_skips_generated_worktree_bulk_inside_unignored_dot_dirs() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
fs::write(root.join(".deepseek/commands/build.md"), "build").unwrap();
fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/.git/objects")).unwrap();
fs::write(
root.join(".deepseek/snapshots/deadbeef/.git/objects/snapshot.pack"),
"pack",
)
.unwrap();
fs::create_dir_all(root.join(".claude/commands")).unwrap();
fs::write(root.join(".claude/commands/test.md"), "test").unwrap();
fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
fs::write(
root.join(".claude/worktrees/agent/src/agent-only.md"),
"agent",
)
.unwrap();
let candidates = collect_candidates(root, Some(WALK_DEPTH));
assert!(candidates.iter().any(|path| path == "src/main.rs"));
assert!(
candidates
.iter()
.any(|path| path == ".deepseek/commands/build.md"),
"normal .deepseek command files should stay discoverable: {candidates:?}",
);
assert!(
candidates
.iter()
.any(|path| path == ".claude/commands/test.md"),
"normal .claude command files should stay discoverable: {candidates:?}",
);
assert!(
candidates
.iter()
.all(|path| !path.starts_with(".deepseek/snapshots/")),
"snapshot side repo files must not enter picker candidates: {candidates:?}",
);
assert!(
candidates
.iter()
.all(|path| !path.starts_with(".claude/worktrees/")),
".claude worktree files must not enter picker candidates: {candidates:?}",
);
}
#[test]
fn collect_candidates_limited_stops_at_the_cap_and_flags_truncation() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join("pad")).unwrap();
for i in 0..30 {
fs::write(root.join("pad").join(format!("n{i:02}.txt")), "").unwrap();
}
let walk = collect_candidates_limited(root, Some(WALK_DEPTH), 12);
assert!(
walk.truncated,
"hitting the cap must mark the index incomplete"
);
assert_eq!(walk.paths.len(), 12);
assert!(
!collect_candidates_limited(root, Some(WALK_DEPTH), 64).truncated,
"a cap above the file count is a complete index"
);
}
#[test]
fn targeted_rescan_finds_a_file_the_candidate_cap_dropped() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join("pad")).unwrap();
for i in 0..40 {
fs::write(root.join("pad").join(format!("n{i:02}.txt")), "").unwrap();
}
fs::create_dir_all(root.join("zzz")).unwrap();
fs::write(root.join("zzz/room_chat_shell.dart"), "late").unwrap();
let walk = collect_candidates_limited(root, Some(WALK_DEPTH), 15);
assert!(walk.truncated);
let hits = collect_query_matches(root, Some(WALK_DEPTH), "room_chat_shell", 64);
assert!(
hits.iter().any(|path| path == "zzz/room_chat_shell.dart"),
"targeted rescan must recover the file past the cap: {hits:?}"
);
}
#[test]
fn targeted_walk_root_descends_into_an_existing_prefix() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join("src/nested")).unwrap();
fs::write(root.join("src/nested/hit.rs"), "").unwrap();
let (start, depth) = targeted_walk_root(root, "src/nested/hit", Some(10));
assert_eq!(start, root.join("src/nested"));
assert_eq!(depth, Some(8));
}
#[test]
fn picker_query_miss_rescans_a_truncated_index() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join("zzz")).unwrap();
fs::write(root.join("zzz/room_chat_shell.dart"), "late").unwrap();
let mut view = FilePickerView::from_preloaded(
root,
vec!["pad/n00.txt".into(), "pad/n01.txt".into()],
true,
Some(WALK_DEPTH),
);
assert_eq!(
view.visible_count(),
2,
"empty query shows the truncated index"
);
for ch in "room_chat_shell".chars() {
view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(
view.selected_for_test(),
Some("zzz/room_chat_shell.dart"),
"a miss against the truncated index must rescan and surface the file"
);
}
#[test]
fn picker_complete_index_miss_does_not_rescan() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::write(root.join("keep.txt"), "").unwrap();
fs::write(root.join("secret.txt"), "").unwrap();
let mut view =
FilePickerView::from_preloaded(root, vec!["keep.txt".into()], false, Some(WALK_DEPTH));
for ch in "secret".chars() {
view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(view.visible_count(), 0);
assert_eq!(view.candidates, vec!["keep.txt".to_string()]);
}
const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
#[test]
fn file_picker_is_usable_and_opaque_at_blocker_sizes() {
use crate::tui::views::ViewStack;
use ratatui::{buffer::Buffer, layout::Rect};
use unicode_width::UnicodeWidthStr;
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "").unwrap();
fs::write(root.join("src/lib.rs"), "").unwrap();
fs::write(root.join("README.md"), "").unwrap();
for (w, h) in BLOCKER_SIZES {
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
for y in 0..h {
for x in 0..w {
buf[(x, y)].set_symbol("X");
}
}
let mut stack = ViewStack::new();
stack.push(FilePickerView::new_with_relevance(
root,
FilePickerRelevance::default(),
));
stack.render(area, &mut buf);
let rows: Vec<String> = (0..h)
.map(|y| {
(0..w)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect();
let text = rows.join("\n");
for label in ["move", "insert @path", "cancel"] {
assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
}
assert!(
!text.contains('X'),
"{w}x{h}: background bleed-through into modal surface"
);
assert_eq!(
buf[(w / 2, h / 2)].bg,
palette::WHALE_BG,
"{w}x{h}: modal interior must be opaque"
);
for (y, row) in rows.iter().enumerate() {
assert!(
UnicodeWidthStr::width(row.trim_end()) <= w as usize,
"{w}x{h}: row {y} overflows width: {row:?}"
);
}
}
}
#[tokio::test]
async fn opening_the_picker_does_not_block_on_the_workspace_scan() {
let ws = TempDir::new().unwrap();
fs::create_dir_all(ws.path().join("src")).unwrap();
for i in 0..200 {
fs::write(ws.path().join("src").join(format!("f{i}.rs")), "x").unwrap();
}
let mut view = FilePickerView::new_with_relevance_and_depth(
ws.path(),
FilePickerRelevance::default(),
WALK_DEPTH,
Locale::En,
);
assert!(
view.is_loading,
"the constructor must hand back a paintable view, not a finished scan"
);
assert!(
view.candidates.is_empty(),
"no walk may have run on the calling thread"
);
let area = Rect::new(0, 0, 60, 20);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
for _ in 0..500 {
view.tick();
if !view.is_loading {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(!view.is_loading, "the background scan must land via tick");
assert_eq!(
view.candidates.len(),
200,
"every workspace file is discovered once the scan lands"
);
assert_eq!(
view.filtered.len(),
200,
"results are refiltered after the scan, not left empty"
);
}
#[tokio::test]
async fn a_query_typed_during_the_scan_is_applied_when_results_land() {
let ws = TempDir::new().unwrap();
fs::write(ws.path().join("alpha.rs"), "x").unwrap();
fs::write(ws.path().join("beta.rs"), "x").unwrap();
let mut view = FilePickerView::new_with_relevance_and_depth(
ws.path(),
FilePickerRelevance::default(),
WALK_DEPTH,
Locale::En,
);
assert!(view.is_loading);
for ch in "alpha".chars() {
view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
for _ in 0..500 {
view.tick();
if !view.is_loading {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(!view.is_loading);
assert_eq!(view.query, "alpha");
let matched: Vec<&str> = view
.filtered
.iter()
.map(|i| view.candidates[*i].as_str())
.collect();
assert_eq!(
matched,
vec!["alpha.rs"],
"the scan must refilter against the query the user already typed"
);
}
#[tokio::test]
async fn truncated_index_rescan_does_not_block_handle_key() {
let ws = TempDir::new().unwrap();
fs::write(ws.path().join("late_unique_file.rs"), "x").unwrap();
let mut view = FilePickerView::from_preloaded(
ws.path(),
vec!["unrelated.rs".into()],
true,
Some(WALK_DEPTH),
);
for ch in "late_unique_file".chars() {
view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert!(
view.is_rescanning
|| view
.candidates
.iter()
.any(|path| path == "late_unique_file.rs"),
"rescan must start off-thread (or already have merged on a tiny race)"
);
for _ in 0..500 {
view.tick();
if view
.candidates
.iter()
.any(|path| path == "late_unique_file.rs")
{
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert_eq!(view.selected_for_test(), Some("late_unique_file.rs"));
}
}