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