feat: highlight matching characters in fuzzy file search (#1420)
Using the new file-search API introduced in https://github.com/openai/codex/pull/1419, matching characters are now shown in bold in the TUI: https://github.com/user-attachments/assets/8bbcc6c6-75a3-493f-8ea4-b2a063e09b3a Fixes https://github.com/openai/codex/issues/1261
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
use codex_core::protocol::Event;
|
use codex_core::protocol::Event;
|
||||||
|
use codex_file_search::FileMatch;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
|
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
@@ -39,6 +40,6 @@ pub(crate) enum AppEvent {
|
|||||||
/// still relevant.
|
/// still relevant.
|
||||||
FileSearchResult {
|
FileSearchResult {
|
||||||
query: String,
|
query: String,
|
||||||
matches: Vec<String>,
|
matches: Vec<FileMatch>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use super::file_search_popup::FileSearchPopup;
|
|||||||
|
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
|
use codex_file_search::FileMatch;
|
||||||
|
|
||||||
/// Minimum number of visible text rows inside the textarea.
|
/// Minimum number of visible text rows inside the textarea.
|
||||||
const MIN_TEXTAREA_ROWS: usize = 1;
|
const MIN_TEXTAREA_ROWS: usize = 1;
|
||||||
@@ -129,7 +130,7 @@ impl ChatComposer<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Integrate results from an asynchronous file search.
|
/// Integrate results from an asynchronous file search.
|
||||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<String>) {
|
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||||
// Only apply if user is still editing a token starting with `query`.
|
// Only apply if user is still editing a token starting with `query`.
|
||||||
let current_opt = Self::current_at_token(&self.textarea);
|
let current_opt = Self::current_at_token(&self.textarea);
|
||||||
let Some(current_token) = current_opt else {
|
let Some(current_token) = current_opt else {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
use codex_file_search::FileMatch;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::prelude::Constraint;
|
use ratatui::prelude::Constraint;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
|
use ratatui::style::Modifier;
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
|
use ratatui::text::Line;
|
||||||
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::Block;
|
use ratatui::widgets::Block;
|
||||||
use ratatui::widgets::BorderType;
|
use ratatui::widgets::BorderType;
|
||||||
use ratatui::widgets::Borders;
|
use ratatui::widgets::Borders;
|
||||||
@@ -25,7 +29,7 @@ pub(crate) struct FileSearchPopup {
|
|||||||
/// When `true` we are still waiting for results for `pending_query`.
|
/// When `true` we are still waiting for results for `pending_query`.
|
||||||
waiting: bool,
|
waiting: bool,
|
||||||
/// Cached matches; paths relative to the search dir.
|
/// Cached matches; paths relative to the search dir.
|
||||||
matches: Vec<String>,
|
matches: Vec<FileMatch>,
|
||||||
/// Currently selected index inside `matches` (if any).
|
/// Currently selected index inside `matches` (if any).
|
||||||
selected_idx: Option<usize>,
|
selected_idx: Option<usize>,
|
||||||
}
|
}
|
||||||
@@ -63,7 +67,7 @@ impl FileSearchPopup {
|
|||||||
|
|
||||||
/// Replace matches when a `FileSearchResult` arrives.
|
/// Replace matches when a `FileSearchResult` arrives.
|
||||||
/// Replace matches. Only applied when `query` matches `pending_query`.
|
/// Replace matches. Only applied when `query` matches `pending_query`.
|
||||||
pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<String>) {
|
pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<FileMatch>) {
|
||||||
if query != self.pending_query {
|
if query != self.pending_query {
|
||||||
return; // stale
|
return; // stale
|
||||||
}
|
}
|
||||||
@@ -101,7 +105,7 @@ impl FileSearchPopup {
|
|||||||
pub(crate) fn selected_match(&self) -> Option<&str> {
|
pub(crate) fn selected_match(&self) -> Option<&str> {
|
||||||
self.selected_idx
|
self.selected_idx
|
||||||
.and_then(|idx| self.matches.get(idx))
|
.and_then(|idx| self.matches.get(idx))
|
||||||
.map(String::as_str)
|
.map(|file_match| file_match.path.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferred height (rows) including border.
|
/// Preferred height (rows) including border.
|
||||||
@@ -130,11 +134,36 @@ impl WidgetRef for &FileSearchPopup {
|
|||||||
.iter()
|
.iter()
|
||||||
.take(MAX_RESULTS)
|
.take(MAX_RESULTS)
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, p)| {
|
.map(|(i, file_match)| {
|
||||||
let mut cell = Cell::from(p.as_str());
|
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<Span> = 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 {
|
if Some(i) == self.selected_idx {
|
||||||
cell = cell.style(Style::default().fg(Color::Yellow));
|
cell = cell.style(Style::default().fg(Color::Yellow));
|
||||||
}
|
}
|
||||||
|
|
||||||
Row::new(vec![cell])
|
Row::new(vec![cell])
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
||||||
|
|
||||||
use bottom_pane_view::BottomPaneView;
|
|
||||||
use bottom_pane_view::ConditionalUpdate;
|
|
||||||
use codex_core::protocol::TokenUsage;
|
|
||||||
use crossterm::event::KeyEvent;
|
|
||||||
use ratatui::buffer::Buffer;
|
|
||||||
use ratatui::layout::Rect;
|
|
||||||
use ratatui::widgets::WidgetRef;
|
|
||||||
|
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::user_approval_widget::ApprovalRequest;
|
use crate::user_approval_widget::ApprovalRequest;
|
||||||
|
use bottom_pane_view::BottomPaneView;
|
||||||
|
use bottom_pane_view::ConditionalUpdate;
|
||||||
|
use codex_core::protocol::TokenUsage;
|
||||||
|
use codex_file_search::FileMatch;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::widgets::WidgetRef;
|
||||||
|
|
||||||
mod approval_modal_view;
|
mod approval_modal_view;
|
||||||
mod bottom_pane_view;
|
mod bottom_pane_view;
|
||||||
@@ -228,7 +228,7 @@ impl BottomPane<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<String>) {
|
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||||
self.composer.on_file_search_result(query, matches);
|
self.composer.on_file_search_result(query, matches);
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ use crate::bottom_pane::InputResult;
|
|||||||
use crate::conversation_history_widget::ConversationHistoryWidget;
|
use crate::conversation_history_widget::ConversationHistoryWidget;
|
||||||
use crate::history_cell::PatchEventType;
|
use crate::history_cell::PatchEventType;
|
||||||
use crate::user_approval_widget::ApprovalRequest;
|
use crate::user_approval_widget::ApprovalRequest;
|
||||||
|
use codex_file_search::FileMatch;
|
||||||
|
|
||||||
pub(crate) struct ChatWidget<'a> {
|
pub(crate) struct ChatWidget<'a> {
|
||||||
app_event_tx: AppEventSender,
|
app_event_tx: AppEventSender,
|
||||||
@@ -405,7 +406,7 @@ impl ChatWidget<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Forward file-search results to the bottom pane.
|
/// Forward file-search results to the bottom pane.
|
||||||
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<String>) {
|
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||||
self.bottom_pane.on_file_search_result(query, matches);
|
self.bottom_pane.on_file_search_result(query, matches);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ impl FileSearchManager {
|
|||||||
cancellation_token: Arc<AtomicBool>,
|
cancellation_token: Arc<AtomicBool>,
|
||||||
search_state: Arc<Mutex<SearchState>>,
|
search_state: Arc<Mutex<SearchState>>,
|
||||||
) {
|
) {
|
||||||
let compute_indices = false;
|
let compute_indices = true;
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let matches = file_search::run(
|
let matches = file_search::run(
|
||||||
&query,
|
&query,
|
||||||
@@ -176,12 +176,7 @@ impl FileSearchManager {
|
|||||||
cancellation_token.clone(),
|
cancellation_token.clone(),
|
||||||
compute_indices,
|
compute_indices,
|
||||||
)
|
)
|
||||||
.map(|res| {
|
.map(|res| res.matches)
|
||||||
res.matches
|
|
||||||
.into_iter()
|
|
||||||
.map(|m| m.path)
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let is_cancelled = cancellation_token.load(Ordering::Relaxed);
|
let is_cancelled = cancellation_token.load(Ordering::Relaxed);
|
||||||
|
|||||||
Reference in New Issue
Block a user