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