ZIm/crates/agent/src/context_picker/symbol_context_picker.rs
Bennet Bo Fenner 780143298a
agent: Fuzzy match on paths and symbols when typing @ (#28357)
Release Notes:

- agent: Improve fuzzy matching when using @-mentions
2025-04-09 19:00:23 +00:00

447 lines
14 KiB
Rust

use std::cmp::Reverse;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use anyhow::Result;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity,
};
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
use project::{DocumentSymbol, Symbol};
use text::OffsetRangeExt;
use ui::{ListItem, prelude::*};
use util::ResultExt as _;
use workspace::Workspace;
use crate::context_picker::{ConfirmBehavior, ContextPicker};
use crate::context_store::ContextStore;
pub struct SymbolContextPicker {
picker: Entity<Picker<SymbolContextPickerDelegate>>,
}
impl SymbolContextPicker {
pub fn new(
context_picker: WeakEntity<ContextPicker>,
workspace: WeakEntity<Workspace>,
context_store: WeakEntity<ContextStore>,
confirm_behavior: ConfirmBehavior,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let delegate = SymbolContextPickerDelegate::new(
context_picker,
workspace,
context_store,
confirm_behavior,
);
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
Self { picker }
}
}
impl Focusable for SymbolContextPicker {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for SymbolContextPicker {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.picker.clone()
}
}
pub struct SymbolContextPickerDelegate {
context_picker: WeakEntity<ContextPicker>,
workspace: WeakEntity<Workspace>,
context_store: WeakEntity<ContextStore>,
confirm_behavior: ConfirmBehavior,
matches: Vec<SymbolEntry>,
selected_index: usize,
}
impl SymbolContextPickerDelegate {
pub fn new(
context_picker: WeakEntity<ContextPicker>,
workspace: WeakEntity<Workspace>,
context_store: WeakEntity<ContextStore>,
confirm_behavior: ConfirmBehavior,
) -> Self {
Self {
context_picker,
workspace,
context_store,
confirm_behavior,
matches: Vec::new(),
selected_index: 0,
}
}
}
impl PickerDelegate for SymbolContextPickerDelegate {
type ListItem = ListItem;
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search symbols…".into()
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let Some(workspace) = self.workspace.upgrade() else {
return Task::ready(());
};
let search_task = search_symbols(query, Arc::<AtomicBool>::default(), &workspace, cx);
let context_store = self.context_store.clone();
cx.spawn_in(window, async move |this, cx| {
let symbols = search_task.await;
let symbol_entries = context_store
.read_with(cx, |context_store, cx| {
compute_symbol_entries(symbols, context_store, cx)
})
.log_err()
.unwrap_or_default();
this.update(cx, |this, _cx| {
this.delegate.matches = symbol_entries;
})
.log_err();
})
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(mat) = self.matches.get(self.selected_index) else {
return;
};
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let confirm_behavior = self.confirm_behavior;
let add_symbol_task = add_symbol(
mat.symbol.clone(),
true,
workspace,
self.context_store.clone(),
cx,
);
let selected_index = self.selected_index;
cx.spawn_in(window, async move |this, cx| {
let included = add_symbol_task.await?;
this.update_in(cx, |this, window, cx| {
if let Some(mat) = this.delegate.matches.get_mut(selected_index) {
mat.is_included = included;
}
match confirm_behavior {
ConfirmBehavior::KeepOpen => {}
ConfirmBehavior::Close => this.delegate.dismissed(window, cx),
}
})
})
.detach_and_log_err(cx);
}
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
self.context_picker
.update(cx, |_, cx| {
cx.emit(DismissEvent);
})
.ok();
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
_: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let mat = &self.matches[ix];
Some(ListItem::new(ix).inset(true).toggle_state(selected).child(
render_symbol_context_entry(
ElementId::NamedInteger("symbol-ctx-picker".into(), ix),
mat,
),
))
}
}
pub(crate) struct SymbolEntry {
pub symbol: Symbol,
pub is_included: bool,
}
pub(crate) fn add_symbol(
symbol: Symbol,
remove_if_exists: bool,
workspace: Entity<Workspace>,
context_store: WeakEntity<ContextStore>,
cx: &mut App,
) -> Task<Result<bool>> {
let project = workspace.read(cx).project().clone();
let open_buffer_task = project.update(cx, |project, cx| {
project.open_buffer(symbol.path.clone(), cx)
});
cx.spawn(async move |cx| {
let buffer = open_buffer_task.await?;
let document_symbols = project
.update(cx, |project, cx| project.document_symbols(&buffer, cx))?
.await?;
// Try to find a matching document symbol. Document symbols include
// not only the symbol itself (e.g. function name), but they also
// include the context that they contain (e.g. function body).
let (name, range, enclosing_range) = if let Some(DocumentSymbol {
name,
range,
selection_range,
..
}) =
find_matching_symbol(&symbol, document_symbols.as_slice())
{
(name, selection_range, range)
} else {
// If we do not find a matching document symbol, fall back to
// just the symbol itself
(symbol.name, symbol.range.clone(), symbol.range)
};
let (range, enclosing_range) = buffer.read_with(cx, |buffer, _| {
(
buffer.anchor_after(range.start)..buffer.anchor_before(range.end),
buffer.anchor_after(enclosing_range.start)
..buffer.anchor_before(enclosing_range.end),
)
})?;
context_store
.update(cx, move |context_store, cx| {
context_store.add_symbol(
buffer,
name.into(),
range,
enclosing_range,
remove_if_exists,
cx,
)
})?
.await
})
}
fn find_matching_symbol(symbol: &Symbol, candidates: &[DocumentSymbol]) -> Option<DocumentSymbol> {
let mut candidates = candidates.iter();
let mut candidate = candidates.next()?;
loop {
if candidate.range.start > symbol.range.end {
return None;
}
if candidate.range.end < symbol.range.start {
candidate = candidates.next()?;
continue;
}
if candidate.selection_range == symbol.range {
return Some(candidate.clone());
}
if candidate.range.start <= symbol.range.start && symbol.range.end <= candidate.range.end {
candidates = candidate.children.iter();
candidate = candidates.next()?;
continue;
}
return None;
}
}
pub struct SymbolMatch {
pub symbol: Symbol,
}
pub(crate) fn search_symbols(
query: String,
cancellation_flag: Arc<AtomicBool>,
workspace: &Entity<Workspace>,
cx: &mut App,
) -> Task<Vec<SymbolMatch>> {
let symbols_task = workspace.update(cx, |workspace, cx| {
workspace
.project()
.update(cx, |project, cx| project.symbols(&query, cx))
});
let project = workspace.read(cx).project().clone();
cx.spawn(async move |cx| {
let Some(symbols) = symbols_task.await.log_err() else {
return Vec::new();
};
let Some((visible_match_candidates, external_match_candidates)): Option<(Vec<_>, Vec<_>)> =
project
.update(cx, |project, cx| {
symbols
.iter()
.enumerate()
.map(|(id, symbol)| {
StringMatchCandidate::new(id, &symbol.label.filter_text())
})
.partition(|candidate| {
project
.entry_for_path(&symbols[candidate.id].path, cx)
.map_or(false, |e| !e.is_ignored)
})
})
.log_err()
else {
return Vec::new();
};
const MAX_MATCHES: usize = 100;
let mut visible_matches = cx.background_executor().block(fuzzy::match_strings(
&visible_match_candidates,
&query,
false,
MAX_MATCHES,
&cancellation_flag,
cx.background_executor().clone(),
));
let mut external_matches = cx.background_executor().block(fuzzy::match_strings(
&external_match_candidates,
&query,
false,
MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
&cancellation_flag,
cx.background_executor().clone(),
));
let sort_key_for_match = |mat: &StringMatch| {
let symbol = &symbols[mat.candidate_id];
(Reverse(OrderedFloat(mat.score)), symbol.label.filter_text())
};
visible_matches.sort_unstable_by_key(sort_key_for_match);
external_matches.sort_unstable_by_key(sort_key_for_match);
let mut matches = visible_matches;
matches.append(&mut external_matches);
matches
.into_iter()
.map(|mut mat| {
let symbol = symbols[mat.candidate_id].clone();
let filter_start = symbol.label.filter_range.start;
for position in &mut mat.positions {
*position += filter_start;
}
SymbolMatch { symbol }
})
.collect()
})
}
fn compute_symbol_entries(
symbols: Vec<SymbolMatch>,
context_store: &ContextStore,
cx: &App,
) -> Vec<SymbolEntry> {
let mut symbol_entries = Vec::with_capacity(symbols.len());
for SymbolMatch { symbol, .. } in symbols {
let symbols_for_path = context_store.included_symbols_by_path().get(&symbol.path);
let is_included = if let Some(symbols_for_path) = symbols_for_path {
let mut is_included = false;
for included_symbol_id in symbols_for_path {
if included_symbol_id.name.as_ref() == symbol.name.as_str() {
if let Some(buffer) = context_store.buffer_for_symbol(included_symbol_id) {
let snapshot = buffer.read(cx).snapshot();
let included_symbol_range =
included_symbol_id.range.to_point_utf16(&snapshot);
if included_symbol_range.start == symbol.range.start.0
&& included_symbol_range.end == symbol.range.end.0
{
is_included = true;
break;
}
}
}
}
is_included
} else {
false
};
symbol_entries.push(SymbolEntry {
symbol,
is_included,
})
}
symbol_entries
}
pub fn render_symbol_context_entry(id: ElementId, entry: &SymbolEntry) -> Stateful<Div> {
let path = entry
.symbol
.path
.path
.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or_default();
let symbol_location = format!("{} L{}", path, entry.symbol.range.start.0.row + 1);
h_flex()
.id(id)
.gap_1p5()
.w_full()
.child(
Icon::new(IconName::Code)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
h_flex()
.gap_1()
.child(Label::new(&entry.symbol.name))
.child(
Label::new(symbol_location)
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.when(entry.is_included, |el| {
el.child(
h_flex()
.w_full()
.justify_end()
.gap_0p5()
.child(
Icon::new(IconName::Check)
.size(IconSize::Small)
.color(Color::Success),
)
.child(Label::new("Added").size(LabelSize::Small)),
)
})
}