file-search: improve file query perf (#9939)

switch nucleo-matcher for nucleo and use a "file search session" w/ live
updating query instead of a single hermetic run per query.
This commit is contained in:
Jeremy Rose
2026-01-28 10:54:43 -08:00
committed by GitHub
parent 35e03a0716
commit b8156706e6
10 changed files with 865 additions and 422 deletions
+1 -3
View File
@@ -1507,9 +1507,7 @@ impl App {
tui.frame_requester().schedule_frame();
}
AppEvent::StartFileSearch(query) => {
if !query.is_empty() {
self.file_search.on_user_query(query);
}
self.file_search.on_user_query(query);
}
AppEvent::FileSearchResult { query, matches } => {
self.chat_widget.apply_file_search_result(query, matches);
+29 -2
View File
@@ -2380,6 +2380,11 @@ impl ChatComposer {
// When browsing input history (shell-style Up/Down recall), skip all popup
// synchronization so nothing steals focus from continued history navigation.
if browsing_history {
if self.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.current_file_query = None;
}
self.active_popup = ActivePopup::None;
return;
}
@@ -2390,12 +2395,22 @@ impl ChatComposer {
self.sync_command_popup(allow_command_popup);
if matches!(self.active_popup, ActivePopup::Command(_)) {
if self.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.current_file_query = None;
}
self.dismissed_file_popup_token = None;
self.dismissed_skill_popup_token = None;
return;
}
if let Some(token) = skill_token {
if self.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.current_file_query = None;
}
self.sync_skill_popup(token);
return;
}
@@ -2406,6 +2421,11 @@ impl ChatComposer {
return;
}
if self.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.current_file_query = None;
}
self.dismissed_file_popup_token = None;
if matches!(
self.active_popup,
@@ -2539,7 +2559,10 @@ impl ChatComposer {
return;
}
if !query.is_empty() {
if query.is_empty() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
} else {
self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone()));
}
@@ -2563,7 +2586,11 @@ impl ChatComposer {
}
}
self.current_file_query = Some(query);
if query.is_empty() {
self.current_file_query = None;
} else {
self.current_file_query = Some(query);
}
self.dismissed_file_popup_token = None;
}
@@ -43,18 +43,10 @@ impl FileSearchPopup {
return;
}
// Determine if current matches are still relevant.
let keep_existing = query.starts_with(&self.display_query);
self.pending_query.clear();
self.pending_query.push_str(query);
self.waiting = true; // waiting for new results
if !keep_existing {
self.matches.clear();
self.state.reset();
}
}
/// Put the popup into an "idle" state used for an empty query (just "@").
+81 -152
View File
@@ -1,32 +1,15 @@
//! Helper that owns the debounce/cancellation logic for `@` file searches.
//! Session-based orchestration for `@` file searches.
//!
//! `ChatComposer` publishes *every* change of the `@token` as
//! `AppEvent::StartFileSearch(query)`.
//! This struct receives those events and decides when to actually spawn the
//! expensive search (handled in the main `App` thread). It tries to ensure:
//!
//! - Even when the user types long text quickly, they will start seeing results
//! after a short delay using an early version of what they typed.
//! - At most one search is in-flight at any time.
//!
//! It works as follows:
//!
//! 1. First query starts a debounce timer.
//! 2. While the timer is pending, the latest query from the user is stored.
//! 3. When the timer fires, it is cleared, and a search is done for the most
//! recent query.
//! 4. If there is a in-flight search that is not a prefix of the latest thing
//! the user typed, it is cancelled.
//! `ChatComposer` publishes every change of the `@token` as
//! `AppEvent::StartFileSearch(query)`. This manager owns a single
//! `codex-file-search` session for the current search root, updates the query
//! on every keystroke, and drops the session when the query becomes empty.
use codex_file_search as file_search;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::thread;
use std::time::Duration;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -34,35 +17,16 @@ use crate::app_event_sender::AppEventSender;
const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(20).unwrap();
const NUM_FILE_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap();
/// How long to wait after a keystroke before firing the first search when none
/// is currently running. Keeps early queries more meaningful.
const FILE_SEARCH_DEBOUNCE: Duration = Duration::from_millis(100);
const ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL: Duration = Duration::from_millis(20);
/// State machine for file-search orchestration.
pub(crate) struct FileSearchManager {
/// Unified state guarded by one mutex.
state: Arc<Mutex<SearchState>>,
search_dir: PathBuf,
app_tx: AppEventSender,
}
struct SearchState {
/// Latest query typed by user (updated every keystroke).
latest_query: String,
/// true if a search is currently scheduled.
is_search_scheduled: bool,
/// If there is an active search, this will be the query being searched.
active_search: Option<ActiveSearch>,
}
struct ActiveSearch {
query: String,
cancellation_token: Arc<AtomicBool>,
session: Option<file_search::FileSearchSession>,
session_token: usize,
}
impl FileSearchManager {
@@ -70,8 +34,8 @@ impl FileSearchManager {
Self {
state: Arc::new(Mutex::new(SearchState {
latest_query: String::new(),
is_search_scheduled: false,
active_search: None,
session: None,
session_token: 0,
})),
search_dir,
app_tx: tx,
@@ -80,120 +44,85 @@ impl FileSearchManager {
/// Call whenever the user edits the `@` token.
pub fn on_user_query(&self, query: String) {
{
#[expect(clippy::unwrap_used)]
let mut st = self.state.lock().unwrap();
if query == st.latest_query {
// No change, nothing to do.
return;
}
#[expect(clippy::unwrap_used)]
let mut st = self.state.lock().unwrap();
if query == st.latest_query {
return;
}
st.latest_query.clear();
st.latest_query.push_str(&query);
// Update latest query.
st.latest_query.clear();
st.latest_query.push_str(&query);
// If there is an in-flight search that is definitely obsolete,
// cancel it now.
if let Some(active_search) = &st.active_search
&& !query.starts_with(&active_search.query)
{
active_search
.cancellation_token
.store(true, Ordering::Relaxed);
st.active_search = None;
}
// Schedule a search to run after debounce.
if !st.is_search_scheduled {
st.is_search_scheduled = true;
} else {
return;
}
if query.is_empty() {
st.session.take();
return;
}
// If we are here, we set `st.is_search_scheduled = true` before
// dropping the lock. This means we are the only thread that can spawn a
// debounce timer.
let state = self.state.clone();
let search_dir = self.search_dir.clone();
let tx_clone = self.app_tx.clone();
thread::spawn(move || {
// Always do a minimum debounce, but then poll until the
// `active_search` is cleared.
thread::sleep(FILE_SEARCH_DEBOUNCE);
loop {
#[expect(clippy::unwrap_used)]
if state.lock().unwrap().active_search.is_none() {
break;
}
thread::sleep(ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL);
}
// The debounce timer has expired, so start a search using the
// latest query.
let cancellation_token = Arc::new(AtomicBool::new(false));
let token = cancellation_token.clone();
let query = {
#[expect(clippy::unwrap_used)]
let mut st = state.lock().unwrap();
let query = st.latest_query.clone();
st.is_search_scheduled = false;
st.active_search = Some(ActiveSearch {
query: query.clone(),
cancellation_token: token,
});
query
};
FileSearchManager::spawn_file_search(
query,
search_dir,
tx_clone,
cancellation_token,
state,
);
});
if st.session.is_none() {
self.start_session_locked(&mut st);
}
if let Some(session) = st.session.as_ref() {
session.update_query(&query);
}
}
fn spawn_file_search(
query: String,
search_dir: PathBuf,
tx: AppEventSender,
cancellation_token: Arc<AtomicBool>,
search_state: Arc<Mutex<SearchState>>,
) {
let compute_indices = true;
std::thread::spawn(move || {
let matches = file_search::run(
&query,
MAX_FILE_SEARCH_RESULTS,
&search_dir,
Vec::new(),
NUM_FILE_SEARCH_THREADS,
cancellation_token.clone(),
compute_indices,
true,
)
.map(|res| res.matches)
.unwrap_or_default();
let is_cancelled = cancellation_token.load(Ordering::Relaxed);
if !is_cancelled {
tx.send(AppEvent::FileSearchResult { query, matches });
fn start_session_locked(&self, st: &mut SearchState) {
st.session_token = st.session_token.wrapping_add(1);
let session_token = st.session_token;
let reporter = Arc::new(TuiSessionReporter {
state: self.state.clone(),
app_tx: self.app_tx.clone(),
session_token,
});
let session = file_search::create_session(
&self.search_dir,
file_search::SessionOptions {
limit: MAX_FILE_SEARCH_RESULTS,
exclude: Vec::new(),
threads: NUM_FILE_SEARCH_THREADS,
compute_indices: true,
respect_gitignore: true,
},
reporter,
);
match session {
Ok(session) => st.session = Some(session),
Err(err) => {
tracing::warn!("file search session failed to start: {err}");
st.session = None;
}
}
}
}
// Reset the active search state. Do a pointer comparison to verify
// that we are clearing the ActiveSearch that corresponds to the
// cancellation token we were given.
{
#[expect(clippy::unwrap_used)]
let mut st = search_state.lock().unwrap();
if let Some(active_search) = &st.active_search
&& Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token)
{
st.active_search = None;
}
}
struct TuiSessionReporter {
state: Arc<Mutex<SearchState>>,
app_tx: AppEventSender,
session_token: usize,
}
impl TuiSessionReporter {
fn send_snapshot(&self, snapshot: &file_search::FileSearchSnapshot) {
#[expect(clippy::unwrap_used)]
let st = self.state.lock().unwrap();
if st.session_token != self.session_token
|| st.latest_query.is_empty()
|| snapshot.query.is_empty()
{
return;
}
let query = snapshot.query.clone();
drop(st);
self.app_tx.send(AppEvent::FileSearchResult {
query,
matches: snapshot.matches.clone(),
});
}
}
impl file_search::SessionReporter for TuiSessionReporter {
fn on_update(&self, snapshot: &file_search::FileSearchSnapshot) {
self.send_snapshot(snapshot);
}
fn on_complete(&self) {}
}