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 editor = self.active_editor.read(cx);
198 let cursor_offset = editor.selections.newest::<usize>(cx).head();
199 let buffer = editor.buffer().read(cx).snapshot(cx);
200 selected_index = self
201 .outline
202 .items
203 .iter()
204 .enumerate()
205 .map(|(ix, item)| {
206 let range = item.range.to_offset(&buffer);
207 let distance_to_closest_endpoint = cmp::min(
208 (range.start as isize - cursor_offset as isize).abs(),
209 (range.end as isize - cursor_offset as isize).abs(),
210 );
211 let depth = if range.contains(&cursor_offset) {
212 Some(item.depth)
213 } else {
214 None
215 };
216 (ix, depth, distance_to_closest_endpoint)
217 })
218 .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance)))
219 .map(|(ix, _, _)| ix)
220 .unwrap_or(0);
221 } else {
222 self.matches = smol::block_on(
223 self.outline
224 .search(&query, cx.background_executor().clone()),
225 );
226 selected_index = self
227 .matches
228 .iter()
229 .enumerate()
230 .max_by_key(|(_, m)| OrderedFloat(m.score))
231 .map(|(ix, _)| ix)
232 .unwrap_or(0);
233 }
234 self.last_query = query;
235 self.set_selected_index(selected_index, !self.last_query.is_empty(), cx);
236 Task::ready(())
237 }
238
239 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<OutlineViewDelegate>>) {
240 self.prev_scroll_position.take();
241
242 self.active_editor.update(cx, |active_editor, cx| {
243 let highlight = active_editor
244 .highlighted_rows::<OutlineRowHighlights>()
245 .next();
246 if let Some((rows, _)) = highlight {
247 active_editor.change_selections(Some(Autoscroll::center()), cx, |s| {
248 s.select_ranges([rows.start..rows.start])
249 });
250 active_editor.clear_row_highlights::<OutlineRowHighlights>();
251 active_editor.focus(cx);
252 }
253 });
254
255 self.dismissed(cx);
256 }
257
258 fn dismissed(&mut self, cx: &mut ViewContext<Picker<OutlineViewDelegate>>) {
259 self.outline_view
260 .update(cx, |_, cx| cx.emit(DismissEvent))
261 .log_err();
262 self.restore_active_editor(cx);
263 }
264
265 fn render_match(
266 &self,
267 ix: usize,
268 selected: bool,
269 cx: &mut ViewContext<Picker<Self>>,
270 ) -> Option<Self::ListItem> {
271 let mat = self.matches.get(ix)?;
272 let outline_item = self.outline.items.get(mat.candidate_id)?;
273
274 Some(
275 ListItem::new(ix)
276 .inset(true)
277 .spacing(ListItemSpacing::Sparse)
278 .selected(selected)
279 .child(
280 div()
281 .text_ui(cx)
282 .pl(rems(outline_item.depth as f32))
283 .child(language::render_item(outline_item, mat.ranges(), cx)),
284 ),
285 )
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use gpui::{TestAppContext, VisualTestContext};
293 use indoc::indoc;
294 use language::{Language, LanguageConfig, LanguageMatcher};
295 use project::{FakeFs, Project};
296 use serde_json::json;
297 use workspace::{AppState, Workspace};
298
299 #[gpui::test]
300 async fn test_outline_view_row_highlights(cx: &mut TestAppContext) {
301 init_test(cx);
302 let fs = FakeFs::new(cx.executor());
303 fs.insert_tree(
304 "/dir",
305 json!({
306 "a.rs": indoc!{"
307 struct SingleLine; // display line 0
308 // display line 1
309 struct MultiLine { // display line 2
310 field_1: i32, // display line 3
311 field_2: i32, // display line 4
312 } // display line 5
313 "}
314 }),
315 )
316 .await;
317
318 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
319 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
320
321 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
322 let worktree_id = workspace.update(cx, |workspace, cx| {
323 workspace.project().update(cx, |project, cx| {
324 project.worktrees(cx).next().unwrap().read(cx).id()
325 })
326 });
327 let _buffer = project
328 .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
329 .await
330 .unwrap();
331 let editor = workspace
332 .update(cx, |workspace, cx| {
333 workspace.open_path((worktree_id, "a.rs"), None, true, cx)
334 })
335 .await
336 .unwrap()
337 .downcast::<Editor>()
338 .unwrap();
339 let ensure_outline_view_contents =
340 |outline_view: &View<Picker<OutlineViewDelegate>>, cx: &mut VisualTestContext| {
341 assert_eq!(query(outline_view, cx), "");
342 assert_eq!(
343 outline_names(outline_view, cx),
344 vec![
345 "struct SingleLine",
346 "struct MultiLine",
347 "field_1",
348 "field_2"
349 ],
350 );
351 };
352
353 let outline_view = open_outline_view(&workspace, cx);
354 ensure_outline_view_contents(&outline_view, cx);
355 assert_eq!(
356 highlighted_display_rows(&editor, cx),
357 Vec::<u32>::new(),
358 "Initially opened outline view should have no highlights"
359 );
360 assert_single_caret_at_row(&editor, 0, cx);
361
362 cx.dispatch_action(menu::SelectNext);
363 ensure_outline_view_contents(&outline_view, cx);
364 assert_eq!(
365 highlighted_display_rows(&editor, cx),
366 vec![2, 3, 4, 5],
367 "Second struct's rows should be highlighted"
368 );
369 assert_single_caret_at_row(&editor, 0, cx);
370
371 cx.dispatch_action(menu::SelectPrev);
372 ensure_outline_view_contents(&outline_view, cx);
373 assert_eq!(
374 highlighted_display_rows(&editor, cx),
375 vec![0],
376 "First struct's row should be highlighted"
377 );
378 assert_single_caret_at_row(&editor, 0, cx);
379
380 cx.dispatch_action(menu::Cancel);
381 ensure_outline_view_contents(&outline_view, cx);
382 assert_eq!(
383 highlighted_display_rows(&editor, cx),
384 Vec::<u32>::new(),
385 "No rows should be highlighted after outline view is cancelled and closed"
386 );
387 assert_single_caret_at_row(&editor, 0, cx);
388
389 let outline_view = open_outline_view(&workspace, cx);
390 ensure_outline_view_contents(&outline_view, cx);
391 assert_eq!(
392 highlighted_display_rows(&editor, cx),
393 Vec::<u32>::new(),
394 "Reopened outline view should have no highlights"
395 );
396 assert_single_caret_at_row(&editor, 0, cx);
397
398 let expected_first_highlighted_row = 2;
399 cx.dispatch_action(menu::SelectNext);
400 ensure_outline_view_contents(&outline_view, cx);
401 assert_eq!(
402 highlighted_display_rows(&editor, cx),
403 vec![expected_first_highlighted_row, 3, 4, 5]
404 );
405 assert_single_caret_at_row(&editor, 0, cx);
406 cx.dispatch_action(menu::Confirm);
407 ensure_outline_view_contents(&outline_view, cx);
408 assert_eq!(
409 highlighted_display_rows(&editor, cx),
410 Vec::<u32>::new(),
411 "No rows should be highlighted after outline view is confirmed and closed"
412 );
413 // On confirm, should place the caret on the first row of the highlighted rows range.
414 assert_single_caret_at_row(&editor, expected_first_highlighted_row, cx);
415 }
416
417 fn open_outline_view(
418 workspace: &View<Workspace>,
419 cx: &mut VisualTestContext,
420 ) -> View<Picker<OutlineViewDelegate>> {
421 cx.dispatch_action(ToggleOutline);
422 workspace.update(cx, |workspace, cx| {
423 workspace
424 .active_modal::<OutlineView>(cx)
425 .unwrap()
426 .read(cx)
427 .picker
428 .clone()
429 })
430 }
431
432 fn query(
433 outline_view: &View<Picker<OutlineViewDelegate>>,
434 cx: &mut VisualTestContext,
435 ) -> String {
436 outline_view.update(cx, |outline_view, cx| outline_view.query(cx))
437 }
438
439 fn outline_names(
440 outline_view: &View<Picker<OutlineViewDelegate>>,
441 cx: &mut VisualTestContext,
442 ) -> Vec<String> {
443 outline_view.update(cx, |outline_view, _| {
444 let items = &outline_view.delegate.outline.items;
445 outline_view
446 .delegate
447 .matches
448 .iter()
449 .map(|hit| items[hit.candidate_id].text.clone())
450 .collect::<Vec<_>>()
451 })
452 }
453
454 fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
455 editor.update(cx, |editor, cx| {
456 editor
457 .highlighted_display_rows(cx)
458 .into_keys()
459 .map(|r| r.0)
460 .collect()
461 })
462 }
463
464 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
465 cx.update(|cx| {
466 let state = AppState::test(cx);
467 language::init(cx);
468 crate::init(cx);
469 editor::init(cx);
470 workspace::init_settings(cx);
471 Project::init_settings(cx);
472 state
473 })
474 }
475
476 fn rust_lang() -> Arc<Language> {
477 Arc::new(
478 Language::new(
479 LanguageConfig {
480 name: "Rust".into(),
481 matcher: LanguageMatcher {
482 path_suffixes: vec!["rs".to_string()],
483 ..Default::default()
484 },
485 ..Default::default()
486 },
487 Some(tree_sitter_rust::LANGUAGE.into()),
488 )
489 .with_outline_query(
490 r#"(struct_item
491 (visibility_modifier)? @context
492 "struct" @context
493 name: (_) @name) @item
494
495 (enum_item
496 (visibility_modifier)? @context
497 "enum" @context
498 name: (_) @name) @item
499
500 (enum_variant
501 (visibility_modifier)? @context
502 name: (_) @name) @item
503
504 (impl_item
505 "impl" @context
506 trait: (_)? @name
507 "for"? @context
508 type: (_) @name) @item
509
510 (trait_item
511 (visibility_modifier)? @context
512 "trait" @context
513 name: (_) @name) @item
514
515 (function_item
516 (visibility_modifier)? @context
517 (function_modifiers)? @context
518 "fn" @context
519 name: (_) @name) @item
520
521 (function_signature_item
522 (visibility_modifier)? @context
523 (function_modifiers)? @context
524 "fn" @context
525 name: (_) @name) @item
526
527 (macro_definition
528 . "macro_rules!" @context
529 name: (_) @name) @item
530
531 (mod_item
532 (visibility_modifier)? @context
533 "mod" @context
534 name: (_) @name) @item
535
536 (type_item
537 (visibility_modifier)? @context
538 "type" @context
539 name: (_) @name) @item
540
541 (associated_type
542 "type" @context
543 name: (_) @name) @item
544
545 (const_item
546 (visibility_modifier)? @context
547 "const" @context
548 name: (_) @name) @item
549
550 (field_declaration
551 (visibility_modifier)? @context
552 name: (_) @name) @item
553"#,
554 )
555 .unwrap(),
556 )
557 }
558
559 #[track_caller]
560 fn assert_single_caret_at_row(
561 editor: &View<Editor>,
562 buffer_row: u32,
563 cx: &mut VisualTestContext,
564 ) {
565 let selections = editor.update(cx, |editor, cx| {
566 editor
567 .selections
568 .all::<rope::Point>(cx)
569 .into_iter()
570 .map(|s| s.start..s.end)
571 .collect::<Vec<_>>()
572 });
573 assert!(
574 selections.len() == 1,
575 "Expected one caret selection but got: {selections:?}"
576 );
577 let selection = &selections[0];
578 assert!(
579 selection.start == selection.end,
580 "Expected a single caret selection, but got: {selection:?}"
581 );
582 assert_eq!(selection.start.row, buffer_row);
583 }
584}