Codex Handbook
tui/src/bottom_pane/file_search_popup.rs 184 lines
use std::path::PathBuf;use codex_file_search::FileMatch;use ratatui::buffer::Buffer;use ratatui::layout::Rect;use ratatui::widgets::WidgetRef;use crate::render::Insets;use crate::render::RectExt;use super::popup_consts::MAX_POPUP_ROWS;use super::scroll_state::ScrollState;use super::selection_popup_common::GenericDisplayRow;use super::selection_popup_common::render_rows;/// Visual state for the file-search popup.pub(crate) struct FileSearchPopup {    /// Query corresponding to the `matches` currently shown.    display_query: String,    /// Latest query typed by the user. May differ from `display_query` when    /// a search is still in-flight.    pending_query: String,    /// When `true` we are still waiting for results for `pending_query`.    waiting: bool,    /// Cached matches; paths relative to the search dir.    matches: Vec<FileMatch>,    /// Shared selection/scroll state.    state: ScrollState,}impl FileSearchPopup {    pub(crate) fn new() -> Self {        Self {            display_query: String::new(),            pending_query: String::new(),            waiting: true,            matches: Vec::new(),            state: ScrollState::new(),        }    }    /// Update the query and reset state to *waiting*.    pub(crate) fn set_query(&mut self, query: &str) {        if query == self.pending_query {            return;        }        self.pending_query.clear();        self.pending_query.push_str(query);        self.waiting = true; // waiting for new results    }    /// Put the popup into an "idle" state used for an empty query (just "@").    /// Shows a hint instead of matches until the user types more characters.    pub(crate) fn set_empty_prompt(&mut self) {        self.display_query.clear();        self.pending_query.clear();        self.waiting = false;        self.matches.clear();        // Reset selection/scroll state when showing the empty prompt.        self.state.reset();    }    /// Replace matches when a `FileSearchResult` arrives.    /// Replace matches. Only applied when `query` matches `pending_query`.    pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<FileMatch>) {        if query != self.pending_query {            return; // stale        }        self.display_query = query.to_string();        self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect();        self.waiting = false;        let len = self.matches.len();        self.state.clamp_selection(len);        self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));    }    /// Move selection cursor up.    pub(crate) fn move_up(&mut self) {        let len = self.matches.len();        self.state.move_up_wrap(len);        self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));    }    /// Move selection cursor down.    pub(crate) fn move_down(&mut self) {        let len = self.matches.len();        self.state.move_down_wrap(len);        self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));    }    pub(crate) fn selected_match(&self) -> Option<&PathBuf> {        self.state            .selected_idx            .and_then(|idx| self.matches.get(idx))            .map(|file_match| &file_match.path)    }    pub(crate) fn calculate_required_height(&self) -> u16 {        // Row count depends on whether we already have matches. If no matches        // yet (e.g. initial search or query with no results) reserve a single        // row so the popup is still visible. When matches are present we show        // up to MAX_RESULTS regardless of the waiting flag so the list        // remains stable while a newer search is in-flight.        self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16    }}impl WidgetRef for &FileSearchPopup {    fn render_ref(&self, area: Rect, buf: &mut Buffer) {        // Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.        let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {            Vec::new()        } else {            self.matches                .iter()                .map(|m| GenericDisplayRow {                    name: m.path.to_string_lossy().to_string(),                    name_prefix_spans: Vec::new(),                    match_indices: m                        .indices                        .as_ref()                        .map(|v| v.iter().map(|&i| i as usize).collect()),                    display_shortcut: None,                    description: None,                    category_tag: None,                    wrap_indent: None,                    is_disabled: false,                    disabled_reason: None,                })                .collect()        };        let empty_message = if self.waiting {            "loading..."        } else {            "no matches"        };        render_rows(            area.inset(Insets::tlbr(                /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,            )),            buf,            &rows_all,            &self.state,            MAX_POPUP_ROWS,            empty_message,        );    }}#[cfg(test)]mod tests {    use super::*;    use codex_file_search::MatchType;    use pretty_assertions::assert_eq;    fn file_match(index: usize) -> FileMatch {        FileMatch {            score: index as u32,            path: PathBuf::from(format!("src/file_{index:02}.rs")),            match_type: MatchType::File,            root: PathBuf::from("/tmp/repo"),            indices: None,        }    }    #[test]    fn set_matches_keeps_only_the_first_page_of_results() {        let mut popup = FileSearchPopup::new();        popup.set_query("file");        popup.set_matches("file", (0..(MAX_POPUP_ROWS + 2)).map(file_match).collect());        assert_eq!(            popup.matches,            (0..MAX_POPUP_ROWS).map(file_match).collect::<Vec<_>>()        );        assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16);    }}