refactor(tui): group completion search state
Introduce a small SearchContext for workspace completion walks so the related search state moves together and the helper signatures no longer need too-many-arguments allowances. Also keeps the nearby hotbar test clippy-clean under the all-targets TUI gate. Modified harvest from PR #3128 by @Hmbown. Co-authored-by: Hmbown <101357273+Hmbown@users.noreply.github.com>
This commit is contained in:
@@ -3340,8 +3340,10 @@ fn hotbar_dispatches_bound_slot_and_ignores_empty_slot() {
|
||||
"mode-changing hotbar actions should leave the app ready to redraw"
|
||||
);
|
||||
|
||||
let mut empty_config = Config::default();
|
||||
empty_config.hotbar = Some(Vec::new());
|
||||
let empty_config = Config {
|
||||
hotbar: Some(Vec::new()),
|
||||
..Config::default()
|
||||
};
|
||||
assert_eq!(
|
||||
dispatch_hotbar_slot(&mut app, &empty_config, 1).expect("empty slot is ok"),
|
||||
None
|
||||
|
||||
@@ -35,6 +35,33 @@ pub struct Workspace {
|
||||
completion_walk_depth: Option<usize>,
|
||||
}
|
||||
|
||||
struct SearchContext<'a> {
|
||||
needle: &'a str,
|
||||
limit: usize,
|
||||
prefix_hits: &'a mut Vec<String>,
|
||||
substring_hits: &'a mut Vec<String>,
|
||||
seen: &'a mut HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl SearchContext<'_> {
|
||||
fn is_full(&self) -> bool {
|
||||
self.prefix_hits.len() + self.substring_hits.len() >= self.limit
|
||||
}
|
||||
|
||||
fn remember(&mut self, path: PathBuf) -> bool {
|
||||
self.seen.insert(path)
|
||||
}
|
||||
|
||||
fn push_match(&mut self, candidate: String) {
|
||||
let lower = candidate.to_lowercase();
|
||||
if self.needle.is_empty() || lower.starts_with(self.needle) {
|
||||
self.prefix_hits.push(candidate);
|
||||
} else if lower.contains(self.needle) {
|
||||
self.substring_hits.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Construct a workspace anchored at `root`, capturing the process CWD as
|
||||
/// the secondary resolution pass. Convenience entry point intended for
|
||||
@@ -220,53 +247,32 @@ impl Workspace {
|
||||
// Walk the recorded cwd first when it diverges from the workspace
|
||||
// root, so cwd-relative entries appear ahead of duplicates surfaced by
|
||||
// the workspace walk.
|
||||
let cwd_diverges = self
|
||||
.cwd
|
||||
.as_deref()
|
||||
.map(|c| c != self.root.as_path())
|
||||
.unwrap_or(false);
|
||||
if cwd_diverges && let Some(cwd) = self.cwd.as_deref() {
|
||||
walk_for_completions(
|
||||
cwd,
|
||||
cwd,
|
||||
&needle,
|
||||
{
|
||||
let mut ctx = SearchContext {
|
||||
needle: &needle,
|
||||
limit,
|
||||
&mut prefix_hits,
|
||||
&mut substring_hits,
|
||||
&mut seen,
|
||||
self.completion_walk_depth,
|
||||
);
|
||||
prefix_hits: &mut prefix_hits,
|
||||
substring_hits: &mut substring_hits,
|
||||
seen: &mut seen,
|
||||
};
|
||||
|
||||
let cwd_diverges = self
|
||||
.cwd
|
||||
.as_deref()
|
||||
.map(|c| c != self.root.as_path())
|
||||
.unwrap_or(false);
|
||||
if cwd_diverges && let Some(cwd) = self.cwd.as_deref() {
|
||||
walk_for_completions(cwd, cwd, &mut ctx, self.completion_walk_depth);
|
||||
add_local_reference_completions(cwd, cwd, &mut ctx, self.completion_walk_depth);
|
||||
}
|
||||
walk_for_completions(&self.root, &self.root, &mut ctx, self.completion_walk_depth);
|
||||
add_local_reference_completions(
|
||||
cwd,
|
||||
cwd,
|
||||
&needle,
|
||||
limit,
|
||||
&mut prefix_hits,
|
||||
&mut substring_hits,
|
||||
&mut seen,
|
||||
&self.root,
|
||||
&self.root,
|
||||
&mut ctx,
|
||||
self.completion_walk_depth,
|
||||
);
|
||||
}
|
||||
walk_for_completions(
|
||||
&self.root,
|
||||
&self.root,
|
||||
&needle,
|
||||
limit,
|
||||
&mut prefix_hits,
|
||||
&mut substring_hits,
|
||||
&mut seen,
|
||||
self.completion_walk_depth,
|
||||
);
|
||||
add_local_reference_completions(
|
||||
&self.root,
|
||||
&self.root,
|
||||
&needle,
|
||||
limit,
|
||||
&mut prefix_hits,
|
||||
&mut substring_hits,
|
||||
&mut seen,
|
||||
self.completion_walk_depth,
|
||||
);
|
||||
|
||||
prefix_hits.sort();
|
||||
substring_hits.sort();
|
||||
@@ -402,15 +408,10 @@ fn discovery_walk_builder(root: &Path, max_depth: Option<usize>) -> WalkBuilder
|
||||
/// Walk the AI-tool dot-directories (`.deepseek/`, `.cursor/`, `.claude/`,
|
||||
/// `.agents/`) with gitignore disabled so their contents are discoverable
|
||||
/// even when the project's `.gitignore` / `.ignore` excludes them.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn walk_always_discoverable_dirs(
|
||||
walk_root: &Path,
|
||||
display_root: &Path,
|
||||
needle: &str,
|
||||
limit: usize,
|
||||
prefix_hits: &mut Vec<String>,
|
||||
substring_hits: &mut Vec<String>,
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
ctx: &mut SearchContext<'_>,
|
||||
max_depth: Option<usize>,
|
||||
) {
|
||||
for dir_name in DISCOVERY_ALWAYS_DIRS {
|
||||
@@ -428,7 +429,7 @@ fn walk_always_discoverable_dirs(
|
||||
builder.max_depth(Some(depth.saturating_sub(1)));
|
||||
}
|
||||
for entry in builder.build().flatten() {
|
||||
if prefix_hits.len() + substring_hits.len() >= limit {
|
||||
if ctx.is_full() {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
@@ -445,7 +446,7 @@ fn walk_always_discoverable_dirs(
|
||||
continue;
|
||||
}
|
||||
let abs = path.to_path_buf();
|
||||
if !seen.insert(abs) {
|
||||
if !ctx.remember(abs) {
|
||||
continue;
|
||||
}
|
||||
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
||||
@@ -454,31 +455,21 @@ fn walk_always_discoverable_dirs(
|
||||
} else {
|
||||
rel_str.clone()
|
||||
};
|
||||
let lower = candidate.to_lowercase();
|
||||
if needle.is_empty() || lower.starts_with(needle) {
|
||||
prefix_hits.push(candidate);
|
||||
} else if lower.contains(needle) {
|
||||
substring_hits.push(candidate);
|
||||
}
|
||||
ctx.push_match(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn walk_for_completions(
|
||||
walk_root: &Path,
|
||||
display_root: &Path,
|
||||
needle: &str,
|
||||
limit: usize,
|
||||
prefix_hits: &mut Vec<String>,
|
||||
substring_hits: &mut Vec<String>,
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
ctx: &mut SearchContext<'_>,
|
||||
max_depth: Option<usize>,
|
||||
) {
|
||||
let builder = discovery_walk_builder(walk_root, max_depth);
|
||||
|
||||
for entry in builder.build().flatten() {
|
||||
if prefix_hits.len() + substring_hits.len() >= limit {
|
||||
if ctx.is_full() {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
@@ -492,7 +483,7 @@ fn walk_for_completions(
|
||||
// Dedup across the (cwd, workspace) double-walk by absolute path; we
|
||||
// want the cwd-relative display when both walks see the same file.
|
||||
let abs = path.to_path_buf();
|
||||
if !seen.insert(abs) {
|
||||
if !ctx.remember(abs) {
|
||||
continue;
|
||||
}
|
||||
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
||||
@@ -501,62 +492,38 @@ fn walk_for_completions(
|
||||
} else {
|
||||
rel_str.clone()
|
||||
};
|
||||
let lower = candidate.to_lowercase();
|
||||
if needle.is_empty() || lower.starts_with(needle) {
|
||||
prefix_hits.push(candidate);
|
||||
} else if lower.contains(needle) {
|
||||
substring_hits.push(candidate);
|
||||
}
|
||||
ctx.push_match(candidate);
|
||||
}
|
||||
|
||||
// Also walk the AI-tool dot-directories with gitignore disabled so
|
||||
// `.deepseek/`, `.cursor/`, etc. are always discoverable.
|
||||
walk_always_discoverable_dirs(
|
||||
walk_root,
|
||||
display_root,
|
||||
needle,
|
||||
limit,
|
||||
prefix_hits,
|
||||
substring_hits,
|
||||
seen,
|
||||
max_depth,
|
||||
);
|
||||
walk_always_discoverable_dirs(walk_root, display_root, ctx, max_depth);
|
||||
}
|
||||
|
||||
const LOCAL_REFERENCE_SCAN_LIMIT: usize = 4096;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_local_reference_completions(
|
||||
root: &Path,
|
||||
display_root: &Path,
|
||||
needle: &str,
|
||||
limit: usize,
|
||||
prefix_hits: &mut Vec<String>,
|
||||
substring_hits: &mut Vec<String>,
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
ctx: &mut SearchContext<'_>,
|
||||
max_depth: Option<usize>,
|
||||
) {
|
||||
if !should_try_local_reference_completion(needle) {
|
||||
if !should_try_local_reference_completion(ctx.needle) {
|
||||
return;
|
||||
}
|
||||
|
||||
for path in local_reference_paths(root, LOCAL_REFERENCE_SCAN_LIMIT, max_depth) {
|
||||
if prefix_hits.len() + substring_hits.len() >= limit {
|
||||
if ctx.is_full() {
|
||||
break;
|
||||
}
|
||||
let Ok(rel) = path.strip_prefix(display_root) else {
|
||||
continue;
|
||||
};
|
||||
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
||||
if rel_str.is_empty() || !seen.insert(path.clone()) {
|
||||
if rel_str.is_empty() || !ctx.remember(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
let lower = rel_str.to_lowercase();
|
||||
if needle.is_empty() || lower.starts_with(needle) {
|
||||
prefix_hits.push(rel_str);
|
||||
} else if lower.contains(needle) {
|
||||
substring_hits.push(rel_str);
|
||||
}
|
||||
ctx.push_match(rel_str);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user