use codex_file_search::FileMatch; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Constraint; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; use ratatui::widgets::Cell; use ratatui::widgets::Row; use ratatui::widgets::Table; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; /// Maximum number of suggestions shown in the popup. const MAX_RESULTS: usize = 8; /// 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, /// Currently selected index inside `matches` (if any). selected_idx: Option, } impl FileSearchPopup { pub(crate) fn new() -> Self { Self { display_query: String::new(), pending_query: String::new(), waiting: true, matches: Vec::new(), selected_idx: None, } } /// Update the query and reset state to *waiting*. pub(crate) fn set_query(&mut self, query: &str) { if query == self.pending_query { 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.selected_idx = None; } } /// 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) { if query != self.pending_query { return; // stale } self.display_query = query.to_string(); self.matches = matches; self.waiting = false; self.selected_idx = if self.matches.is_empty() { None } else { Some(0) }; } /// Move selection cursor up. pub(crate) fn move_up(&mut self) { if let Some(idx) = self.selected_idx { if idx > 0 { self.selected_idx = Some(idx - 1); } } } /// Move selection cursor down. pub(crate) fn move_down(&mut self) { if let Some(idx) = self.selected_idx { if idx + 1 < self.matches.len() { self.selected_idx = Some(idx + 1); } } else if !self.matches.is_empty() { self.selected_idx = Some(0); } } pub(crate) fn selected_match(&self) -> Option<&str> { self.selected_idx .and_then(|idx| self.matches.get(idx)) .map(|file_match| file_match.path.as_str()) } /// Preferred height (rows) including border. 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_RESULTS) as u16 } } impl WidgetRef for &FileSearchPopup { fn render_ref(&self, area: Rect, buf: &mut Buffer) { // Prepare rows. let rows: Vec = if self.matches.is_empty() { vec![Row::new(vec![ Cell::from(if self.waiting { "(searching …)" } else { "no matches" }) .style(Style::new().add_modifier(Modifier::ITALIC | Modifier::DIM)), ])] } else { self.matches .iter() .take(MAX_RESULTS) .enumerate() .map(|(i, file_match)| { let FileMatch { path, indices, .. } = file_match; let path = path.as_str(); #[allow(clippy::expect_used)] let indices = indices.as_ref().expect("indices should be present"); // Build spans with bold on matching indices. let mut idx_iter = indices.iter().peekable(); let mut spans: Vec = Vec::with_capacity(path.len()); for (char_idx, ch) in path.chars().enumerate() { let mut style = Style::default(); if idx_iter .peek() .is_some_and(|next| **next == char_idx as u32) { idx_iter.next(); style = style.add_modifier(Modifier::BOLD); } spans.push(Span::styled(ch.to_string(), style)); } // Create cell from the spans. let mut cell = Cell::from(Line::from(spans)); // If selected, also paint yellow. if Some(i) == self.selected_idx { cell = cell.style(Style::default().fg(Color::Yellow)); } Row::new(vec![cell]) }) .collect() }; let table = Table::new(rows, vec![Constraint::Percentage(100)]) .block( Block::default() .borders(Borders::LEFT) .border_type(BorderType::QuadrantOutside) .border_style(Style::default().fg(Color::DarkGray)), ) .widths([Constraint::Percentage(100)]); table.render(area, buf); } }