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