Skip to repository content973 lines · 34.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:13:38.018Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
lsp_locations.rs
1use std::ops::Range;
2use std::sync::Arc;
3
4use collections::HashMap;
5use editor::actions::{FindAllReferences, GoToDefinition, GoToImplementation};
6use editor::{Editor, EditorSettings, GotoDefinitionKind, OpenResultsIn};
7use file_icons::FileIcons;
8use fuzzy::StringMatchCandidate;
9use gpui::{
10 AnyElement, App, AppContext, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter,
11 FocusHandle, Focusable, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity,
12 prelude::*,
13};
14use language::{Buffer, HighlightId, LanguageAwareStyling};
15use picker::{Picker, PickerDelegate};
16use project::{Location, Project, ProjectPath};
17use settings::{GoToDefinitionFallback, Settings as _};
18use text::{Anchor, Point};
19use theme_settings::ThemeSettings;
20use ui::{Divider, FluentBuilder};
21use ui::{ListItem, ListItemSpacing, prelude::*};
22use util::ResultExt as _;
23use workspace::item::ItemSettings;
24use workspace::notifications::NotificationId;
25use workspace::{ModalView, Toast, Workspace};
26
27pub fn init(cx: &mut App) {
28 cx.observe_new(register).detach();
29}
30
31/// Registers handlers for the navigation actions on each full editor. When the
32/// action resolves to [`OpenResultsIn::Picker`], we open the filterable picker;
33/// otherwise we `cx.propagate()` so the editor's own handler runs and builds a
34/// multibuffer.
35fn register(editor: &mut Editor, _window: Option<&mut Window>, cx: &mut Context<Editor>) {
36 if !editor.mode().is_full() {
37 return;
38 }
39 let handle = cx.entity().downgrade();
40 editor
41 .register_action({
42 let handle = handle.clone();
43 move |action: &GoToDefinition, window, cx| {
44 handle_nav_action(
45 action.open_results_in,
46 LspPickerKind::Definition,
47 &handle,
48 window,
49 cx,
50 );
51 }
52 })
53 .detach();
54 editor
55 .register_action({
56 let handle = handle.clone();
57 move |action: &GoToImplementation, window, cx| {
58 handle_nav_action(
59 action.open_results_in,
60 LspPickerKind::Implementation,
61 &handle,
62 window,
63 cx,
64 );
65 }
66 })
67 .detach();
68 editor
69 .register_action(move |action: &FindAllReferences, window, cx| {
70 handle_nav_action(
71 action.open_results_in,
72 LspPickerKind::References,
73 &handle,
74 window,
75 cx,
76 );
77 })
78 .detach();
79}
80
81/// Either opens the picker for the editor, or propagates the action so the
82/// editor's built-in (multibuffer) handler runs. A `None` argument falls back to
83/// the `lsp_results_location` setting.
84fn handle_nav_action(
85 open_results_in: Option<OpenResultsIn>,
86 kind: LspPickerKind,
87 editor: &WeakEntity<Editor>,
88 window: &mut Window,
89 cx: &mut App,
90) {
91 let open_results_in =
92 open_results_in.unwrap_or_else(|| EditorSettings::get_global(cx).lsp_results_location);
93 if open_results_in != OpenResultsIn::Picker {
94 cx.propagate();
95 return;
96 }
97 LspLocationsPicker::open_for_editor(kind, editor.clone(), window, cx);
98}
99
100/// Runs the LSP query for `kind` and returns the raw locations. Returns `None`
101/// (and reports any error) when there is nothing to query, so the caller stops
102/// without an empty-results toast. Deduplication and dropping fileless results
103/// happen later in [`build_location_matches`].
104async fn run_picker_query(
105 kind: LspPickerKind,
106 editor: &WeakEntity<Editor>,
107 workspace: &WeakEntity<Workspace>,
108 project: &Entity<Project>,
109 cx: &mut AsyncWindowContext,
110) -> Option<Vec<Location>> {
111 let query = editor
112 .update(cx, |editor, cx| kind.run_query(editor, project, cx))
113 .ok()
114 .flatten()?;
115 match query.await {
116 Ok(locations) => Some(locations),
117 Err(error) => {
118 log::error!("LSP {kind:?} query failed: {error:#}");
119 workspace
120 .update(cx, |workspace, cx| workspace.show_error(error, cx))
121 .log_err();
122 None
123 }
124 }
125}
126
127/// Runs the query for `kind` and builds the displayable, deduped matches.
128async fn run_picker_matches(
129 kind: LspPickerKind,
130 editor: &WeakEntity<Editor>,
131 workspace: &WeakEntity<Workspace>,
132 project: &Entity<Project>,
133 cx: &mut AsyncWindowContext,
134) -> Option<Vec<LocationMatch>> {
135 let locations = run_picker_query(kind, editor, workspace, project, cx).await?;
136 editor
137 .update(cx, |_, cx| build_location_matches(&locations, cx))
138 .ok()
139}
140
141fn show_no_results_toast(
142 workspace: &WeakEntity<Workspace>,
143 kind: LspPickerKind,
144 cx: &mut AsyncWindowContext,
145) {
146 workspace
147 .update(cx, |workspace, cx| {
148 struct NoLspResults;
149 workspace.show_toast(
150 Toast::new(
151 NotificationId::unique::<NoLspResults>(),
152 kind.empty_message(),
153 )
154 .autohide(),
155 cx,
156 );
157 })
158 .log_err();
159}
160
161#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162pub enum LspPickerKind {
163 References,
164 Definition,
165 Implementation,
166}
167
168impl LspPickerKind {
169 fn placeholder(self) -> &'static str {
170 match self {
171 LspPickerKind::References => "Filter references…",
172 LspPickerKind::Definition => "Filter definitions…",
173 LspPickerKind::Implementation => "Filter implementations…",
174 }
175 }
176
177 /// Message shown when the query produces no results, so the command does not
178 /// appear to silently do nothing.
179 fn empty_message(self) -> &'static str {
180 match self {
181 LspPickerKind::References => "No references found",
182 LspPickerKind::Definition => "No definitions found",
183 LspPickerKind::Implementation => "No implementations found",
184 }
185 }
186
187 /// Runs the query for this kind against the active editor, returning the raw
188 /// locations to populate the picker.
189 fn run_query(
190 self,
191 editor: &mut Editor,
192 project: &Entity<Project>,
193 cx: &mut Context<Editor>,
194 ) -> Option<Task<anyhow::Result<Vec<Location>>>> {
195 match self {
196 LspPickerKind::References => editor.find_all_references_locations(project, cx),
197 LspPickerKind::Definition => {
198 editor.definition_locations_of_kind(GotoDefinitionKind::Symbol, cx)
199 }
200 LspPickerKind::Implementation => {
201 editor.definition_locations_of_kind(GotoDefinitionKind::Implementation, cx)
202 }
203 }
204 }
205}
206
207pub struct LspLocationsPicker {
208 picker: Entity<Picker<LspLocationsDelegate>>,
209 _subscription: Subscription,
210}
211
212impl LspLocationsPicker {
213 fn open_for_editor(
214 kind: LspPickerKind,
215 editor: WeakEntity<Editor>,
216 window: &mut Window,
217 cx: &mut App,
218 ) {
219 let Some(editor) = editor.upgrade() else {
220 return;
221 };
222 let Some(workspace) = editor.read(cx).workspace() else {
223 return;
224 };
225 workspace.update(cx, |workspace, cx| {
226 Self::open(kind, editor, workspace, window, cx);
227 });
228 }
229
230 /// Opens the picker for `kind`: runs a fresh LSP query and shows the
231 /// results. An empty definitions query falls back to references when the
232 /// `go_to_definition_fallback` setting calls for it, matching
233 /// [`Editor::go_to_definition`].
234 fn open(
235 kind: LspPickerKind,
236 editor: Entity<Editor>,
237 workspace: &mut Workspace,
238 window: &mut Window,
239 cx: &mut Context<Workspace>,
240 ) {
241 let project = workspace.project().clone();
242 let fallback = EditorSettings::get_global(cx).go_to_definition_fallback;
243 let editor = editor.downgrade();
244 cx.spawn_in(window, async move |workspace, cx| {
245 // The kind the user invoked, kept for user-facing messages even if
246 // the query below falls back to references.
247 let invoked_kind = kind;
248 let mut kind = kind;
249
250 // Count on the built matches (not raw locations): they are deduped by
251 // range and exclude fileless results, so a single distinct result
252 // jumps directly and a fileless-only result reports "no results"
253 // instead of opening a blank picker.
254 let Some(mut matches) =
255 run_picker_matches(kind, &editor, &workspace, &project, cx).await
256 else {
257 return;
258 };
259
260 if matches.is_empty()
261 && kind == LspPickerKind::Definition
262 && fallback == GoToDefinitionFallback::FindAllReferences
263 {
264 kind = LspPickerKind::References;
265 let Some(references) =
266 run_picker_matches(kind, &editor, &workspace, &project, cx).await
267 else {
268 return;
269 };
270 matches = references;
271 }
272
273 if matches.is_empty() {
274 show_no_results_toast(&workspace, invoked_kind, cx);
275 return;
276 }
277
278 if matches.len() == 1 {
279 if let Some(location_match) = matches.into_iter().next() {
280 let location = Location {
281 buffer: location_match.buffer,
282 range: location_match.anchor_range,
283 };
284 if let Ok(task) = editor.update_in(cx, |editor, window, cx| {
285 editor.open_location(location, false, window, cx)
286 }) {
287 task.await.log_err();
288 }
289 }
290 return;
291 }
292
293 workspace
294 .update_in(cx, |workspace, window, cx| {
295 workspace.toggle_modal(window, cx, |window, cx| {
296 Self::new(kind, matches, project, editor, window, cx)
297 });
298 })
299 .log_err();
300 })
301 .detach();
302 }
303
304 fn new(
305 kind: LspPickerKind,
306 matches: Vec<LocationMatch>,
307 project: Entity<Project>,
308 editor: WeakEntity<Editor>,
309 window: &mut Window,
310 cx: &mut Context<Self>,
311 ) -> Self {
312 let preview = picker_preview::editor_preview(project.clone(), window, cx);
313 let delegate = LspLocationsDelegate::new(kind, matches, project, editor);
314 let picker = cx.new(|cx| Picker::list_with_preview(delegate, preview, window, cx));
315 let subscription = cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| {
316 cx.emit(DismissEvent);
317 });
318 Self {
319 picker,
320 _subscription: subscription,
321 }
322 }
323}
324
325impl ModalView for LspLocationsPicker {}
326
327impl EventEmitter<DismissEvent> for LspLocationsPicker {}
328
329impl Focusable for LspLocationsPicker {
330 fn focus_handle(&self, cx: &App) -> FocusHandle {
331 self.picker.focus_handle(cx)
332 }
333}
334
335impl Render for LspLocationsPicker {
336 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
337 v_flex().child(self.picker.clone())
338 }
339}
340
341struct LocationMatch {
342 path: ProjectPath,
343 buffer: Entity<Buffer>,
344 anchor_range: Range<Anchor>,
345 range: Range<usize>,
346 display_text: String,
347 syntax_highlights: Vec<(Range<usize>, HighlightId)>,
348 match_range: Range<usize>,
349 line_number: u32,
350}
351
352/// A row in the grouped display list: a non-selectable file header, a match, or
353/// a separator between file groups. `selected_index` indexes into this list.
354enum Entry {
355 Header(ProjectPath),
356 Match(usize),
357 Separator,
358}
359
360struct LspLocationsDelegate {
361 kind: LspPickerKind,
362 project: Entity<Project>,
363 editor: WeakEntity<Editor>,
364 all_matches: Vec<LocationMatch>,
365 candidates: Arc<[StringMatchCandidate]>,
366 matches: Vec<usize>,
367 entries: Vec<Entry>,
368 selected_index: usize,
369 max_line_number: u32,
370}
371
372impl LspLocationsDelegate {
373 fn new(
374 kind: LspPickerKind,
375 all_matches: Vec<LocationMatch>,
376 project: Entity<Project>,
377 editor: WeakEntity<Editor>,
378 ) -> Self {
379 // Match against the line text and the file path, mirroring the fuzzy
380 // matching every other Zed picker uses.
381 let candidates = all_matches
382 .iter()
383 .enumerate()
384 .map(|(index, location_match)| {
385 StringMatchCandidate::new(
386 index,
387 &format!(
388 "{} {}",
389 location_match.display_text,
390 location_match.path.path.as_unix_str()
391 ),
392 )
393 })
394 .collect();
395 let matches = (0..all_matches.len()).collect();
396 let mut this = Self {
397 kind,
398 project,
399 editor,
400 all_matches,
401 candidates,
402 matches,
403 entries: Vec::new(),
404 selected_index: 0,
405 max_line_number: 0,
406 };
407 this.rebuild_entries();
408 this
409 }
410
411 /// Rebuilds the grouped [`Self::entries`] from the filtered [`Self::matches`]:
412 /// one header per file, its matches, and a separator before every group
413 /// after the first. Selection snaps to the first selectable row.
414 fn rebuild_entries(&mut self) {
415 let mut entries = Vec::with_capacity(self.matches.len());
416 let mut last_path: Option<&ProjectPath> = None;
417 let mut max_line_number = 0;
418 for &match_index in &self.matches {
419 let location_match = &self.all_matches[match_index];
420 if last_path != Some(&location_match.path) {
421 if last_path.is_some() {
422 entries.push(Entry::Separator);
423 }
424 entries.push(Entry::Header(location_match.path.clone()));
425 last_path = Some(&location_match.path);
426 }
427 max_line_number = max_line_number.max(location_match.line_number);
428 entries.push(Entry::Match(match_index));
429 }
430 self.entries = entries;
431 self.max_line_number = max_line_number;
432 self.selected_index = self.first_selectable_index().unwrap_or(0);
433 }
434
435 fn first_selectable_index(&self) -> Option<usize> {
436 self.entries
437 .iter()
438 .position(|entry| matches!(entry, Entry::Match(_)))
439 }
440
441 fn selected_location_match(&self) -> Option<&LocationMatch> {
442 match self.entries.get(self.selected_index)? {
443 Entry::Match(match_index) => self.all_matches.get(*match_index),
444 Entry::Header(_) | Entry::Separator => None,
445 }
446 }
447
448 fn open_selected(&mut self, split: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
449 let Some(location_match) = self.selected_location_match() else {
450 return;
451 };
452 let location = Location {
453 buffer: location_match.buffer.clone(),
454 range: location_match.anchor_range.clone(),
455 };
456 let Some(editor) = self.editor.upgrade() else {
457 return;
458 };
459 editor
460 .update(cx, |editor, cx| {
461 editor.open_location(location, split, window, cx)
462 })
463 .detach_and_log_err(cx);
464 cx.emit(DismissEvent);
465 }
466}
467
468fn build_location_matches(locations: &[Location], cx: &App) -> Vec<LocationMatch> {
469 use gpui::EntityId;
470 let mut snapshots: HashMap<EntityId, language::BufferSnapshot> = HashMap::default();
471 let mut matches = Vec::with_capacity(locations.len());
472
473 for location in locations {
474 let snapshot = snapshots
475 .entry(location.buffer.entity_id())
476 .or_insert_with(|| location.buffer.read(cx).snapshot());
477
478 let Some(file) = snapshot.file() else {
479 continue;
480 };
481 let path = ProjectPath {
482 worktree_id: file.worktree_id(cx),
483 path: file.path().clone(),
484 };
485
486 let start_offset: usize = snapshot.summary_for_anchor(&location.range.start);
487 let end_offset: usize = snapshot.summary_for_anchor(&location.range.end);
488 let row = snapshot.offset_to_point(start_offset).row;
489 let line_start = snapshot.point_to_offset(Point::new(row, 0));
490 let line_end = snapshot.point_to_offset(Point::new(row, snapshot.line_len(row)));
491 let full_line: String = snapshot.text_for_range(line_start..line_end).collect();
492
493 // The row shows the line with leading indentation trimmed. Offsets below
494 // are relative to that displayed text.
495 let display_text = full_line.trim_start().to_string();
496 let visible_start = line_end.saturating_sub(display_text.len());
497 let visible_end = line_end;
498
499 // Precompute syntax highlights for the displayed text so rendering a row
500 // never re-snapshots the buffer or re-runs highlighting.
501 let mut syntax_highlights = Vec::new();
502 let mut offset = 0;
503 for chunk in snapshot.chunks(
504 visible_start..visible_end,
505 LanguageAwareStyling {
506 tree_sitter: true,
507 diagnostics: false,
508 },
509 ) {
510 let chunk_len = chunk.text.len();
511 if let Some(id) = chunk.syntax_highlight_id {
512 syntax_highlights.push((offset..offset + chunk_len, id));
513 }
514 offset += chunk_len;
515 }
516
517 // The match span, clamped into the displayed text. `clamp` bounds each
518 // endpoint to the line; `min`/`max` then keep the range well-ordered even
519 // for a malformed/inverted LSP range (clamping alone preserves bounds but
520 // not `start <= end`).
521 let clamped_start = start_offset.clamp(visible_start, visible_end) - visible_start;
522 let clamped_end = end_offset.clamp(visible_start, visible_end) - visible_start;
523 let match_range = clamped_start.min(clamped_end)..clamped_start.max(clamped_end);
524
525 matches.push(LocationMatch {
526 path,
527 buffer: location.buffer.clone(),
528 anchor_range: location.range.clone(),
529 range: start_offset..end_offset,
530 display_text,
531 syntax_highlights,
532 match_range,
533 line_number: row + 1,
534 });
535 }
536
537 // Group by file and order by position so the grouped display list is stable,
538 // then drop exact-duplicate ranges a server may report more than once.
539 matches.sort_by(|a, b| a.path.cmp(&b.path).then(a.range.start.cmp(&b.range.start)));
540 matches.dedup_by(|a, b| a.path == b.path && a.range == b.range);
541 matches
542}
543
544impl PickerDelegate for LspLocationsDelegate {
545 type ListItem = AnyElement;
546
547 fn name() -> &'static str {
548 "lsp locations picker"
549 }
550
551 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> std::sync::Arc<str> {
552 self.kind.placeholder().into()
553 }
554
555 fn match_count(&self) -> usize {
556 self.entries.len()
557 }
558
559 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
560 matches!(self.entries.get(ix), Some(Entry::Match(_)))
561 }
562
563 fn selected_index(&self) -> usize {
564 self.selected_index
565 }
566
567 fn select_on_hover(&self) -> bool {
568 false
569 }
570
571 fn set_selected_index(
572 &mut self,
573 ix: usize,
574 _window: &mut Window,
575 _cx: &mut Context<Picker<Self>>,
576 ) {
577 self.selected_index = ix;
578 }
579
580 fn update_matches(
581 &mut self,
582 query: String,
583 _window: &mut Window,
584 cx: &mut Context<Picker<Self>>,
585 ) -> Task<()> {
586 let query = query.trim().to_owned();
587 let candidates = self.candidates.clone();
588 cx.spawn(async move |picker, cx| {
589 let matches = if query.is_empty() {
590 (0..candidates.len()).collect()
591 } else {
592 let string_matches = fuzzy::match_strings(
593 &candidates,
594 &query,
595 false,
596 true,
597 candidates.len(),
598 &Default::default(),
599 cx.background_executor().clone(),
600 )
601 .await;
602 let mut indices = string_matches
603 .into_iter()
604 .map(|string_match| string_match.candidate_id)
605 .collect::<Vec<_>>();
606 // Restore the file-grouped, positional order (fuzzy returns by score).
607 indices.sort_unstable();
608 indices
609 };
610 picker
611 .update(cx, |picker, cx| {
612 picker.delegate.matches = matches;
613 picker.delegate.rebuild_entries();
614 cx.notify();
615 })
616 .ok();
617 })
618 }
619
620 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
621 self.open_selected(secondary, window, cx);
622 }
623
624 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
625 cx.emit(DismissEvent);
626 }
627
628 fn try_get_preview_data_for_match(&self, _cx: &App) -> Option<picker::PreviewUpdate> {
629 let location_match = self.selected_location_match()?;
630 Some(picker::PreviewUpdate::from_buffer(
631 location_match.buffer.clone(),
632 picker::MatchLocation {
633 anchor_range: location_match.anchor_range.clone(),
634 range: location_match.range.clone(),
635 },
636 ))
637 }
638
639 fn render_match(
640 &self,
641 ix: usize,
642 selected: bool,
643 _window: &mut Window,
644 cx: &mut Context<Picker<Self>>,
645 ) -> Option<Self::ListItem> {
646 match self.entries.get(ix)? {
647 Entry::Separator => Some(
648 div()
649 .py(DynamicSpacing::Base04.rems(cx))
650 .child(Divider::horizontal())
651 .into_any_element(),
652 ),
653 Entry::Header(path) => {
654 let path_style = self.project.read(cx).path_style(cx);
655 let file_name = path
656 .path
657 .file_name()
658 .map(|name| name.to_string())
659 .unwrap_or_default();
660 let directory = path
661 .path
662 .parent()
663 .map(|parent| parent.display(path_style))
664 .map(SharedString::new)
665 .unwrap_or_default();
666 let file_icon = ItemSettings::get_global(cx)
667 .file_icons
668 .then(|| FileIcons::get_icon(path.path.as_std_path(), cx))
669 .flatten()
670 .map(|icon| {
671 Icon::from_path(icon)
672 .color(Color::Muted)
673 .size(IconSize::Small)
674 });
675 Some(
676 h_flex()
677 .w_full()
678 .min_w_0()
679 .px(DynamicSpacing::Base06.rems(cx))
680 .py_1()
681 .gap_1p5()
682 .children(file_icon)
683 .child(
684 h_flex()
685 .gap_1()
686 .child(Label::new(file_name).size(LabelSize::Small))
687 .when(!directory.is_empty(), |this| {
688 this.child(
689 Label::new(directory)
690 .size(LabelSize::Small)
691 .color(Color::Muted)
692 .truncate_start(),
693 )
694 }),
695 )
696 .into_any_element(),
697 )
698 }
699 Entry::Match(match_index) => {
700 let location_match = self.all_matches.get(*match_index)?;
701 Some(
702 ListItem::new(ix)
703 .spacing(ListItemSpacing::Sparse)
704 .inset(true)
705 .toggle_state(selected)
706 .child(
707 h_flex()
708 .w_full()
709 .min_w_0()
710 .gap_2p5()
711 .text_sm()
712 .child(
713 h_flex()
714 .w(rems(
715 (self.max_line_number.max(1).ilog10() + 1) as f32 * 0.5,
716 ))
717 .justify_end()
718 .child(
719 Label::new(location_match.line_number.to_string())
720 .color(Color::Custom(
721 cx.theme().colors().text_muted.opacity(0.5),
722 )),
723 ),
724 )
725 .child(
726 div()
727 .flex_1()
728 .min_w_0()
729 .truncate()
730 .child(render_matched_line(location_match, cx)),
731 ),
732 )
733 .into_any_element(),
734 )
735 }
736 }
737 }
738}
739
740/// Renders the precomputed displayed line, resolving the stored syntax highlight
741/// ids against the current theme and overlaying the match with a highlighted
742/// background and bold weight.
743fn render_matched_line(location_match: &LocationMatch, cx: &App) -> StyledText {
744 let settings = ThemeSettings::get_global(cx);
745 let text_style = TextStyle {
746 color: cx.theme().colors().text,
747 font_family: settings.buffer_font.family.clone(),
748 font_features: settings.buffer_font.features.clone(),
749 font_fallbacks: settings.buffer_font.fallbacks.clone(),
750 font_size: settings.buffer_font_size(cx).into(),
751 font_weight: settings.buffer_font.weight,
752 line_height: relative(1.),
753 ..Default::default()
754 };
755
756 let syntax_theme = cx.theme().syntax();
757 let syntax_highlights = location_match
758 .syntax_highlights
759 .iter()
760 .filter_map(|(range, id)| Some((range.clone(), syntax_theme.get(*id).copied()?)))
761 .collect::<Vec<_>>();
762
763 let match_style = HighlightStyle {
764 background_color: Some(cx.theme().colors().search_match_background),
765 font_weight: Some(gpui::FontWeight::BOLD),
766 ..Default::default()
767 };
768 let match_highlight = (location_match.match_range.clone(), match_style);
769
770 let highlights = gpui::combine_highlights(syntax_highlights, [match_highlight]);
771 StyledText::new(location_match.display_text.clone())
772 .with_default_highlights(&text_style, highlights)
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778 use editor::test::editor_lsp_test_context::EditorLspTestContext;
779 use gpui::TestAppContext;
780 use indoc::indoc;
781
782 async fn rust_cx(
783 capabilities: lsp::ServerCapabilities,
784 cx: &mut TestAppContext,
785 ) -> EditorLspTestContext {
786 EditorLspTestContext::new_rust(capabilities, cx).await
787 }
788
789 fn open(cx: &mut EditorLspTestContext, kind: LspPickerKind) {
790 let editor = cx.editor.clone();
791 let workspace = cx.workspace.clone();
792 cx.update(|window, cx| {
793 workspace.update(cx, |workspace, cx| {
794 LspLocationsPicker::open(kind, editor, workspace, window, cx);
795 });
796 });
797 cx.run_until_parked();
798 }
799
800 fn active_picker(cx: &mut EditorLspTestContext) -> Option<Entity<LspLocationsPicker>> {
801 let workspace = cx.workspace.clone();
802 cx.update(|_window, cx| workspace.read(cx).active_modal::<LspLocationsPicker>(cx))
803 }
804
805 fn references(uri: lsp::Uri, ranges: &[(u32, u32, u32)]) -> Vec<lsp::Location> {
806 ranges
807 .iter()
808 .map(|&(row, start, end)| lsp::Location {
809 uri: uri.clone(),
810 range: lsp::Range::new(
811 lsp::Position::new(row, start),
812 lsp::Position::new(row, end),
813 ),
814 })
815 .collect()
816 }
817
818 const SOURCE: &str = indoc! {r#"
819 fn main() {
820 let aˇbc = 123;
821 let xyz = abc;
822 }
823 "#};
824
825 #[gpui::test]
826 async fn test_multiple_references_open_picker(cx: &mut TestAppContext) {
827 let mut cx = rust_cx(
828 lsp::ServerCapabilities {
829 references_provider: Some(lsp::OneOf::Left(true)),
830 ..Default::default()
831 },
832 cx,
833 )
834 .await;
835 cx.set_state(SOURCE);
836 cx.lsp
837 .set_request_handler::<lsp::request::References, _, _>(async move |params, _| {
838 let uri = params.text_document_position.text_document.uri;
839 Ok(Some(references(uri, &[(1, 8, 11), (2, 14, 17)])))
840 });
841
842 open(&mut cx, LspPickerKind::References);
843
844 assert!(
845 active_picker(&mut cx).is_some(),
846 "multiple references should open the picker"
847 );
848 }
849
850 #[gpui::test]
851 async fn test_single_result_jumps_without_picker(cx: &mut TestAppContext) {
852 let mut cx = rust_cx(
853 lsp::ServerCapabilities {
854 references_provider: Some(lsp::OneOf::Left(true)),
855 ..Default::default()
856 },
857 cx,
858 )
859 .await;
860 cx.set_state(SOURCE);
861 cx.lsp
862 .set_request_handler::<lsp::request::References, _, _>(async move |params, _| {
863 let uri = params.text_document_position.text_document.uri;
864 Ok(Some(references(uri, &[(2, 14, 17)])))
865 });
866
867 open(&mut cx, LspPickerKind::References);
868
869 assert!(
870 active_picker(&mut cx).is_none(),
871 "a single result should jump directly instead of opening the picker"
872 );
873 // The lone result at row 2 should be selected directly, moving the
874 // cursor off its starting position on row 1.
875 cx.assert_editor_state(indoc! {r#"
876 fn main() {
877 let abc = 123;
878 let xyz = «abcˇ»;
879 }
880 "#});
881 }
882
883 #[gpui::test]
884 async fn test_no_results_does_not_open_picker(cx: &mut TestAppContext) {
885 let mut cx = rust_cx(
886 lsp::ServerCapabilities {
887 references_provider: Some(lsp::OneOf::Left(true)),
888 ..Default::default()
889 },
890 cx,
891 )
892 .await;
893 cx.set_state(SOURCE);
894 cx.lsp
895 .set_request_handler::<lsp::request::References, _, _>(async move |_params, _| {
896 Ok(Some(Vec::new()))
897 });
898
899 open(&mut cx, LspPickerKind::References);
900
901 assert!(
902 active_picker(&mut cx).is_none(),
903 "an empty result should not open the picker"
904 );
905 }
906
907 #[gpui::test]
908 async fn test_definition_falls_back_to_references_picker(cx: &mut TestAppContext) {
909 let mut cx = rust_cx(
910 lsp::ServerCapabilities {
911 definition_provider: Some(lsp::OneOf::Left(true)),
912 references_provider: Some(lsp::OneOf::Left(true)),
913 ..Default::default()
914 },
915 cx,
916 )
917 .await;
918 cx.set_state(SOURCE);
919 cx.lsp
920 .set_request_handler::<lsp::request::GotoDefinition, _, _>(async move |_params, _| {
921 Ok(None)
922 });
923 cx.lsp
924 .set_request_handler::<lsp::request::References, _, _>(async move |params, _| {
925 let uri = params.text_document_position.text_document.uri;
926 Ok(Some(references(uri, &[(1, 8, 11), (2, 14, 17)])))
927 });
928
929 open(&mut cx, LspPickerKind::Definition);
930
931 assert!(
932 active_picker(&mut cx).is_some(),
933 "an empty definition query should fall back to the references picker"
934 );
935 }
936
937 #[gpui::test]
938 async fn test_fuzzy_filter_matches_subsequence(cx: &mut TestAppContext) {
939 let mut cx = rust_cx(
940 lsp::ServerCapabilities {
941 references_provider: Some(lsp::OneOf::Left(true)),
942 ..Default::default()
943 },
944 cx,
945 )
946 .await;
947 cx.set_state(SOURCE);
948 cx.lsp
949 .set_request_handler::<lsp::request::References, _, _>(async move |params, _| {
950 let uri = params.text_document_position.text_document.uri;
951 Ok(Some(references(uri, &[(1, 8, 11), (2, 14, 17)])))
952 });
953
954 open(&mut cx, LspPickerKind::References);
955 let modal = active_picker(&mut cx).expect("multiple references should open the picker");
956 let picker = cx.update(|_window, cx| modal.read(cx).picker.clone());
957
958 let matches = |cx: &mut EditorLspTestContext, query: &str| -> usize {
959 cx.update(|window, cx| {
960 picker.update(cx, |picker, cx| picker.set_query(query, window, cx));
961 });
962 cx.run_until_parked();
963 cx.update(|_window, cx| picker.read(cx).delegate.matches.len())
964 };
965
966 // "lx" is a subsequence of "let xyz" but not a substring of either line,
967 // so it only matches with fuzzy matching.
968 assert_eq!(matches(&mut cx, "lx"), 1);
969 assert_eq!(matches(&mut cx, "zzzz"), 0);
970 assert_eq!(matches(&mut cx, ""), 2);
971 }
972}
973