mirror of
https://github.com/openai/codex.git
synced 2026-05-03 12:52:11 +03:00
feat(tui2): copy tui crate and normalize snapshots (#7833)
Introduce a full codex-tui source snapshot under the new codex-tui2 crate so viewport work can be replayed in isolation. This change copies the entire codex-rs/tui/src tree into codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep future diffs vs the original viewport bookmark easy to reason about. The goal is for codex-tui2 to render identically to the existing TUI behind the `features.tui2` flag while we gradually port the viewport/history commits from the joshka/viewport bookmark onto this forked tree. While on this baseline change, we also ran the codex-tui2 snapshot test suite and accepted all insta snapshots for the new crate, so the snapshot files now use the codex-tui2 naming scheme and encode the unmodified legacy TUI behavior. This keeps later viewport commits focused on intentional behavior changes (and their snapshots) rather than on mechanical snapshot renames.
This commit is contained in:
199
codex-rs/tui2/src/file_search.rs
Normal file
199
codex-rs/tui2/src/file_search.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! Helper that owns the debounce/cancellation logic 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.
|
||||
|
||||
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;
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
impl FileSearchManager {
|
||||
pub fn new(search_dir: PathBuf, tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(SearchState {
|
||||
latest_query: String::new(),
|
||||
is_search_scheduled: false,
|
||||
active_search: None,
|
||||
})),
|
||||
search_dir,
|
||||
app_tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
// 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 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user