1use std::ops::Range;
2use std::{
3 cmp::{self, Reverse},
4 sync::Arc,
5};
6
7use editor::{scroll::Autoscroll, Anchor, AnchorRangeExt, Editor, EditorMode};
8use fuzzy::StringMatch;
9use gpui::{
10 div, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, HighlightStyle,
11 ParentElement, Point, Render, Styled, StyledText, Task, TextStyle, View, ViewContext,
12 VisualContext, WeakView, WindowContext,
13};
14use language::{Outline, OutlineItem};
15use ordered_float::OrderedFloat;
16use picker::{Picker, PickerDelegate};
17use settings::Settings;
18use theme::{color_alpha, ActiveTheme, ThemeSettings};
19use ui::{prelude::*, ListItem, ListItemSpacing};
20use util::ResultExt;
21use workspace::{DismissDecision, ModalView};
22
23pub fn init(cx: &mut AppContext) {
24 cx.observe_new_views(OutlineView::register).detach();
25 zed_actions::outline::TOGGLE_OUTLINE
26 .set(|view, cx| {
27 let Ok(view) = view.downcast::<Editor>() else {
28 return;
29 };
30
31 toggle(view, &Default::default(), cx);
32 })
33 .ok();
34}
35
36pub fn toggle(
37 editor: View<Editor>,
38 _: &zed_actions::outline::ToggleOutline,
39 cx: &mut WindowContext,
40) {
41 let outline = editor
42 .read(cx)
43 .buffer()
44 .read(cx)
45 .snapshot(cx)
46 .outline(Some(cx.theme().syntax()));
47
48 if let Some((workspace, outline)) = editor.read(cx).workspace().zip(outline) {
49 workspace.update(cx, |workspace, cx| {
50 workspace.toggle_modal(cx, |cx| OutlineView::new(outline, editor, cx));
51 })
52 }
53}
54
55pub struct OutlineView {
56 picker: View<Picker<OutlineViewDelegate>>,
57}
58
59impl FocusableView for OutlineView {
60 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
61 self.picker.focus_handle(cx)
62 }
63}
64
65impl EventEmitter<DismissEvent> for OutlineView {}
66impl ModalView for OutlineView {
67 fn on_before_dismiss(&mut self, cx: &mut ViewContext<Self>) -> DismissDecision {
68 self.picker
69 .update(cx, |picker, cx| picker.delegate.restore_active_editor(cx));
70 DismissDecision::Dismiss(true)
71 }
72}
73
74impl Render for OutlineView {
75 fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
76 v_flex().w(rems(34.)).child(self.picker.clone())
77 }
78}
79
80impl OutlineView {
81 fn register(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
82 if editor.mode() == EditorMode::Full {
83 let handle = cx.view().downgrade();
84 editor
85 .register_action(move |action, cx| {
86 if let Some(editor) = handle.upgrade() {
87 toggle(editor, action, cx);
88 }
89 })
90 .detach();
91 }
92 }
93
94 fn new(
95 outline: Outline<Anchor>,
96 editor: View<Editor>,
97 cx: &mut ViewContext<Self>,
98 ) -> OutlineView {
99 let delegate = OutlineViewDelegate::new(cx.view().downgrade(), outline, editor, cx);
100 let picker =
101 cx.new_view(|cx| Picker::uniform_list(delegate, cx).max_height(Some(vh(0.75, cx))));
102 OutlineView { picker }
103 }
104}
105
106struct OutlineViewDelegate {
107 outline_view: WeakView<OutlineView>,
108 active_editor: View<Editor>,
109 outline: Outline<Anchor>,
110 selected_match_index: usize,
111 prev_scroll_position: Option<Point<f32>>,
112 matches: Vec<StringMatch>,
113 last_query: String,
114}
115
116enum OutlineRowHighlights {}
117
118impl OutlineViewDelegate {
119 fn new(
120 outline_view: WeakView<OutlineView>,
121 outline: Outline<Anchor>,
122 editor: View<Editor>,
123 cx: &mut ViewContext<OutlineView>,
124 ) -> Self {
125 Self {
126 outline_view,
127 last_query: Default::default(),
128 matches: Default::default(),
129 selected_match_index: 0,
130 prev_scroll_position: Some(editor.update(cx, |editor, cx| editor.scroll_position(cx))),
131 active_editor: editor,
132 outline,
133 }
134 }
135
136 fn restore_active_editor(&mut self, cx: &mut WindowContext) {
137 self.active_editor.update(cx, |editor, cx| {
138 editor.clear_row_highlights::<OutlineRowHighlights>();
139 if let Some(scroll_position) = self.prev_scroll_position {
140 editor.set_scroll_position(scroll_position, cx);
141 }
142 })
143 }
144
145 fn set_selected_index(
146 &mut self,
147 ix: usize,
148 navigate: bool,
149 cx: &mut ViewContext<Picker<OutlineViewDelegate>>,
150 ) {
151 self.selected_match_index = ix;
152
153 if navigate && !self.matches.is_empty() {
154 let selected_match = &self.matches[self.selected_match_index];
155 let outline_item = &self.outline.items[selected_match.candidate_id];
156
157 self.active_editor.update(cx, |active_editor, cx| {
158 active_editor.clear_row_highlights::<OutlineRowHighlights>();
159 active_editor.highlight_rows::<OutlineRowHighlights>(
160 outline_item.range.start..outline_item.range.end,
161 cx.theme().colors().editor_highlighted_line_background,
162 true,
163 cx,
164 );
165 active_editor.request_autoscroll(Autoscroll::center(), cx);
166 });
167 }
168 }
169}
170
171impl PickerDelegate for OutlineViewDelegate {
172 type ListItem = ListItem;
173
174 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
175 "Search buffer symbols...".into()
176 }
177
178 fn match_count(&self) -> usize {
179 self.matches.len()
180 }
181
182 fn selected_index(&self) -> usize {
183 self.selected_match_index
184 }
185
186 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<OutlineViewDelegate>>) {
187 self.set_selected_index(ix, true, cx);
188 }
189
190 fn update_matches(
191 &mut self,
192 query: String,
193 cx: &mut ViewContext<Picker<OutlineViewDelegate>>,
194 ) -> Task<()> {
195 let selected_index;
196 if query.is_empty() {
197 self.restore_active_editor(cx);
198 self.matches = self
199 .outline
200 .items
201 .iter()
202 .enumerate()
203 .map(|(index, _)| StringMatch {
204 candidate_id: index,
205 score: Default::default(),
206 positions: Default::default(),
207 string: Default::default(),
208 })
209 .collect();
210
211 let (buffer, cursor_offset) = self.active_editor.update(cx, |editor, cx| {
212 let buffer = editor.buffer().read(cx).snapshot(cx);
213 let cursor_offset = editor.selections.newest::<usize>(cx).head();
214 (buffer, cursor_offset)
215 });
216 selected_index = self
217 .outline
218 .items
219 .iter()
220 .enumerate()
221 .map(|(ix, item)| {
222 let range = item.range.to_offset(&buffer);
223 let distance_to_closest_endpoint = cmp::min(
224 (range.start as isize - cursor_offset as isize).abs(),
225 (range.end as isize - cursor_offset as isize).abs(),
226 );
227 let depth = if range.contains(&cursor_offset) {
228 Some(item.depth)
229 } else {
230 None
231 };
232 (ix, depth, distance_to_closest_endpoint)
233 })
234 .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance)))
235 .map(|(ix, _, _)| ix)
236 .unwrap_or(0);
237 } else {
238 self.matches = smol::block_on(
239 self.outline
240 .search(&query, cx.background_executor().clone()),
241 );
242 selected_index = self
243 .matches
244 .iter()
245 .enumerate()
246 .max_by_key(|(_, m)| OrderedFloat(m.score))
247 .map(|(ix, _)| ix)
248 .unwrap_or(0);
249 }
250 self.last_query = query;
251 self.set_selected_index(selected_index, !self.last_query.is_empty(), cx);
252 Task::ready(())
253 }
254
255 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<OutlineViewDelegate>>) {
256 self.prev_scroll_position.take();
257
258 self.active_editor.update(cx, |active_editor, cx| {
259 let highlight = active_editor
260 .highlighted_rows::<OutlineRowHighlights>()
261 .next();
262 if let Some((rows, _)) = highlight {
263 active_editor.change_selections(Some(Autoscroll::center()), cx, |s| {
264 s.select_ranges([rows.start..rows.start])
265 });
266 active_editor.clear_row_highlights::<OutlineRowHighlights>();
267 active_editor.focus(cx);
268 }
269 });
270
271 self.dismissed(cx);
272 }
273
274 fn dismissed(&mut self, cx: &mut ViewContext<Picker<OutlineViewDelegate>>) {
275 self.outline_view
276 .update(cx, |_, cx| cx.emit(DismissEvent))
277 .log_err();
278 self.restore_active_editor(cx);
279 }
280
281 fn render_match(
282 &self,
283 ix: usize,
284 selected: bool,
285 cx: &mut ViewContext<Picker<Self>>,
286 ) -> Option<Self::ListItem> {
287 let mat = self.matches.get(ix)?;
288 let outline_item = self.outline.items.get(mat.candidate_id)?;
289
290 Some(
291 ListItem::new(ix)
292 .inset(true)
293 .spacing(ListItemSpacing::Sparse)
294 .toggle_state(selected)
295 .child(
296 div()
297 .text_ui(cx)
298 .pl(rems(outline_item.depth as f32))
299 .child(render_item(outline_item, mat.ranges(), cx)),
300 ),
301 )
302 }
303}
304
305pub fn render_item<T>(
306 outline_item: &OutlineItem<T>,
307 match_ranges: impl IntoIterator<Item = Range<usize>>,
308 cx: &AppContext,
309) -> StyledText {
310 let highlight_style = HighlightStyle {
311 background_color: Some(color_alpha(cx.theme().colors().text_accent, 0.3)),
312 ..Default::default()
313 };
314 let custom_highlights = match_ranges
315 .into_iter()
316 .map(|range| (range, highlight_style));
317
318 let settings = ThemeSettings::get_global(cx);
319
320 // TODO: We probably shouldn't need to build a whole new text style here
321 // but I'm not sure how to get the current one and modify it.
322 // Before this change TextStyle::default() was used here, which was giving us the wrong font and text color.
323 let text_style = TextStyle {
324 color: cx.theme().colors().text,
325 font_family: settings.buffer_font.family.clone(),
326 font_features: settings.buffer_font.features.clone(),
327 font_fallbacks: settings.buffer_font.fallbacks.clone(),
328 font_size: settings.buffer_font_size(cx).into(),
329 font_weight: settings.buffer_font.weight,
330 line_height: relative(1.),
331 ..Default::default()
332 };
333 let highlights = gpui::combine_highlights(
334 custom_highlights,
335 outline_item.highlight_ranges.iter().cloned(),
336 );
337
338 StyledText::new(outline_item.text.clone()).with_highlights(&text_style, highlights)
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use gpui::{TestAppContext, VisualTestContext};
345 use indoc::indoc;
346 use language::{Language, LanguageConfig, LanguageMatcher};
347 use project::{FakeFs, Project};
348 use serde_json::json;
349 use workspace::{AppState, Workspace};
350
351 #[gpui::test]
352 async fn test_outline_view_row_highlights(cx: &mut TestAppContext) {
353 init_test(cx);
354 let fs = FakeFs::new(cx.executor());
355 fs.insert_tree(
356 "/dir",
357 json!({
358 "a.rs": indoc!{"
359 struct SingleLine; // display line 0
360 // display line 1
361 struct MultiLine { // display line 2
362 field_1: i32, // display line 3
363 field_2: i32, // display line 4
364 } // display line 5
365 "}
366 }),
367 )
368 .await;
369
370 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
371 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
372
373 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
374 let worktree_id = workspace.update(cx, |workspace, cx| {
375 workspace.project().update(cx, |project, cx| {
376 project.worktrees(cx).next().unwrap().read(cx).id()
377 })
378 });
379 let _buffer = project
380 .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
381 .await
382 .unwrap();
383 let editor = workspace
384 .update(cx, |workspace, cx| {
385 workspace.open_path((worktree_id, "a.rs"), None, true, cx)
386 })
387 .await
388 .unwrap()
389 .downcast::<Editor>()
390 .unwrap();
391 let ensure_outline_view_contents =
392 |outline_view: &View<Picker<OutlineViewDelegate>>, cx: &mut VisualTestContext| {
393 assert_eq!(query(outline_view, cx), "");
394 assert_eq!(
395 outline_names(outline_view, cx),
396 vec![
397 "struct SingleLine",
398 "struct MultiLine",
399 "field_1",
400 "field_2"
401 ],
402 );
403 };
404
405 let outline_view = open_outline_view(&workspace, cx);
406 ensure_outline_view_contents(&outline_view, cx);
407 assert_eq!(
408 highlighted_display_rows(&editor, cx),
409 Vec::<u32>::new(),
410 "Initially opened outline view should have no highlights"
411 );
412 assert_single_caret_at_row(&editor, 0, cx);
413
414 cx.dispatch_action(menu::SelectNext);
415 ensure_outline_view_contents(&outline_view, cx);
416 assert_eq!(
417 highlighted_display_rows(&editor, cx),
418 vec![2, 3, 4, 5],
419 "Second struct's rows should be highlighted"
420 );
421 assert_single_caret_at_row(&editor, 0, cx);
422
423 cx.dispatch_action(menu::SelectPrev);
424 ensure_outline_view_contents(&outline_view, cx);
425 assert_eq!(
426 highlighted_display_rows(&editor, cx),
427 vec![0],
428 "First struct's row should be highlighted"
429 );
430 assert_single_caret_at_row(&editor, 0, cx);
431
432 cx.dispatch_action(menu::Cancel);
433 ensure_outline_view_contents(&outline_view, cx);
434 assert_eq!(
435 highlighted_display_rows(&editor, cx),
436 Vec::<u32>::new(),
437 "No rows should be highlighted after outline view is cancelled and closed"
438 );
439 assert_single_caret_at_row(&editor, 0, cx);
440
441 let outline_view = open_outline_view(&workspace, cx);
442 ensure_outline_view_contents(&outline_view, cx);
443 assert_eq!(
444 highlighted_display_rows(&editor, cx),
445 Vec::<u32>::new(),
446 "Reopened outline view should have no highlights"
447 );
448 assert_single_caret_at_row(&editor, 0, cx);
449
450 let expected_first_highlighted_row = 2;
451 cx.dispatch_action(menu::SelectNext);
452 ensure_outline_view_contents(&outline_view, cx);
453 assert_eq!(
454 highlighted_display_rows(&editor, cx),
455 vec![expected_first_highlighted_row, 3, 4, 5]
456 );
457 assert_single_caret_at_row(&editor, 0, cx);
458 cx.dispatch_action(menu::Confirm);
459 ensure_outline_view_contents(&outline_view, cx);
460 assert_eq!(
461 highlighted_display_rows(&editor, cx),
462 Vec::<u32>::new(),
463 "No rows should be highlighted after outline view is confirmed and closed"
464 );
465 // On confirm, should place the caret on the first row of the highlighted rows range.
466 assert_single_caret_at_row(&editor, expected_first_highlighted_row, cx);
467 }
468
469 fn open_outline_view(
470 workspace: &View<Workspace>,
471 cx: &mut VisualTestContext,
472 ) -> View<Picker<OutlineViewDelegate>> {
473 cx.dispatch_action(zed_actions::outline::ToggleOutline);
474 workspace.update(cx, |workspace, cx| {
475 workspace
476 .active_modal::<OutlineView>(cx)
477 .unwrap()
478 .read(cx)
479 .picker
480 .clone()
481 })
482 }
483
484 fn query(
485 outline_view: &View<Picker<OutlineViewDelegate>>,
486 cx: &mut VisualTestContext,
487 ) -> String {
488 outline_view.update(cx, |outline_view, cx| outline_view.query(cx))
489 }
490
491 fn outline_names(
492 outline_view: &View<Picker<OutlineViewDelegate>>,
493 cx: &mut VisualTestContext,
494 ) -> Vec<String> {
495 outline_view.update(cx, |outline_view, _| {
496 let items = &outline_view.delegate.outline.items;
497 outline_view
498 .delegate
499 .matches
500 .iter()
501 .map(|hit| items[hit.candidate_id].text.clone())
502 .collect::<Vec<_>>()
503 })
504 }
505
506 fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
507 editor.update(cx, |editor, cx| {
508 editor
509 .highlighted_display_rows(cx)
510 .into_keys()
511 .map(|r| r.0)
512 .collect()
513 })
514 }
515
516 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
517 cx.update(|cx| {
518 let state = AppState::test(cx);
519 language::init(cx);
520 crate::init(cx);
521 editor::init(cx);
522 workspace::init_settings(cx);
523 Project::init_settings(cx);
524 state
525 })
526 }
527
528 fn rust_lang() -> Arc<Language> {
529 Arc::new(
530 Language::new(
531 LanguageConfig {
532 name: "Rust".into(),
533 matcher: LanguageMatcher {
534 path_suffixes: vec!["rs".to_string()],
535 ..Default::default()
536 },
537 ..Default::default()
538 },
539 Some(tree_sitter_rust::LANGUAGE.into()),
540 )
541 .with_outline_query(
542 r#"(struct_item
543 (visibility_modifier)? @context
544 "struct" @context
545 name: (_) @name) @item
546
547 (enum_item
548 (visibility_modifier)? @context
549 "enum" @context
550 name: (_) @name) @item
551
552 (enum_variant
553 (visibility_modifier)? @context
554 name: (_) @name) @item
555
556 (impl_item
557 "impl" @context
558 trait: (_)? @name
559 "for"? @context
560 type: (_) @name) @item
561
562 (trait_item
563 (visibility_modifier)? @context
564 "trait" @context
565 name: (_) @name) @item
566
567 (function_item
568 (visibility_modifier)? @context
569 (function_modifiers)? @context
570 "fn" @context
571 name: (_) @name) @item
572
573 (function_signature_item
574 (visibility_modifier)? @context
575 (function_modifiers)? @context
576 "fn" @context
577 name: (_) @name) @item
578
579 (macro_definition
580 . "macro_rules!" @context
581 name: (_) @name) @item
582
583 (mod_item
584 (visibility_modifier)? @context
585 "mod" @context
586 name: (_) @name) @item
587
588 (type_item
589 (visibility_modifier)? @context
590 "type" @context
591 name: (_) @name) @item
592
593 (associated_type
594 "type" @context
595 name: (_) @name) @item
596
597 (const_item
598 (visibility_modifier)? @context
599 "const" @context
600 name: (_) @name) @item
601
602 (field_declaration
603 (visibility_modifier)? @context
604 name: (_) @name) @item
605"#,
606 )
607 .unwrap(),
608 )
609 }
610
611 #[track_caller]
612 fn assert_single_caret_at_row(
613 editor: &View<Editor>,
614 buffer_row: u32,
615 cx: &mut VisualTestContext,
616 ) {
617 let selections = editor.update(cx, |editor, cx| {
618 editor
619 .selections
620 .all::<rope::Point>(cx)
621 .into_iter()
622 .map(|s| s.start..s.end)
623 .collect::<Vec<_>>()
624 });
625 assert!(
626 selections.len() == 1,
627 "Expected one caret selection but got: {selections:?}"
628 );
629 let selection = &selections[0];
630 assert!(
631 selection.start == selection.end,
632 "Expected a single caret selection, but got: {selection:?}"
633 );
634 assert_eq!(selection.start.row, buffer_row);
635 }
636}