1mod outline_panel_settings;
2
3use anyhow::Context as _;
4use collections::{BTreeSet, HashMap, HashSet};
5use db::kvp::KeyValueStore;
6use editor::{
7 AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, ExcerptRange, MultiBufferSnapshot,
8 RangeToAnchorExt, SelectionEffects,
9 display_map::ToDisplayPoint,
10 items::{entry_git_aware_label_color, entry_label_color},
11 scroll::{Autoscroll, ScrollAnchor},
12};
13use file_icons::FileIcons;
14
15use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
16use gpui::{
17 Action, AnyElement, App, AppContext as _, AsyncWindowContext, Bounds, ClipboardItem, Context,
18 DismissEvent, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle,
19 InteractiveElement, IntoElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
20 MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollStrategy,
21 SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription, Task,
22 UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, point, px, size,
23 uniform_list,
24};
25use itertools::Itertools;
26use language::language_settings::LanguageSettings;
27use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
28
29use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
30use std::{
31 cmp,
32 collections::BTreeMap,
33 hash::Hash,
34 ops::Range,
35 path::{Path, PathBuf},
36 sync::{
37 Arc, OnceLock,
38 atomic::{self, AtomicBool},
39 },
40 time::Duration,
41 u32,
42};
43
44use outline_panel_settings::{DockSide, OutlinePanelSettings, ShowIndentGuides};
45use project::{File, Fs, GitEntry, GitTraversal, Project, ProjectItem};
46use search::{BufferSearchBar, ProjectSearchView};
47use serde::{Deserialize, Serialize};
48use settings::{Settings, SettingsStore};
49use smol::channel;
50use theme::SyntaxTheme;
51use theme_settings::ThemeSettings;
52use ui::{
53 ContextMenu, FluentBuilder, HighlightedLabel, IconButton, IconButtonShape, IndentGuideColors,
54 IndentGuideLayout, ListItem, ScrollAxes, Scrollbars, Tab, Tooltip, WithScrollbar, prelude::*,
55};
56use util::{RangeExt, ResultExt, TryFutureExt, debug_panic, rel_path::RelPath};
57use workspace::{
58 OpenInTerminal, WeakItemHandle, Workspace,
59 dock::{DockPosition, Panel, PanelEvent},
60 item::ItemHandle,
61 searchable::{SearchEvent, SearchableItem},
62};
63use worktree::{Entry, ProjectEntryId, WorktreeId};
64
65use crate::outline_panel_settings::OutlinePanelSettingsScrollbarProxy;
66
67actions!(
68 outline_panel,
69 [
70 /// Collapses all entries in the outline tree.
71 CollapseAllEntries,
72 /// Collapses the currently selected entry.
73 CollapseSelectedEntry,
74 /// Expands all entries in the outline tree.
75 ExpandAllEntries,
76 /// Expands the currently selected entry.
77 ExpandSelectedEntry,
78 /// Folds the selected directory.
79 FoldDirectory,
80 /// Opens the selected entry in the editor.
81 OpenSelectedEntry,
82 /// Reveals the selected item in the system file manager.
83 RevealInFileManager,
84 /// Scroll half a page upwards
85 ScrollUp,
86 /// Scroll half a page downwards
87 ScrollDown,
88 /// Scroll until the cursor displays at the center
89 ScrollCursorCenter,
90 /// Scroll until the cursor displays at the top
91 ScrollCursorTop,
92 /// Scroll until the cursor displays at the bottom
93 ScrollCursorBottom,
94 /// Selects the parent of the current entry.
95 SelectParent,
96 /// Toggles the pin status of the active editor.
97 ToggleActiveEditorPin,
98 /// Unfolds the selected directory.
99 UnfoldDirectory,
100 /// Toggles the outline panel.
101 Toggle,
102 /// Toggles focus on the outline panel.
103 ToggleFocus,
104 ]
105);
106
107const OUTLINE_PANEL_KEY: &str = "OutlinePanel";
108const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
109
110type Outline = OutlineItem<language::Anchor>;
111type HighlightStyleData = Arc<OnceLock<Vec<(Range<usize>, HighlightStyle)>>>;
112
113pub struct OutlinePanel {
114 fs: Arc<dyn Fs>,
115 project: Entity<Project>,
116 workspace: WeakEntity<Workspace>,
117 active: bool,
118 pinned: bool,
119 scroll_handle: UniformListScrollHandle,
120 rendered_entries_len: usize,
121 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
122 focus_handle: FocusHandle,
123 pending_serialization: Task<Option<()>>,
124 fs_entries_depth: HashMap<(WorktreeId, ProjectEntryId), usize>,
125 fs_entries: Vec<FsEntry>,
126 fs_children_count: HashMap<WorktreeId, HashMap<Arc<RelPath>, FsChildren>>,
127 collapsed_entries: HashSet<CollapsedEntry>,
128 unfolded_dirs: HashMap<WorktreeId, BTreeSet<ProjectEntryId>>,
129 selected_entry: SelectedEntry,
130 active_item: Option<ActiveItem>,
131 _subscriptions: Vec<Subscription>,
132 new_entries_for_fs_update: HashSet<BufferId>,
133 fs_entries_update_task: Task<()>,
134 cached_entries_update_task: Task<()>,
135 reveal_selection_task: Task<anyhow::Result<()>>,
136 outline_fetch_tasks: HashMap<BufferId, Task<()>>,
137 buffers: HashMap<BufferId, BufferOutlines>,
138 cached_entries: Vec<CachedEntry>,
139 filter_editor: Entity<Editor>,
140 mode: ItemsDisplayMode,
141 max_width_item_index: Option<usize>,
142 preserve_selection_on_buffer_fold_toggles: HashSet<BufferId>,
143 pending_default_expansion_depth: Option<usize>,
144 outline_children_cache: HashMap<BufferId, HashMap<(Range<Anchor>, usize), bool>>,
145}
146
147#[derive(Debug)]
148enum ItemsDisplayMode {
149 Search(SearchState),
150 Outline,
151}
152
153#[derive(Debug)]
154struct SearchState {
155 kind: SearchKind,
156 query: String,
157 matches: Vec<(Range<editor::Anchor>, Arc<OnceLock<SearchData>>)>,
158 highlight_search_match_tx: channel::Sender<HighlightArguments>,
159 _search_match_highlighter: Task<()>,
160 _search_match_notify: Task<()>,
161}
162
163struct HighlightArguments {
164 multi_buffer_snapshot: MultiBufferSnapshot,
165 match_range: Range<editor::Anchor>,
166 search_data: Arc<OnceLock<SearchData>>,
167}
168
169impl SearchState {
170 fn new(
171 kind: SearchKind,
172 query: String,
173 previous_matches: HashMap<Range<editor::Anchor>, Arc<OnceLock<SearchData>>>,
174 new_matches: Vec<Range<editor::Anchor>>,
175 theme: Arc<SyntaxTheme>,
176 window: &mut Window,
177 cx: &mut Context<OutlinePanel>,
178 ) -> Self {
179 let (highlight_search_match_tx, highlight_search_match_rx) = channel::unbounded();
180 let (notify_tx, notify_rx) = channel::unbounded::<()>();
181 Self {
182 kind,
183 query,
184 matches: new_matches
185 .into_iter()
186 .map(|range| {
187 let search_data = previous_matches
188 .get(&range)
189 .map(Arc::clone)
190 .unwrap_or_default();
191 (range, search_data)
192 })
193 .collect(),
194 highlight_search_match_tx,
195 _search_match_highlighter: cx.background_spawn(async move {
196 while let Ok(highlight_arguments) = highlight_search_match_rx.recv().await {
197 let needs_init = highlight_arguments.search_data.get().is_none();
198 let search_data = highlight_arguments.search_data.get_or_init(|| {
199 SearchData::new(
200 &highlight_arguments.match_range,
201 &highlight_arguments.multi_buffer_snapshot,
202 )
203 });
204 if needs_init {
205 notify_tx.try_send(()).ok();
206 }
207
208 let highlight_data = &search_data.highlights_data;
209 if highlight_data.get().is_some() {
210 continue;
211 }
212 let mut left_whitespaces_count = 0;
213 let mut non_whitespace_symbol_occurred = false;
214 let context_offset_range = search_data
215 .context_range
216 .to_offset(&highlight_arguments.multi_buffer_snapshot);
217 let mut offset = context_offset_range.start;
218 let mut context_text = String::new();
219 let mut highlight_ranges = Vec::new();
220 for mut chunk in highlight_arguments
221 .multi_buffer_snapshot
222 .chunks(context_offset_range.start..context_offset_range.end, true)
223 {
224 if !non_whitespace_symbol_occurred {
225 for c in chunk.text.chars() {
226 if c.is_whitespace() {
227 left_whitespaces_count += c.len_utf8();
228 } else {
229 non_whitespace_symbol_occurred = true;
230 break;
231 }
232 }
233 }
234
235 if chunk.text.len() > context_offset_range.end - offset {
236 chunk.text = &chunk.text[0..(context_offset_range.end - offset)];
237 offset = context_offset_range.end;
238 } else {
239 offset += chunk.text.len();
240 }
241 let style = chunk
242 .syntax_highlight_id
243 .and_then(|highlight| theme.get(highlight).cloned());
244
245 if let Some(style) = style {
246 let start = context_text.len();
247 let end = start + chunk.text.len();
248 highlight_ranges.push((start..end, style));
249 }
250 context_text.push_str(chunk.text);
251 if offset >= context_offset_range.end {
252 break;
253 }
254 }
255
256 highlight_ranges.iter_mut().for_each(|(range, _)| {
257 range.start = range.start.saturating_sub(left_whitespaces_count);
258 range.end = range.end.saturating_sub(left_whitespaces_count);
259 });
260 if highlight_data.set(highlight_ranges).ok().is_some() {
261 notify_tx.try_send(()).ok();
262 }
263
264 let trimmed_text = context_text[left_whitespaces_count..].to_owned();
265 debug_assert_eq!(
266 trimmed_text, search_data.context_text,
267 "Highlighted text that does not match the buffer text"
268 );
269 }
270 }),
271 _search_match_notify: cx.spawn_in(window, async move |outline_panel, cx| {
272 loop {
273 match notify_rx.recv().await {
274 Ok(()) => {}
275 Err(_) => break,
276 };
277 while let Ok(()) = notify_rx.try_recv() {
278 //
279 }
280 let update_result = outline_panel.update(cx, |_, cx| {
281 cx.notify();
282 });
283 if update_result.is_err() {
284 break;
285 }
286 }
287 }),
288 }
289 }
290}
291
292#[derive(Debug)]
293enum SelectedEntry {
294 Invalidated(Option<PanelEntry>),
295 Valid(PanelEntry, usize),
296 None,
297}
298
299impl SelectedEntry {
300 fn invalidate(&mut self) {
301 match std::mem::replace(self, SelectedEntry::None) {
302 Self::Valid(entry, _) => *self = Self::Invalidated(Some(entry)),
303 Self::None => *self = Self::Invalidated(None),
304 other => *self = other,
305 }
306 }
307
308 fn is_invalidated(&self) -> bool {
309 matches!(self, Self::Invalidated(_))
310 }
311}
312
313#[derive(Debug, Clone, Copy, Default)]
314struct FsChildren {
315 files: usize,
316 dirs: usize,
317}
318
319impl FsChildren {
320 fn may_be_fold_part(&self) -> bool {
321 self.dirs == 0 || (self.dirs == 1 && self.files == 0)
322 }
323}
324
325#[derive(Clone, Debug)]
326struct CachedEntry {
327 depth: usize,
328 string_match: Option<StringMatch>,
329 entry: PanelEntry,
330}
331
332#[derive(Clone, Debug, PartialEq, Eq, Hash)]
333enum CollapsedEntry {
334 Dir(WorktreeId, ProjectEntryId),
335 File(WorktreeId, BufferId),
336 ExternalFile(BufferId),
337 Excerpt(ExcerptRange<Anchor>),
338 Outline(Range<Anchor>),
339}
340
341struct BufferOutlines {
342 excerpts: Vec<ExcerptRange<Anchor>>,
343 outlines: OutlineState,
344}
345
346impl BufferOutlines {
347 fn invalidate_outlines(&mut self) {
348 if let OutlineState::Outlines(valid_outlines) = &mut self.outlines {
349 self.outlines = OutlineState::Invalidated(std::mem::take(valid_outlines));
350 }
351 }
352
353 fn iter_outlines(&self) -> impl Iterator<Item = &Outline> {
354 match &self.outlines {
355 OutlineState::Outlines(outlines) => outlines.iter(),
356 OutlineState::Invalidated(outlines) => outlines.iter(),
357 OutlineState::NotFetched => [].iter(),
358 }
359 }
360
361 fn should_fetch_outlines(&self) -> bool {
362 match &self.outlines {
363 OutlineState::Outlines(_) => false,
364 OutlineState::Invalidated(_) => true,
365 OutlineState::NotFetched => true,
366 }
367 }
368}
369
370#[derive(Debug)]
371enum OutlineState {
372 Outlines(Vec<Outline>),
373 Invalidated(Vec<Outline>),
374 NotFetched,
375}
376
377#[derive(Clone, Debug, PartialEq, Eq)]
378struct FoldedDirsEntry {
379 worktree_id: WorktreeId,
380 entries: Vec<GitEntry>,
381}
382
383// TODO: collapse the inner enums into panel entry
384#[derive(Clone, Debug)]
385enum PanelEntry {
386 Fs(FsEntry),
387 FoldedDirs(FoldedDirsEntry),
388 Outline(OutlineEntry),
389 Search(SearchEntry),
390}
391
392#[derive(Clone, Debug)]
393struct SearchEntry {
394 match_range: Range<editor::Anchor>,
395 kind: SearchKind,
396 render_data: Arc<OnceLock<SearchData>>,
397}
398
399#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
400enum SearchKind {
401 Project,
402 Buffer,
403}
404
405#[derive(Clone, Debug)]
406struct SearchData {
407 context_range: Range<editor::Anchor>,
408 context_text: String,
409 truncated_left: bool,
410 truncated_right: bool,
411 search_match_indices: Vec<Range<usize>>,
412 highlights_data: HighlightStyleData,
413}
414
415impl PartialEq for PanelEntry {
416 fn eq(&self, other: &Self) -> bool {
417 match (self, other) {
418 (Self::Fs(a), Self::Fs(b)) => a == b,
419 (
420 Self::FoldedDirs(FoldedDirsEntry {
421 worktree_id: worktree_id_a,
422 entries: entries_a,
423 }),
424 Self::FoldedDirs(FoldedDirsEntry {
425 worktree_id: worktree_id_b,
426 entries: entries_b,
427 }),
428 ) => worktree_id_a == worktree_id_b && entries_a == entries_b,
429 (Self::Outline(a), Self::Outline(b)) => a == b,
430 (
431 Self::Search(SearchEntry {
432 match_range: match_range_a,
433 kind: kind_a,
434 ..
435 }),
436 Self::Search(SearchEntry {
437 match_range: match_range_b,
438 kind: kind_b,
439 ..
440 }),
441 ) => match_range_a == match_range_b && kind_a == kind_b,
442 _ => false,
443 }
444 }
445}
446
447impl Eq for PanelEntry {}
448
449const SEARCH_MATCH_CONTEXT_SIZE: u32 = 40;
450const TRUNCATED_CONTEXT_MARK: &str = "…";
451
452impl SearchData {
453 fn new(
454 match_range: &Range<editor::Anchor>,
455 multi_buffer_snapshot: &MultiBufferSnapshot,
456 ) -> Self {
457 let match_point_range = match_range.to_point(multi_buffer_snapshot);
458 let context_left_border = multi_buffer_snapshot.clip_point(
459 language::Point::new(
460 match_point_range.start.row,
461 match_point_range
462 .start
463 .column
464 .saturating_sub(SEARCH_MATCH_CONTEXT_SIZE),
465 ),
466 Bias::Left,
467 );
468 let context_right_border = multi_buffer_snapshot.clip_point(
469 language::Point::new(
470 match_point_range.end.row,
471 match_point_range.end.column + SEARCH_MATCH_CONTEXT_SIZE,
472 ),
473 Bias::Right,
474 );
475
476 let context_anchor_range =
477 (context_left_border..context_right_border).to_anchors(multi_buffer_snapshot);
478 let context_offset_range = context_anchor_range.to_offset(multi_buffer_snapshot);
479 let match_offset_range = match_range.to_offset(multi_buffer_snapshot);
480
481 let mut search_match_indices = vec![
482 match_offset_range.start - context_offset_range.start
483 ..match_offset_range.end - context_offset_range.start,
484 ];
485
486 let entire_context_text = multi_buffer_snapshot
487 .text_for_range(context_offset_range.clone())
488 .collect::<String>();
489 let left_whitespaces_offset = entire_context_text
490 .chars()
491 .take_while(|c| c.is_whitespace())
492 .map(|c| c.len_utf8())
493 .sum::<usize>();
494
495 let mut extended_context_left_border = context_left_border;
496 extended_context_left_border.column = extended_context_left_border.column.saturating_sub(1);
497 let extended_context_left_border =
498 multi_buffer_snapshot.clip_point(extended_context_left_border, Bias::Left);
499 let mut extended_context_right_border = context_right_border;
500 extended_context_right_border.column += 1;
501 let extended_context_right_border =
502 multi_buffer_snapshot.clip_point(extended_context_right_border, Bias::Right);
503
504 let truncated_left = left_whitespaces_offset == 0
505 && extended_context_left_border < context_left_border
506 && multi_buffer_snapshot
507 .chars_at(extended_context_left_border)
508 .last()
509 .is_some_and(|c| !c.is_whitespace());
510 let truncated_right = entire_context_text
511 .chars()
512 .last()
513 .is_none_or(|c| !c.is_whitespace())
514 && extended_context_right_border > context_right_border
515 && multi_buffer_snapshot
516 .chars_at(extended_context_right_border)
517 .next()
518 .is_some_and(|c| !c.is_whitespace());
519 search_match_indices.iter_mut().for_each(|range| {
520 range.start = range.start.saturating_sub(left_whitespaces_offset);
521 range.end = range.end.saturating_sub(left_whitespaces_offset);
522 });
523
524 let trimmed_row_offset_range =
525 context_offset_range.start + left_whitespaces_offset..context_offset_range.end;
526 let trimmed_text = entire_context_text[left_whitespaces_offset..].to_owned();
527 Self {
528 highlights_data: Arc::default(),
529 search_match_indices,
530 context_range: trimmed_row_offset_range.to_anchors(multi_buffer_snapshot),
531 context_text: trimmed_text,
532 truncated_left,
533 truncated_right,
534 }
535 }
536}
537
538#[derive(Clone, Debug, PartialEq, Eq)]
539enum OutlineEntry {
540 Excerpt(ExcerptRange<Anchor>),
541 Outline(Outline),
542}
543
544impl OutlineEntry {
545 fn buffer_id(&self) -> BufferId {
546 match self {
547 OutlineEntry::Excerpt(excerpt) => excerpt.context.start.buffer_id,
548 OutlineEntry::Outline(outline) => outline.range.start.buffer_id,
549 }
550 }
551
552 fn range(&self) -> Range<Anchor> {
553 match self {
554 OutlineEntry::Excerpt(excerpt) => excerpt.context.clone(),
555 OutlineEntry::Outline(outline) => outline.range.clone(),
556 }
557 }
558}
559
560#[derive(Debug, Clone, Eq)]
561struct FsEntryFile {
562 worktree_id: WorktreeId,
563 entry: GitEntry,
564 buffer_id: BufferId,
565 excerpts: Vec<ExcerptRange<language::Anchor>>,
566}
567
568impl PartialEq for FsEntryFile {
569 fn eq(&self, other: &Self) -> bool {
570 self.worktree_id == other.worktree_id
571 && self.entry.id == other.entry.id
572 && self.buffer_id == other.buffer_id
573 }
574}
575
576impl Hash for FsEntryFile {
577 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
578 (self.buffer_id, self.entry.id, self.worktree_id).hash(state);
579 }
580}
581
582#[derive(Debug, Clone, Eq)]
583struct FsEntryDirectory {
584 worktree_id: WorktreeId,
585 entry: GitEntry,
586}
587
588impl PartialEq for FsEntryDirectory {
589 fn eq(&self, other: &Self) -> bool {
590 self.worktree_id == other.worktree_id && self.entry.id == other.entry.id
591 }
592}
593
594impl Hash for FsEntryDirectory {
595 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
596 (self.worktree_id, self.entry.id).hash(state);
597 }
598}
599
600#[derive(Debug, Clone, Eq)]
601struct FsEntryExternalFile {
602 buffer_id: BufferId,
603 excerpts: Vec<ExcerptRange<language::Anchor>>,
604}
605
606impl PartialEq for FsEntryExternalFile {
607 fn eq(&self, other: &Self) -> bool {
608 self.buffer_id == other.buffer_id
609 }
610}
611
612impl Hash for FsEntryExternalFile {
613 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
614 self.buffer_id.hash(state);
615 }
616}
617
618#[derive(Clone, Debug, Eq, PartialEq)]
619enum FsEntry {
620 ExternalFile(FsEntryExternalFile),
621 Directory(FsEntryDirectory),
622 File(FsEntryFile),
623}
624
625struct ActiveItem {
626 item_handle: Box<dyn WeakItemHandle>,
627 active_editor: WeakEntity<Editor>,
628 _buffer_search_subscription: Subscription,
629 _editor_subscription: Subscription,
630}
631
632#[derive(Debug)]
633pub enum Event {
634 Focus,
635}
636
637#[derive(Serialize, Deserialize)]
638struct SerializedOutlinePanel {
639 active: Option<bool>,
640}
641
642pub fn init(cx: &mut App) {
643 cx.observe_new(|workspace: &mut Workspace, _, _| {
644 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
645 workspace.toggle_panel_focus::<OutlinePanel>(window, cx);
646 });
647 workspace.register_action(|workspace, _: &Toggle, window, cx| {
648 if !workspace.toggle_panel_focus::<OutlinePanel>(window, cx) {
649 workspace.close_panel::<OutlinePanel>(window, cx);
650 }
651 });
652 })
653 .detach();
654}
655
656impl OutlinePanel {
657 pub async fn load(
658 workspace: WeakEntity<Workspace>,
659 mut cx: AsyncWindowContext,
660 ) -> anyhow::Result<Entity<Self>> {
661 let serialized_panel = match workspace
662 .read_with(&cx, |workspace, _| {
663 OutlinePanel::serialization_key(workspace)
664 })
665 .ok()
666 .flatten()
667 {
668 Some(serialization_key) => {
669 let kvp = cx.update(|_, cx| KeyValueStore::global(cx))?;
670 cx.background_spawn(async move { kvp.read_kvp(&serialization_key) })
671 .await
672 .context("loading outline panel")
673 .log_err()
674 .flatten()
675 .map(|panel| serde_json::from_str::<SerializedOutlinePanel>(&panel))
676 .transpose()
677 .log_err()
678 .flatten()
679 }
680 None => None,
681 };
682
683 workspace.update_in(&mut cx, |workspace, window, cx| {
684 let panel = Self::new(workspace, serialized_panel.as_ref(), window, cx);
685 panel.update(cx, |_, cx| cx.notify());
686 panel
687 })
688 }
689
690 fn new(
691 workspace: &mut Workspace,
692 serialized: Option<&SerializedOutlinePanel>,
693 window: &mut Window,
694 cx: &mut Context<Workspace>,
695 ) -> Entity<Self> {
696 let project = workspace.project().clone();
697 let workspace_handle = cx.entity().downgrade();
698
699 cx.new(|cx| {
700 let filter_editor = cx.new(|cx| {
701 let mut editor = Editor::single_line(window, cx);
702 editor.set_placeholder_text("Search buffer symbols…", window, cx);
703 editor
704 });
705 let filter_update_subscription = cx.subscribe_in(
706 &filter_editor,
707 window,
708 |outline_panel: &mut Self, _, event, window, cx| {
709 if let editor::EditorEvent::BufferEdited = event {
710 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
711 }
712 },
713 );
714
715 let focus_handle = cx.focus_handle();
716 let focus_subscription = cx.on_focus(&focus_handle, window, Self::focus_in);
717 let workspace_subscription = cx.subscribe_in(
718 &workspace
719 .weak_handle()
720 .upgrade()
721 .expect("have a &mut Workspace"),
722 window,
723 move |outline_panel, workspace, event, window, cx| {
724 if let workspace::Event::ActiveItemChanged = event {
725 if let Some((new_active_item, new_active_editor)) =
726 workspace_active_editor(workspace.read(cx), cx)
727 {
728 if outline_panel.should_replace_active_item(new_active_item.as_ref()) {
729 outline_panel.replace_active_editor(
730 new_active_item,
731 new_active_editor,
732 window,
733 cx,
734 );
735 }
736 } else {
737 outline_panel.clear_previous(window, cx);
738 cx.notify();
739 }
740 }
741 },
742 );
743
744 let icons_subscription = cx.observe_global::<FileIcons>(|_, cx| {
745 cx.notify();
746 });
747
748 let mut outline_panel_settings = *OutlinePanelSettings::get_global(cx);
749 let mut current_theme = ThemeSettings::get_global(cx).clone();
750 let mut document_symbols_by_buffer = HashMap::default();
751 let settings_subscription =
752 cx.observe_global_in::<SettingsStore>(window, move |outline_panel, window, cx| {
753 let new_settings = OutlinePanelSettings::get_global(cx);
754 let new_theme = ThemeSettings::get_global(cx);
755 let mut outlines_invalidated = false;
756 if ¤t_theme != new_theme {
757 outline_panel_settings = *new_settings;
758 current_theme = new_theme.clone();
759 for buffer in outline_panel.buffers.values_mut() {
760 buffer.invalidate_outlines();
761 }
762 outlines_invalidated = true;
763 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
764 if update_cached_items {
765 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
766 }
767 } else if &outline_panel_settings != new_settings {
768 let old_expansion_depth = outline_panel_settings.expand_outlines_with_depth;
769 outline_panel_settings = *new_settings;
770
771 if old_expansion_depth != new_settings.expand_outlines_with_depth {
772 let old_collapsed_entries = outline_panel.collapsed_entries.clone();
773 outline_panel
774 .collapsed_entries
775 .retain(|entry| !matches!(entry, CollapsedEntry::Outline(..)));
776
777 let new_depth = new_settings.expand_outlines_with_depth;
778
779 for (buffer_id, buffer) in &outline_panel.buffers {
780 if let OutlineState::Outlines(outlines) = &buffer.outlines {
781 for outline in outlines {
782 if outline_panel
783 .outline_children_cache
784 .get(buffer_id)
785 .and_then(|children_map| {
786 let key = (outline.range.clone(), outline.depth);
787 children_map.get(&key)
788 })
789 .copied()
790 .unwrap_or(false)
791 && (new_depth == 0 || outline.depth >= new_depth)
792 {
793 outline_panel.collapsed_entries.insert(
794 CollapsedEntry::Outline(outline.range.clone()),
795 );
796 }
797 }
798 }
799 }
800
801 if old_collapsed_entries != outline_panel.collapsed_entries {
802 outline_panel.update_cached_entries(
803 Some(UPDATE_DEBOUNCE),
804 window,
805 cx,
806 );
807 }
808 } else {
809 cx.notify();
810 }
811 }
812
813 if !outlines_invalidated {
814 let new_document_symbols = outline_panel
815 .buffers
816 .keys()
817 .filter_map(|buffer_id| {
818 let buffer = outline_panel
819 .project
820 .read(cx)
821 .buffer_for_id(*buffer_id, cx)?;
822 let buffer = buffer.read(cx);
823 let doc_symbols =
824 LanguageSettings::for_buffer(buffer, cx).document_symbols;
825 Some((*buffer_id, doc_symbols))
826 })
827 .collect();
828 if new_document_symbols != document_symbols_by_buffer {
829 document_symbols_by_buffer = new_document_symbols;
830 for buffer in outline_panel.buffers.values_mut() {
831 buffer.invalidate_outlines();
832 }
833 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
834 if update_cached_items {
835 outline_panel.update_cached_entries(
836 Some(UPDATE_DEBOUNCE),
837 window,
838 cx,
839 );
840 }
841 }
842 }
843 });
844
845 let scroll_handle = UniformListScrollHandle::new();
846
847 let mut outline_panel = Self {
848 mode: ItemsDisplayMode::Outline,
849 active: serialized.and_then(|s| s.active).unwrap_or(false),
850 pinned: false,
851 workspace: workspace_handle,
852 project,
853 fs: workspace.app_state().fs.clone(),
854 max_width_item_index: None,
855 scroll_handle,
856 rendered_entries_len: 0,
857 focus_handle,
858 filter_editor,
859 fs_entries: Vec::new(),
860 fs_entries_depth: HashMap::default(),
861 fs_children_count: HashMap::default(),
862 collapsed_entries: HashSet::default(),
863 unfolded_dirs: HashMap::default(),
864 selected_entry: SelectedEntry::None,
865 context_menu: None,
866 active_item: None,
867 pending_serialization: Task::ready(None),
868 new_entries_for_fs_update: HashSet::default(),
869 preserve_selection_on_buffer_fold_toggles: HashSet::default(),
870 pending_default_expansion_depth: None,
871 fs_entries_update_task: Task::ready(()),
872 cached_entries_update_task: Task::ready(()),
873 reveal_selection_task: Task::ready(Ok(())),
874 outline_fetch_tasks: HashMap::default(),
875 buffers: HashMap::default(),
876 cached_entries: Vec::new(),
877 _subscriptions: vec![
878 settings_subscription,
879 icons_subscription,
880 focus_subscription,
881 workspace_subscription,
882 filter_update_subscription,
883 ],
884 outline_children_cache: HashMap::default(),
885 };
886 if let Some((item, editor)) = workspace_active_editor(workspace, cx) {
887 outline_panel.replace_active_editor(item, editor, window, cx);
888 }
889 outline_panel
890 })
891 }
892
893 fn serialization_key(workspace: &Workspace) -> Option<String> {
894 workspace
895 .database_id()
896 .map(|id| i64::from(id).to_string())
897 .or(workspace.session_id())
898 .map(|id| format!("{}-{:?}", OUTLINE_PANEL_KEY, id))
899 }
900
901 fn serialize(&mut self, cx: &mut Context<Self>) {
902 let Some(serialization_key) = self
903 .workspace
904 .read_with(cx, |workspace, _| {
905 OutlinePanel::serialization_key(workspace)
906 })
907 .ok()
908 .flatten()
909 else {
910 return;
911 };
912 let active = self.active.then_some(true);
913 let kvp = KeyValueStore::global(cx);
914 self.pending_serialization = cx.background_spawn(
915 async move {
916 kvp.write_kvp(
917 serialization_key,
918 serde_json::to_string(&SerializedOutlinePanel { active })?,
919 )
920 .await?;
921 anyhow::Ok(())
922 }
923 .log_err(),
924 );
925 }
926
927 fn dispatch_context(&self, window: &mut Window, cx: &mut Context<Self>) -> KeyContext {
928 let mut dispatch_context = KeyContext::new_with_defaults();
929 dispatch_context.add("OutlinePanel");
930 dispatch_context.add("menu");
931 let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) {
932 "editing"
933 } else {
934 "not_editing"
935 };
936 dispatch_context.add(identifier);
937 dispatch_context
938 }
939
940 fn unfold_directory(
941 &mut self,
942 _: &UnfoldDirectory,
943 window: &mut Window,
944 cx: &mut Context<Self>,
945 ) {
946 if let Some(PanelEntry::FoldedDirs(FoldedDirsEntry {
947 worktree_id,
948 entries,
949 ..
950 })) = self.selected_entry().cloned()
951 {
952 self.unfolded_dirs
953 .entry(worktree_id)
954 .or_default()
955 .extend(entries.iter().map(|entry| entry.id));
956 self.update_cached_entries(None, window, cx);
957 }
958 }
959
960 fn fold_directory(&mut self, _: &FoldDirectory, window: &mut Window, cx: &mut Context<Self>) {
961 let (worktree_id, entry) = match self.selected_entry().cloned() {
962 Some(PanelEntry::Fs(FsEntry::Directory(directory))) => {
963 (directory.worktree_id, Some(directory.entry))
964 }
965 Some(PanelEntry::FoldedDirs(folded_dirs)) => {
966 (folded_dirs.worktree_id, folded_dirs.entries.last().cloned())
967 }
968 _ => return,
969 };
970 let Some(entry) = entry else {
971 return;
972 };
973 let unfolded_dirs = self.unfolded_dirs.get_mut(&worktree_id);
974 let worktree = self
975 .project
976 .read(cx)
977 .worktree_for_id(worktree_id, cx)
978 .map(|w| w.read(cx).snapshot());
979 let Some((_, unfolded_dirs)) = worktree.zip(unfolded_dirs) else {
980 return;
981 };
982
983 unfolded_dirs.remove(&entry.id);
984 self.update_cached_entries(None, window, cx);
985 }
986
987 fn open_selected_entry(
988 &mut self,
989 _: &OpenSelectedEntry,
990 window: &mut Window,
991 cx: &mut Context<Self>,
992 ) {
993 if self.filter_editor.focus_handle(cx).is_focused(window) {
994 cx.propagate()
995 } else if let Some(selected_entry) = self.selected_entry().cloned() {
996 self.scroll_editor_to_entry(&selected_entry, true, true, window, cx);
997 }
998 }
999
1000 fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
1001 if self.filter_editor.focus_handle(cx).is_focused(window) {
1002 self.focus_handle.focus(window, cx);
1003 } else {
1004 self.filter_editor.focus_handle(cx).focus(window, cx);
1005 }
1006
1007 if self.context_menu.is_some() {
1008 self.context_menu.take();
1009 cx.notify();
1010 }
1011 }
1012
1013 fn open_excerpts(
1014 &mut self,
1015 action: &editor::actions::OpenExcerpts,
1016 window: &mut Window,
1017 cx: &mut Context<Self>,
1018 ) {
1019 if self.filter_editor.focus_handle(cx).is_focused(window) {
1020 cx.propagate()
1021 } else if let Some((active_editor, selected_entry)) =
1022 self.active_editor().zip(self.selected_entry().cloned())
1023 {
1024 self.scroll_editor_to_entry(&selected_entry, true, true, window, cx);
1025 active_editor.update(cx, |editor, cx| editor.open_excerpts(action, window, cx));
1026 }
1027 }
1028
1029 fn open_excerpts_split(
1030 &mut self,
1031 action: &editor::actions::OpenExcerptsSplit,
1032 window: &mut Window,
1033 cx: &mut Context<Self>,
1034 ) {
1035 if self.filter_editor.focus_handle(cx).is_focused(window) {
1036 cx.propagate()
1037 } else if let Some((active_editor, selected_entry)) =
1038 self.active_editor().zip(self.selected_entry().cloned())
1039 {
1040 self.scroll_editor_to_entry(&selected_entry, true, true, window, cx);
1041 active_editor.update(cx, |editor, cx| {
1042 editor.open_excerpts_in_split(action, window, cx)
1043 });
1044 }
1045 }
1046
1047 fn scroll_editor_to_entry(
1048 &mut self,
1049 entry: &PanelEntry,
1050 prefer_selection_change: bool,
1051 prefer_focus_change: bool,
1052 window: &mut Window,
1053 cx: &mut Context<OutlinePanel>,
1054 ) {
1055 let Some(active_editor) = self.active_editor() else {
1056 return;
1057 };
1058 let active_multi_buffer = active_editor.read(cx).buffer().clone();
1059 let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx);
1060 let mut change_selection = prefer_selection_change;
1061 let mut change_focus = prefer_focus_change;
1062 let mut scroll_to_buffer = None;
1063 let scroll_target = match entry {
1064 PanelEntry::FoldedDirs(..) | PanelEntry::Fs(FsEntry::Directory(..)) => {
1065 change_focus = false;
1066 None
1067 }
1068 PanelEntry::Fs(FsEntry::ExternalFile(file)) => {
1069 change_selection = false;
1070 scroll_to_buffer = Some(file.buffer_id);
1071 multi_buffer_snapshot.excerpts().find_map(|excerpt_range| {
1072 if excerpt_range.context.start.buffer_id == file.buffer_id {
1073 multi_buffer_snapshot.anchor_in_excerpt(excerpt_range.context.start)
1074 } else {
1075 None
1076 }
1077 })
1078 }
1079
1080 PanelEntry::Fs(FsEntry::File(file)) => {
1081 change_selection = false;
1082 scroll_to_buffer = Some(file.buffer_id);
1083 self.project
1084 .update(cx, |project, cx| {
1085 project
1086 .path_for_entry(file.entry.id, cx)
1087 .and_then(|path| project.get_open_buffer(&path, cx))
1088 })
1089 .map(|buffer| {
1090 multi_buffer_snapshot.excerpts_for_buffer(buffer.read(cx).remote_id())
1091 })
1092 .and_then(|mut excerpts| {
1093 let excerpt_range = excerpts.next()?;
1094 multi_buffer_snapshot.anchor_in_excerpt(excerpt_range.context.start)
1095 })
1096 }
1097 PanelEntry::Outline(OutlineEntry::Outline(outline)) => multi_buffer_snapshot
1098 .anchor_in_excerpt(outline.range.start)
1099 .or_else(|| multi_buffer_snapshot.anchor_in_excerpt(outline.range.end)),
1100 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
1101 change_selection = false;
1102 change_focus = false;
1103 multi_buffer_snapshot.anchor_in_excerpt(excerpt.context.start)
1104 }
1105 PanelEntry::Search(search_entry) => Some(search_entry.match_range.start),
1106 };
1107
1108 if let Some(anchor) = scroll_target {
1109 let activate = self
1110 .workspace
1111 .update(cx, |workspace, cx| match self.active_item() {
1112 Some(active_item) => workspace.activate_item(
1113 active_item.as_ref(),
1114 true,
1115 change_focus,
1116 window,
1117 cx,
1118 ),
1119 None => workspace.activate_item(&active_editor, true, change_focus, window, cx),
1120 });
1121
1122 if activate.is_ok() {
1123 self.select_entry(entry.clone(), true, window, cx);
1124 if change_selection {
1125 active_editor.update(cx, |editor, cx| {
1126 editor.change_selections(
1127 SelectionEffects::scroll(Autoscroll::center()),
1128 window,
1129 cx,
1130 |s| s.select_ranges(Some(anchor..anchor)),
1131 );
1132 });
1133 } else {
1134 let mut offset = Point::default();
1135 if let Some(buffer_id) = scroll_to_buffer
1136 && multi_buffer_snapshot.as_singleton().is_none()
1137 && !active_editor.read(cx).is_buffer_folded(buffer_id, cx)
1138 {
1139 offset.y = -(active_editor.read(cx).file_header_size() as f64);
1140 }
1141
1142 active_editor.update(cx, |editor, cx| {
1143 editor.set_scroll_anchor(ScrollAnchor { offset, anchor }, window, cx);
1144 });
1145 }
1146
1147 if change_focus {
1148 active_editor.focus_handle(cx).focus(window, cx);
1149 } else {
1150 self.focus_handle.focus(window, cx);
1151 }
1152 }
1153 }
1154 }
1155
1156 fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
1157 for _ in 0..self.rendered_entries_len / 2 {
1158 window.dispatch_action(SelectPrevious.boxed_clone(), cx);
1159 }
1160 }
1161
1162 fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
1163 for _ in 0..self.rendered_entries_len / 2 {
1164 window.dispatch_action(SelectNext.boxed_clone(), cx);
1165 }
1166 }
1167
1168 fn scroll_cursor_center(
1169 &mut self,
1170 _: &ScrollCursorCenter,
1171 _: &mut Window,
1172 cx: &mut Context<Self>,
1173 ) {
1174 if let Some(selected_entry) = self.selected_entry() {
1175 let index = self
1176 .cached_entries
1177 .iter()
1178 .position(|cached_entry| &cached_entry.entry == selected_entry);
1179 if let Some(index) = index {
1180 self.scroll_handle
1181 .scroll_to_item_strict(index, ScrollStrategy::Center);
1182 cx.notify();
1183 }
1184 }
1185 }
1186
1187 fn scroll_cursor_top(&mut self, _: &ScrollCursorTop, _: &mut Window, cx: &mut Context<Self>) {
1188 if let Some(selected_entry) = self.selected_entry() {
1189 let index = self
1190 .cached_entries
1191 .iter()
1192 .position(|cached_entry| &cached_entry.entry == selected_entry);
1193 if let Some(index) = index {
1194 self.scroll_handle
1195 .scroll_to_item_strict(index, ScrollStrategy::Top);
1196 cx.notify();
1197 }
1198 }
1199 }
1200
1201 fn scroll_cursor_bottom(
1202 &mut self,
1203 _: &ScrollCursorBottom,
1204 _: &mut Window,
1205 cx: &mut Context<Self>,
1206 ) {
1207 if let Some(selected_entry) = self.selected_entry() {
1208 let index = self
1209 .cached_entries
1210 .iter()
1211 .position(|cached_entry| &cached_entry.entry == selected_entry);
1212 if let Some(index) = index {
1213 self.scroll_handle
1214 .scroll_to_item_strict(index, ScrollStrategy::Bottom);
1215 cx.notify();
1216 }
1217 }
1218 }
1219
1220 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1221 if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| {
1222 self.cached_entries
1223 .iter()
1224 .map(|cached_entry| &cached_entry.entry)
1225 .skip_while(|entry| entry != &selected_entry)
1226 .nth(1)
1227 .cloned()
1228 }) {
1229 self.select_entry(entry_to_select, true, window, cx);
1230 } else {
1231 self.select_first(&SelectFirst {}, window, cx)
1232 }
1233 if let Some(selected_entry) = self.selected_entry().cloned() {
1234 self.scroll_editor_to_entry(&selected_entry, true, false, window, cx);
1235 }
1236 }
1237
1238 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1239 if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| {
1240 self.cached_entries
1241 .iter()
1242 .rev()
1243 .map(|cached_entry| &cached_entry.entry)
1244 .skip_while(|entry| entry != &selected_entry)
1245 .nth(1)
1246 .cloned()
1247 }) {
1248 self.select_entry(entry_to_select, true, window, cx);
1249 } else {
1250 self.select_last(&SelectLast, window, cx)
1251 }
1252 if let Some(selected_entry) = self.selected_entry().cloned() {
1253 self.scroll_editor_to_entry(&selected_entry, true, false, window, cx);
1254 }
1255 }
1256
1257 fn select_parent(&mut self, _: &SelectParent, window: &mut Window, cx: &mut Context<Self>) {
1258 if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| {
1259 let mut previous_entries = self
1260 .cached_entries
1261 .iter()
1262 .rev()
1263 .map(|cached_entry| &cached_entry.entry)
1264 .skip_while(|entry| entry != &selected_entry)
1265 .skip(1);
1266 match &selected_entry {
1267 PanelEntry::Fs(fs_entry) => match fs_entry {
1268 FsEntry::ExternalFile(..) => None,
1269 FsEntry::File(FsEntryFile {
1270 worktree_id, entry, ..
1271 })
1272 | FsEntry::Directory(FsEntryDirectory {
1273 worktree_id, entry, ..
1274 }) => entry.path.parent().and_then(|parent_path| {
1275 previous_entries.find(|entry| match entry {
1276 PanelEntry::Fs(FsEntry::Directory(directory)) => {
1277 directory.worktree_id == *worktree_id
1278 && directory.entry.path.as_ref() == parent_path
1279 }
1280 PanelEntry::FoldedDirs(FoldedDirsEntry {
1281 worktree_id: dirs_worktree_id,
1282 entries: dirs,
1283 ..
1284 }) => {
1285 dirs_worktree_id == worktree_id
1286 && dirs
1287 .last()
1288 .is_some_and(|dir| dir.path.as_ref() == parent_path)
1289 }
1290 _ => false,
1291 })
1292 }),
1293 },
1294 PanelEntry::FoldedDirs(folded_dirs) => folded_dirs
1295 .entries
1296 .first()
1297 .and_then(|entry| entry.path.parent())
1298 .and_then(|parent_path| {
1299 previous_entries.find(|entry| {
1300 if let PanelEntry::Fs(FsEntry::Directory(directory)) = entry {
1301 directory.worktree_id == folded_dirs.worktree_id
1302 && directory.entry.path.as_ref() == parent_path
1303 } else {
1304 false
1305 }
1306 })
1307 }),
1308 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
1309 previous_entries.find(|entry| match entry {
1310 PanelEntry::Fs(FsEntry::File(file)) => {
1311 file.buffer_id == excerpt.context.start.buffer_id
1312 && file.excerpts.contains(&excerpt)
1313 }
1314 PanelEntry::Fs(FsEntry::ExternalFile(external_file)) => {
1315 external_file.buffer_id == excerpt.context.start.buffer_id
1316 && external_file.excerpts.contains(&excerpt)
1317 }
1318 _ => false,
1319 })
1320 }
1321 PanelEntry::Outline(OutlineEntry::Outline(outline)) => {
1322 previous_entries.find(|entry| {
1323 if let PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) = entry {
1324 if outline.range.start.buffer_id != excerpt.context.start.buffer_id {
1325 return false;
1326 }
1327 let Some(buffer_snapshot) =
1328 self.buffer_snapshot_for_id(outline.range.start.buffer_id, cx)
1329 else {
1330 return false;
1331 };
1332 excerpt.contains(&outline.range.start, &buffer_snapshot)
1333 || excerpt.contains(&outline.range.end, &buffer_snapshot)
1334 } else {
1335 false
1336 }
1337 })
1338 }
1339 PanelEntry::Search(_) => {
1340 previous_entries.find(|entry| !matches!(entry, PanelEntry::Search(_)))
1341 }
1342 }
1343 }) {
1344 self.select_entry(entry_to_select.clone(), true, window, cx);
1345 } else {
1346 self.select_first(&SelectFirst {}, window, cx);
1347 }
1348 }
1349
1350 fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
1351 if let Some(first_entry) = self.cached_entries.first() {
1352 self.select_entry(first_entry.entry.clone(), true, window, cx);
1353 }
1354 }
1355
1356 fn select_last(&mut self, _: &SelectLast, window: &mut Window, cx: &mut Context<Self>) {
1357 if let Some(new_selection) = self
1358 .cached_entries
1359 .iter()
1360 .rev()
1361 .map(|cached_entry| &cached_entry.entry)
1362 .next()
1363 {
1364 self.select_entry(new_selection.clone(), true, window, cx);
1365 }
1366 }
1367
1368 fn autoscroll(&mut self, cx: &mut Context<Self>) {
1369 if let Some(selected_entry) = self.selected_entry() {
1370 let index = self
1371 .cached_entries
1372 .iter()
1373 .position(|cached_entry| &cached_entry.entry == selected_entry);
1374 if let Some(index) = index {
1375 self.scroll_handle
1376 .scroll_to_item(index, ScrollStrategy::Center);
1377 cx.notify();
1378 }
1379 }
1380 }
1381
1382 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1383 if !self.focus_handle.contains_focused(window, cx) {
1384 cx.emit(Event::Focus);
1385 }
1386 }
1387
1388 fn deploy_context_menu(
1389 &mut self,
1390 position: Point<Pixels>,
1391 entry: PanelEntry,
1392 window: &mut Window,
1393 cx: &mut Context<Self>,
1394 ) {
1395 self.select_entry(entry.clone(), true, window, cx);
1396 let is_root = match &entry {
1397 PanelEntry::Fs(FsEntry::File(FsEntryFile {
1398 worktree_id, entry, ..
1399 }))
1400 | PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory {
1401 worktree_id, entry, ..
1402 })) => self
1403 .project
1404 .read(cx)
1405 .worktree_for_id(*worktree_id, cx)
1406 .map(|worktree| {
1407 worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id)
1408 })
1409 .unwrap_or(false),
1410 PanelEntry::FoldedDirs(FoldedDirsEntry {
1411 worktree_id,
1412 entries,
1413 ..
1414 }) => entries
1415 .first()
1416 .and_then(|entry| {
1417 self.project
1418 .read(cx)
1419 .worktree_for_id(*worktree_id, cx)
1420 .map(|worktree| {
1421 worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id)
1422 })
1423 })
1424 .unwrap_or(false),
1425 PanelEntry::Fs(FsEntry::ExternalFile(..)) => false,
1426 PanelEntry::Outline(..) => {
1427 cx.notify();
1428 return;
1429 }
1430 PanelEntry::Search(_) => {
1431 cx.notify();
1432 return;
1433 }
1434 };
1435 let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
1436 let is_foldable = auto_fold_dirs && !is_root && self.is_foldable(&entry);
1437 let is_unfoldable = auto_fold_dirs && !is_root && self.is_unfoldable(&entry);
1438
1439 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
1440 menu.context(self.focus_handle.clone())
1441 .action(
1442 ui::utils::reveal_in_file_manager_label(false),
1443 Box::new(RevealInFileManager),
1444 )
1445 .action("Open in Terminal", Box::new(OpenInTerminal))
1446 .when(is_unfoldable, |menu| {
1447 menu.action("Unfold Directory", Box::new(UnfoldDirectory))
1448 })
1449 .when(is_foldable, |menu| {
1450 menu.action("Fold Directory", Box::new(FoldDirectory))
1451 })
1452 .separator()
1453 .action("Copy Path", Box::new(zed_actions::workspace::CopyPath))
1454 .action(
1455 "Copy Relative Path",
1456 Box::new(zed_actions::workspace::CopyRelativePath),
1457 )
1458 });
1459 window.focus(&context_menu.focus_handle(cx), cx);
1460 let subscription = cx.subscribe(&context_menu, |outline_panel, _, _: &DismissEvent, cx| {
1461 outline_panel.context_menu.take();
1462 cx.notify();
1463 });
1464 self.context_menu = Some((context_menu, position, subscription));
1465 cx.notify();
1466 }
1467
1468 fn is_unfoldable(&self, entry: &PanelEntry) -> bool {
1469 matches!(entry, PanelEntry::FoldedDirs(..))
1470 }
1471
1472 fn is_foldable(&self, entry: &PanelEntry) -> bool {
1473 let (directory_worktree, directory_entry) = match entry {
1474 PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory {
1475 worktree_id,
1476 entry: directory_entry,
1477 ..
1478 })) => (*worktree_id, Some(directory_entry)),
1479 _ => return false,
1480 };
1481 let Some(directory_entry) = directory_entry else {
1482 return false;
1483 };
1484
1485 if self
1486 .unfolded_dirs
1487 .get(&directory_worktree)
1488 .is_none_or(|unfolded_dirs| !unfolded_dirs.contains(&directory_entry.id))
1489 {
1490 return false;
1491 }
1492
1493 let children = self
1494 .fs_children_count
1495 .get(&directory_worktree)
1496 .and_then(|entries| entries.get(&directory_entry.path))
1497 .copied()
1498 .unwrap_or_default();
1499
1500 children.may_be_fold_part() && children.dirs > 0
1501 }
1502
1503 fn expand_selected_entry(
1504 &mut self,
1505 _: &ExpandSelectedEntry,
1506 window: &mut Window,
1507 cx: &mut Context<Self>,
1508 ) {
1509 let Some(active_editor) = self.active_editor() else {
1510 return;
1511 };
1512 let Some(selected_entry) = self.selected_entry().cloned() else {
1513 return;
1514 };
1515 let mut buffers_to_unfold = HashSet::default();
1516 let entry_to_expand = match &selected_entry {
1517 PanelEntry::FoldedDirs(FoldedDirsEntry {
1518 entries: dir_entries,
1519 worktree_id,
1520 ..
1521 }) => dir_entries.last().map(|entry| {
1522 buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry));
1523 CollapsedEntry::Dir(*worktree_id, entry.id)
1524 }),
1525 PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory {
1526 worktree_id, entry, ..
1527 })) => {
1528 buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry));
1529 Some(CollapsedEntry::Dir(*worktree_id, entry.id))
1530 }
1531 PanelEntry::Fs(FsEntry::File(FsEntryFile {
1532 worktree_id,
1533 buffer_id,
1534 ..
1535 })) => {
1536 buffers_to_unfold.insert(*buffer_id);
1537 Some(CollapsedEntry::File(*worktree_id, *buffer_id))
1538 }
1539 PanelEntry::Fs(FsEntry::ExternalFile(external_file)) => {
1540 buffers_to_unfold.insert(external_file.buffer_id);
1541 Some(CollapsedEntry::ExternalFile(external_file.buffer_id))
1542 }
1543 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
1544 Some(CollapsedEntry::Excerpt(excerpt.clone()))
1545 }
1546 PanelEntry::Outline(OutlineEntry::Outline(outline)) => {
1547 Some(CollapsedEntry::Outline(outline.range.clone()))
1548 }
1549 PanelEntry::Search(_) => return,
1550 };
1551 let Some(collapsed_entry) = entry_to_expand else {
1552 return;
1553 };
1554 let expanded = self.collapsed_entries.remove(&collapsed_entry);
1555 if expanded {
1556 if let CollapsedEntry::Dir(worktree_id, dir_entry_id) = collapsed_entry {
1557 let task = self.project.update(cx, |project, cx| {
1558 project.expand_entry(worktree_id, dir_entry_id, cx)
1559 });
1560 if let Some(task) = task {
1561 task.detach_and_log_err(cx);
1562 }
1563 };
1564
1565 active_editor.update(cx, |editor, cx| {
1566 buffers_to_unfold.retain(|buffer_id| editor.is_buffer_folded(*buffer_id, cx));
1567 });
1568 self.select_entry(selected_entry, true, window, cx);
1569 if buffers_to_unfold.is_empty() {
1570 self.update_cached_entries(None, window, cx);
1571 } else {
1572 self.toggle_buffers_fold(buffers_to_unfold, false, window, cx)
1573 .detach();
1574 }
1575 } else {
1576 self.select_next(&SelectNext, window, cx)
1577 }
1578 }
1579
1580 fn collapse_selected_entry(
1581 &mut self,
1582 _: &CollapseSelectedEntry,
1583 window: &mut Window,
1584 cx: &mut Context<Self>,
1585 ) {
1586 let Some(active_editor) = self.active_editor() else {
1587 return;
1588 };
1589 let Some(selected_entry) = self.selected_entry().cloned() else {
1590 return;
1591 };
1592
1593 let mut buffers_to_fold = HashSet::default();
1594 let collapsed = match &selected_entry {
1595 PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory {
1596 worktree_id, entry, ..
1597 })) => {
1598 if self
1599 .collapsed_entries
1600 .insert(CollapsedEntry::Dir(*worktree_id, entry.id))
1601 {
1602 buffers_to_fold.extend(self.buffers_inside_directory(*worktree_id, entry));
1603 true
1604 } else {
1605 false
1606 }
1607 }
1608 PanelEntry::Fs(FsEntry::File(FsEntryFile {
1609 worktree_id,
1610 buffer_id,
1611 ..
1612 })) => {
1613 if self
1614 .collapsed_entries
1615 .insert(CollapsedEntry::File(*worktree_id, *buffer_id))
1616 {
1617 buffers_to_fold.insert(*buffer_id);
1618 true
1619 } else {
1620 false
1621 }
1622 }
1623 PanelEntry::Fs(FsEntry::ExternalFile(external_file)) => {
1624 if self
1625 .collapsed_entries
1626 .insert(CollapsedEntry::ExternalFile(external_file.buffer_id))
1627 {
1628 buffers_to_fold.insert(external_file.buffer_id);
1629 true
1630 } else {
1631 false
1632 }
1633 }
1634 PanelEntry::FoldedDirs(folded_dirs) => {
1635 let mut folded = false;
1636 if let Some(dir_entry) = folded_dirs.entries.last()
1637 && self
1638 .collapsed_entries
1639 .insert(CollapsedEntry::Dir(folded_dirs.worktree_id, dir_entry.id))
1640 {
1641 folded = true;
1642 buffers_to_fold
1643 .extend(self.buffers_inside_directory(folded_dirs.worktree_id, dir_entry));
1644 }
1645 folded
1646 }
1647 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => self
1648 .collapsed_entries
1649 .insert(CollapsedEntry::Excerpt(excerpt.clone())),
1650 PanelEntry::Outline(OutlineEntry::Outline(outline)) => self
1651 .collapsed_entries
1652 .insert(CollapsedEntry::Outline(outline.range.clone())),
1653 PanelEntry::Search(_) => false,
1654 };
1655
1656 if collapsed {
1657 active_editor.update(cx, |editor, cx| {
1658 buffers_to_fold.retain(|buffer_id| !editor.is_buffer_folded(*buffer_id, cx));
1659 });
1660 self.select_entry(selected_entry, true, window, cx);
1661 if buffers_to_fold.is_empty() {
1662 self.update_cached_entries(None, window, cx);
1663 } else {
1664 self.toggle_buffers_fold(buffers_to_fold, true, window, cx)
1665 .detach();
1666 }
1667 } else {
1668 self.select_parent(&SelectParent, window, cx);
1669 }
1670 }
1671
1672 pub fn expand_all_entries(
1673 &mut self,
1674 _: &ExpandAllEntries,
1675 window: &mut Window,
1676 cx: &mut Context<Self>,
1677 ) {
1678 let Some(active_editor) = self.active_editor() else {
1679 return;
1680 };
1681
1682 let mut to_uncollapse: HashSet<CollapsedEntry> = HashSet::default();
1683 let mut buffers_to_unfold: HashSet<BufferId> = HashSet::default();
1684
1685 for fs_entry in &self.fs_entries {
1686 match fs_entry {
1687 FsEntry::File(FsEntryFile {
1688 worktree_id,
1689 buffer_id,
1690 ..
1691 }) => {
1692 to_uncollapse.insert(CollapsedEntry::File(*worktree_id, *buffer_id));
1693 buffers_to_unfold.insert(*buffer_id);
1694 }
1695 FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }) => {
1696 to_uncollapse.insert(CollapsedEntry::ExternalFile(*buffer_id));
1697 buffers_to_unfold.insert(*buffer_id);
1698 }
1699 FsEntry::Directory(FsEntryDirectory {
1700 worktree_id, entry, ..
1701 }) => {
1702 to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, entry.id));
1703 }
1704 }
1705 }
1706
1707 for (_buffer_id, buffer) in &self.buffers {
1708 match &buffer.outlines {
1709 OutlineState::Outlines(outlines) => {
1710 for outline in outlines {
1711 to_uncollapse.insert(CollapsedEntry::Outline(outline.range.clone()));
1712 }
1713 }
1714 OutlineState::Invalidated(outlines) => {
1715 for outline in outlines {
1716 to_uncollapse.insert(CollapsedEntry::Outline(outline.range.clone()));
1717 }
1718 }
1719 OutlineState::NotFetched => {}
1720 }
1721 to_uncollapse.extend(
1722 buffer
1723 .excerpts
1724 .iter()
1725 .map(|excerpt| CollapsedEntry::Excerpt(excerpt.clone())),
1726 );
1727 }
1728
1729 for cached in &self.cached_entries {
1730 if let PanelEntry::FoldedDirs(FoldedDirsEntry {
1731 worktree_id,
1732 entries,
1733 ..
1734 }) = &cached.entry
1735 {
1736 if let Some(last) = entries.last() {
1737 to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, last.id));
1738 }
1739 }
1740 }
1741
1742 self.collapsed_entries
1743 .retain(|entry| !to_uncollapse.contains(entry));
1744
1745 active_editor.update(cx, |editor, cx| {
1746 buffers_to_unfold.retain(|buffer_id| editor.is_buffer_folded(*buffer_id, cx));
1747 });
1748
1749 if buffers_to_unfold.is_empty() {
1750 self.update_cached_entries(None, window, cx);
1751 } else {
1752 self.toggle_buffers_fold(buffers_to_unfold, false, window, cx)
1753 .detach();
1754 }
1755 }
1756
1757 pub fn collapse_all_entries(
1758 &mut self,
1759 _: &CollapseAllEntries,
1760 window: &mut Window,
1761 cx: &mut Context<Self>,
1762 ) {
1763 let Some(active_editor) = self.active_editor() else {
1764 return;
1765 };
1766 let mut buffers_to_fold = HashSet::default();
1767 self.collapsed_entries
1768 .extend(self.cached_entries.iter().filter_map(
1769 |cached_entry| match &cached_entry.entry {
1770 PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory {
1771 worktree_id,
1772 entry,
1773 ..
1774 })) => Some(CollapsedEntry::Dir(*worktree_id, entry.id)),
1775 PanelEntry::Fs(FsEntry::File(FsEntryFile {
1776 worktree_id,
1777 buffer_id,
1778 ..
1779 })) => {
1780 buffers_to_fold.insert(*buffer_id);
1781 Some(CollapsedEntry::File(*worktree_id, *buffer_id))
1782 }
1783 PanelEntry::Fs(FsEntry::ExternalFile(external_file)) => {
1784 buffers_to_fold.insert(external_file.buffer_id);
1785 Some(CollapsedEntry::ExternalFile(external_file.buffer_id))
1786 }
1787 PanelEntry::FoldedDirs(FoldedDirsEntry {
1788 worktree_id,
1789 entries,
1790 ..
1791 }) => Some(CollapsedEntry::Dir(*worktree_id, entries.last()?.id)),
1792 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
1793 Some(CollapsedEntry::Excerpt(excerpt.clone()))
1794 }
1795 PanelEntry::Outline(OutlineEntry::Outline(outline)) => {
1796 Some(CollapsedEntry::Outline(outline.range.clone()))
1797 }
1798 PanelEntry::Search(_) => None,
1799 },
1800 ));
1801
1802 active_editor.update(cx, |editor, cx| {
1803 buffers_to_fold.retain(|buffer_id| !editor.is_buffer_folded(*buffer_id, cx));
1804 });
1805 if buffers_to_fold.is_empty() {
1806 self.update_cached_entries(None, window, cx);
1807 } else {
1808 self.toggle_buffers_fold(buffers_to_fold, true, window, cx)
1809 .detach();
1810 }
1811 }
1812
1813 fn toggle_expanded(&mut self, entry: &PanelEntry, window: &mut Window, cx: &mut Context<Self>) {
1814 let Some(active_editor) = self.active_editor() else {
1815 return;
1816 };
1817 let mut fold = false;
1818 let mut buffers_to_toggle = HashSet::default();
1819 match entry {
1820 PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory {
1821 worktree_id,
1822 entry: dir_entry,
1823 ..
1824 })) => {
1825 let entry_id = dir_entry.id;
1826 let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id);
1827 buffers_to_toggle.extend(self.buffers_inside_directory(*worktree_id, dir_entry));
1828 if self.collapsed_entries.remove(&collapsed_entry) {
1829 self.project
1830 .update(cx, |project, cx| {
1831 project.expand_entry(*worktree_id, entry_id, cx)
1832 })
1833 .unwrap_or_else(|| Task::ready(Ok(())))
1834 .detach_and_log_err(cx);
1835 } else {
1836 self.collapsed_entries.insert(collapsed_entry);
1837 fold = true;
1838 }
1839 }
1840 PanelEntry::Fs(FsEntry::File(FsEntryFile {
1841 worktree_id,
1842 buffer_id,
1843 ..
1844 })) => {
1845 let collapsed_entry = CollapsedEntry::File(*worktree_id, *buffer_id);
1846 buffers_to_toggle.insert(*buffer_id);
1847 if !self.collapsed_entries.remove(&collapsed_entry) {
1848 self.collapsed_entries.insert(collapsed_entry);
1849 fold = true;
1850 }
1851 }
1852 PanelEntry::Fs(FsEntry::ExternalFile(external_file)) => {
1853 let collapsed_entry = CollapsedEntry::ExternalFile(external_file.buffer_id);
1854 buffers_to_toggle.insert(external_file.buffer_id);
1855 if !self.collapsed_entries.remove(&collapsed_entry) {
1856 self.collapsed_entries.insert(collapsed_entry);
1857 fold = true;
1858 }
1859 }
1860 PanelEntry::FoldedDirs(FoldedDirsEntry {
1861 worktree_id,
1862 entries: dir_entries,
1863 ..
1864 }) => {
1865 if let Some(dir_entry) = dir_entries.first() {
1866 let entry_id = dir_entry.id;
1867 let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id);
1868 buffers_to_toggle
1869 .extend(self.buffers_inside_directory(*worktree_id, dir_entry));
1870 if self.collapsed_entries.remove(&collapsed_entry) {
1871 self.project
1872 .update(cx, |project, cx| {
1873 project.expand_entry(*worktree_id, entry_id, cx)
1874 })
1875 .unwrap_or_else(|| Task::ready(Ok(())))
1876 .detach_and_log_err(cx);
1877 } else {
1878 self.collapsed_entries.insert(collapsed_entry);
1879 fold = true;
1880 }
1881 }
1882 }
1883 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
1884 let collapsed_entry = CollapsedEntry::Excerpt(excerpt.clone());
1885 if !self.collapsed_entries.remove(&collapsed_entry) {
1886 self.collapsed_entries.insert(collapsed_entry);
1887 }
1888 }
1889 PanelEntry::Outline(OutlineEntry::Outline(outline)) => {
1890 let collapsed_entry = CollapsedEntry::Outline(outline.range.clone());
1891 if !self.collapsed_entries.remove(&collapsed_entry) {
1892 self.collapsed_entries.insert(collapsed_entry);
1893 }
1894 }
1895 _ => {}
1896 }
1897
1898 active_editor.update(cx, |editor, cx| {
1899 buffers_to_toggle.retain(|buffer_id| {
1900 let folded = editor.is_buffer_folded(*buffer_id, cx);
1901 if fold { !folded } else { folded }
1902 });
1903 });
1904
1905 self.select_entry(entry.clone(), true, window, cx);
1906 if buffers_to_toggle.is_empty() {
1907 self.update_cached_entries(None, window, cx);
1908 } else {
1909 self.toggle_buffers_fold(buffers_to_toggle, fold, window, cx)
1910 .detach();
1911 }
1912 }
1913
1914 fn toggle_buffers_fold(
1915 &self,
1916 buffers: HashSet<BufferId>,
1917 fold: bool,
1918 window: &mut Window,
1919 cx: &mut Context<Self>,
1920 ) -> Task<()> {
1921 let Some(active_editor) = self.active_editor() else {
1922 return Task::ready(());
1923 };
1924 cx.spawn_in(window, async move |outline_panel, cx| {
1925 outline_panel
1926 .update_in(cx, |outline_panel, window, cx| {
1927 active_editor.update(cx, |editor, cx| {
1928 for buffer_id in buffers {
1929 outline_panel
1930 .preserve_selection_on_buffer_fold_toggles
1931 .insert(buffer_id);
1932 if fold {
1933 editor.fold_buffer(buffer_id, cx);
1934 } else {
1935 editor.unfold_buffer(buffer_id, cx);
1936 }
1937 }
1938 });
1939 if let Some(selection) = outline_panel.selected_entry().cloned() {
1940 outline_panel.scroll_editor_to_entry(&selection, false, false, window, cx);
1941 }
1942 })
1943 .ok();
1944 })
1945 }
1946
1947 fn copy_path(
1948 &mut self,
1949 _: &zed_actions::workspace::CopyPath,
1950 _: &mut Window,
1951 cx: &mut Context<Self>,
1952 ) {
1953 if let Some(clipboard_text) = self
1954 .selected_entry()
1955 .and_then(|entry| self.abs_path(entry, cx))
1956 .map(|p| p.to_string_lossy().into_owned())
1957 {
1958 cx.write_to_clipboard(ClipboardItem::new_string(clipboard_text));
1959 }
1960 }
1961
1962 fn copy_relative_path(
1963 &mut self,
1964 _: &zed_actions::workspace::CopyRelativePath,
1965 _: &mut Window,
1966 cx: &mut Context<Self>,
1967 ) {
1968 let path_style = self.project.read(cx).path_style(cx);
1969 if let Some(clipboard_text) = self
1970 .selected_entry()
1971 .and_then(|entry| match entry {
1972 PanelEntry::Fs(entry) => self.relative_path(entry, cx),
1973 PanelEntry::FoldedDirs(folded_dirs) => {
1974 folded_dirs.entries.last().map(|entry| entry.path.clone())
1975 }
1976 PanelEntry::Search(_) | PanelEntry::Outline(..) => None,
1977 })
1978 .map(|p| p.display(path_style).to_string())
1979 {
1980 cx.write_to_clipboard(ClipboardItem::new_string(clipboard_text));
1981 }
1982 }
1983
1984 fn reveal_in_finder(
1985 &mut self,
1986 _: &RevealInFileManager,
1987 _: &mut Window,
1988 cx: &mut Context<Self>,
1989 ) {
1990 if let Some(abs_path) = self
1991 .selected_entry()
1992 .and_then(|entry| self.abs_path(entry, cx))
1993 {
1994 self.project
1995 .update(cx, |project, cx| project.reveal_path(&abs_path, cx));
1996 }
1997 }
1998
1999 fn open_in_terminal(
2000 &mut self,
2001 _: &OpenInTerminal,
2002 window: &mut Window,
2003 cx: &mut Context<Self>,
2004 ) {
2005 let selected_entry = self.selected_entry();
2006 let abs_path = selected_entry.and_then(|entry| self.abs_path(entry, cx));
2007 let working_directory = if let (
2008 Some(abs_path),
2009 Some(PanelEntry::Fs(FsEntry::File(..) | FsEntry::ExternalFile(..))),
2010 ) = (&abs_path, selected_entry)
2011 {
2012 abs_path.parent().map(|p| p.to_owned())
2013 } else {
2014 abs_path
2015 };
2016
2017 if let Some(working_directory) = working_directory {
2018 window.dispatch_action(
2019 workspace::OpenTerminal {
2020 working_directory,
2021 local: false,
2022 }
2023 .boxed_clone(),
2024 cx,
2025 )
2026 }
2027 }
2028
2029 fn reveal_entry_for_selection(
2030 &mut self,
2031 editor: Entity<Editor>,
2032 window: &mut Window,
2033 cx: &mut Context<Self>,
2034 ) {
2035 if !self.active
2036 || !OutlinePanelSettings::get_global(cx).auto_reveal_entries
2037 || self.focus_handle.contains_focused(window, cx)
2038 {
2039 return;
2040 }
2041 let project = self.project.clone();
2042 self.reveal_selection_task = cx.spawn_in(window, async move |outline_panel, cx| {
2043 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
2044 let multibuffer_snapshot =
2045 editor.read_with(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx));
2046 let entry_with_selection =
2047 outline_panel.update_in(cx, |outline_panel, window, cx| {
2048 outline_panel.location_for_editor_selection(&editor, window, cx)
2049 })?;
2050 let Some(entry_with_selection) = entry_with_selection else {
2051 outline_panel.update(cx, |outline_panel, cx| {
2052 outline_panel.selected_entry = SelectedEntry::None;
2053 cx.notify();
2054 })?;
2055 return Ok(());
2056 };
2057 let related_buffer_entry = match &entry_with_selection {
2058 PanelEntry::Fs(FsEntry::File(FsEntryFile {
2059 worktree_id,
2060 buffer_id,
2061 ..
2062 })) => project.update(cx, |project, cx| {
2063 let entry_id = project
2064 .buffer_for_id(*buffer_id, cx)
2065 .and_then(|buffer| buffer.read(cx).entry_id(cx));
2066 project
2067 .worktree_for_id(*worktree_id, cx)
2068 .zip(entry_id)
2069 .and_then(|(worktree, entry_id)| {
2070 let entry = worktree.read(cx).entry_for_id(entry_id)?.clone();
2071 Some((worktree, entry))
2072 })
2073 }),
2074 PanelEntry::Outline(outline_entry) => {
2075 let buffer_id = outline_entry.buffer_id();
2076 let outline_range = outline_entry.range();
2077 outline_panel.update(cx, |outline_panel, cx| {
2078 outline_panel
2079 .collapsed_entries
2080 .remove(&CollapsedEntry::ExternalFile(buffer_id));
2081 if let Some(buffer_snapshot) =
2082 outline_panel.buffer_snapshot_for_id(buffer_id, cx)
2083 {
2084 outline_panel.collapsed_entries.retain(|entry| match entry {
2085 CollapsedEntry::Excerpt(excerpt_range) => {
2086 let intersects = excerpt_range.context.start.buffer_id
2087 == buffer_id
2088 && (excerpt_range
2089 .contains(&outline_range.start, &buffer_snapshot)
2090 || excerpt_range
2091 .contains(&outline_range.end, &buffer_snapshot));
2092 !intersects
2093 }
2094 _ => true,
2095 });
2096 }
2097 let project = outline_panel.project.read(cx);
2098 let entry_id = project
2099 .buffer_for_id(buffer_id, cx)
2100 .and_then(|buffer| buffer.read(cx).entry_id(cx));
2101
2102 entry_id.and_then(|entry_id| {
2103 project
2104 .worktree_for_entry(entry_id, cx)
2105 .and_then(|worktree| {
2106 let worktree_id = worktree.read(cx).id();
2107 outline_panel
2108 .collapsed_entries
2109 .remove(&CollapsedEntry::File(worktree_id, buffer_id));
2110 let entry = worktree.read(cx).entry_for_id(entry_id)?.clone();
2111 Some((worktree, entry))
2112 })
2113 })
2114 })?
2115 }
2116 PanelEntry::Fs(FsEntry::ExternalFile(..)) => None,
2117 PanelEntry::Search(SearchEntry { match_range, .. }) => multibuffer_snapshot
2118 .anchor_to_buffer_anchor(match_range.start)
2119 .map(|(anchor, _)| anchor.buffer_id)
2120 .map(|buffer_id| {
2121 outline_panel.update(cx, |outline_panel, cx| {
2122 outline_panel
2123 .collapsed_entries
2124 .remove(&CollapsedEntry::ExternalFile(buffer_id));
2125 let project = project.read(cx);
2126 let entry_id = project
2127 .buffer_for_id(buffer_id, cx)
2128 .and_then(|buffer| buffer.read(cx).entry_id(cx));
2129
2130 entry_id.and_then(|entry_id| {
2131 project
2132 .worktree_for_entry(entry_id, cx)
2133 .and_then(|worktree| {
2134 let worktree_id = worktree.read(cx).id();
2135 outline_panel
2136 .collapsed_entries
2137 .remove(&CollapsedEntry::File(worktree_id, buffer_id));
2138 let entry =
2139 worktree.read(cx).entry_for_id(entry_id)?.clone();
2140 Some((worktree, entry))
2141 })
2142 })
2143 })
2144 })
2145 .transpose()?
2146 .flatten(),
2147 _ => return anyhow::Ok(()),
2148 };
2149 if let Some((worktree, buffer_entry)) = related_buffer_entry {
2150 outline_panel.update(cx, |outline_panel, cx| {
2151 let worktree_id = worktree.read(cx).id();
2152 let mut dirs_to_expand = Vec::new();
2153 {
2154 let mut traversal = worktree.read(cx).traverse_from_path(
2155 true,
2156 true,
2157 true,
2158 buffer_entry.path.as_ref(),
2159 );
2160 let mut current_entry = buffer_entry;
2161 loop {
2162 if current_entry.is_dir()
2163 && outline_panel
2164 .collapsed_entries
2165 .remove(&CollapsedEntry::Dir(worktree_id, current_entry.id))
2166 {
2167 dirs_to_expand.push(current_entry.id);
2168 }
2169
2170 if traversal.back_to_parent()
2171 && let Some(parent_entry) = traversal.entry()
2172 {
2173 current_entry = parent_entry.clone();
2174 continue;
2175 }
2176 break;
2177 }
2178 }
2179 for dir_to_expand in dirs_to_expand {
2180 project
2181 .update(cx, |project, cx| {
2182 project.expand_entry(worktree_id, dir_to_expand, cx)
2183 })
2184 .unwrap_or_else(|| Task::ready(Ok(())))
2185 .detach_and_log_err(cx)
2186 }
2187 })?
2188 }
2189
2190 outline_panel.update_in(cx, |outline_panel, window, cx| {
2191 outline_panel.select_entry(entry_with_selection, false, window, cx);
2192 outline_panel.update_cached_entries(None, window, cx);
2193 })?;
2194
2195 anyhow::Ok(())
2196 });
2197 }
2198
2199 fn render_excerpt(
2200 &self,
2201 excerpt: &ExcerptRange<Anchor>,
2202 depth: usize,
2203 window: &mut Window,
2204 cx: &mut Context<OutlinePanel>,
2205 ) -> Option<Stateful<Div>> {
2206 let item_id = ElementId::from(format!("{excerpt:?}"));
2207 let is_active = match self.selected_entry() {
2208 Some(PanelEntry::Outline(OutlineEntry::Excerpt(selected_excerpt))) => {
2209 selected_excerpt == excerpt
2210 }
2211 _ => false,
2212 };
2213 let has_outlines = self
2214 .buffers
2215 .get(&excerpt.context.start.buffer_id)
2216 .and_then(|buffer| match &buffer.outlines {
2217 OutlineState::Outlines(outlines) => Some(outlines),
2218 OutlineState::Invalidated(outlines) => Some(outlines),
2219 OutlineState::NotFetched => None,
2220 })
2221 .is_some_and(|outlines| !outlines.is_empty());
2222 let is_expanded = !self
2223 .collapsed_entries
2224 .contains(&CollapsedEntry::Excerpt(excerpt.clone()));
2225 let color = entry_label_color(is_active);
2226 let icon = if has_outlines {
2227 FileIcons::get_chevron_icon(is_expanded, cx)
2228 .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element())
2229 } else {
2230 None
2231 }
2232 .unwrap_or_else(empty_icon);
2233
2234 let label = self.excerpt_label(&excerpt, cx)?;
2235 let label_element = Label::new(label)
2236 .single_line()
2237 .color(color)
2238 .into_any_element();
2239
2240 Some(self.entry_element(
2241 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.clone())),
2242 item_id,
2243 depth,
2244 icon,
2245 is_active,
2246 label_element,
2247 window,
2248 cx,
2249 ))
2250 }
2251
2252 fn excerpt_label(&self, range: &ExcerptRange<language::Anchor>, cx: &App) -> Option<String> {
2253 let buffer_snapshot = self.buffer_snapshot_for_id(range.context.start.buffer_id, cx)?;
2254 let excerpt_range = range.context.to_point(&buffer_snapshot);
2255 Some(format!(
2256 "Lines {}- {}",
2257 excerpt_range.start.row + 1,
2258 excerpt_range.end.row + 1,
2259 ))
2260 }
2261
2262 fn render_outline(
2263 &self,
2264 outline: &Outline,
2265 depth: usize,
2266 string_match: Option<&StringMatch>,
2267 window: &mut Window,
2268 cx: &mut Context<Self>,
2269 ) -> Stateful<Div> {
2270 let item_id = ElementId::from(SharedString::from(format!(
2271 "{:?}|{:?}",
2272 outline.range, &outline.text,
2273 )));
2274
2275 let label_element = outline::render_item(
2276 &outline,
2277 string_match
2278 .map(|string_match| string_match.ranges().collect::<Vec<_>>())
2279 .unwrap_or_default(),
2280 cx,
2281 )
2282 .into_any_element();
2283
2284 let is_active = match self.selected_entry() {
2285 Some(PanelEntry::Outline(OutlineEntry::Outline(selected))) => outline == selected,
2286 _ => false,
2287 };
2288
2289 let has_children = self
2290 .outline_children_cache
2291 .get(&outline.range.start.buffer_id)
2292 .and_then(|children_map| {
2293 let key = (outline.range.clone(), outline.depth);
2294 children_map.get(&key)
2295 })
2296 .copied()
2297 .unwrap_or(false);
2298 let is_expanded = !self
2299 .collapsed_entries
2300 .contains(&CollapsedEntry::Outline(outline.range.clone()));
2301
2302 let icon = if has_children {
2303 FileIcons::get_chevron_icon(is_expanded, cx)
2304 .map(|icon_path| {
2305 Icon::from_path(icon_path)
2306 .color(entry_label_color(is_active))
2307 .into_any_element()
2308 })
2309 .unwrap_or_else(empty_icon)
2310 } else {
2311 empty_icon()
2312 };
2313
2314 self.entry_element(
2315 PanelEntry::Outline(OutlineEntry::Outline(outline.clone())),
2316 item_id,
2317 depth,
2318 icon,
2319 is_active,
2320 label_element,
2321 window,
2322 cx,
2323 )
2324 }
2325
2326 fn render_entry(
2327 &self,
2328 rendered_entry: &FsEntry,
2329 depth: usize,
2330 string_match: Option<&StringMatch>,
2331 window: &mut Window,
2332 cx: &mut Context<Self>,
2333 ) -> Stateful<Div> {
2334 let settings = OutlinePanelSettings::get_global(cx);
2335 let is_active = match self.selected_entry() {
2336 Some(PanelEntry::Fs(selected_entry)) => selected_entry == rendered_entry,
2337 _ => false,
2338 };
2339 let (item_id, label_element, icon) = match rendered_entry {
2340 FsEntry::File(FsEntryFile {
2341 worktree_id, entry, ..
2342 }) => {
2343 let name = self.entry_name(worktree_id, entry, cx);
2344 let color =
2345 entry_git_aware_label_color(entry.git_summary, entry.is_ignored, is_active);
2346 let icon = if settings.file_icons {
2347 FileIcons::get_icon(entry.path.as_std_path(), cx)
2348 .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element())
2349 } else {
2350 None
2351 };
2352 (
2353 ElementId::from(entry.id.to_proto() as usize),
2354 HighlightedLabel::new(
2355 name,
2356 string_match
2357 .map(|string_match| string_match.positions.clone())
2358 .unwrap_or_default(),
2359 )
2360 .color(color)
2361 .into_any_element(),
2362 icon.unwrap_or_else(empty_icon),
2363 )
2364 }
2365 FsEntry::Directory(directory) => {
2366 let name = self.entry_name(&directory.worktree_id, &directory.entry, cx);
2367
2368 let is_expanded = !self.collapsed_entries.contains(&CollapsedEntry::Dir(
2369 directory.worktree_id,
2370 directory.entry.id,
2371 ));
2372 let color = entry_git_aware_label_color(
2373 directory.entry.git_summary,
2374 directory.entry.is_ignored,
2375 is_active,
2376 );
2377 let icon = if settings.folder_icons {
2378 FileIcons::get_folder_icon(is_expanded, directory.entry.path.as_std_path(), cx)
2379 } else {
2380 FileIcons::get_chevron_icon(is_expanded, cx)
2381 }
2382 .map(Icon::from_path)
2383 .map(|icon| icon.color(color).into_any_element());
2384 (
2385 ElementId::from(directory.entry.id.to_proto() as usize),
2386 HighlightedLabel::new(
2387 name,
2388 string_match
2389 .map(|string_match| string_match.positions.clone())
2390 .unwrap_or_default(),
2391 )
2392 .color(color)
2393 .into_any_element(),
2394 icon.unwrap_or_else(empty_icon),
2395 )
2396 }
2397 FsEntry::ExternalFile(external_file) => {
2398 let color = entry_label_color(is_active);
2399 let (icon, name) = match self.buffer_snapshot_for_id(external_file.buffer_id, cx) {
2400 Some(buffer_snapshot) => match buffer_snapshot.file() {
2401 Some(file) => {
2402 let path = file.path();
2403 let icon = if settings.file_icons {
2404 FileIcons::get_icon(path.as_std_path(), cx)
2405 } else {
2406 None
2407 }
2408 .map(Icon::from_path)
2409 .map(|icon| icon.color(color).into_any_element());
2410 (icon, file_name(path.as_std_path()))
2411 }
2412 None => (None, "Untitled".to_string()),
2413 },
2414 None => (None, "Unknown buffer".to_string()),
2415 };
2416 (
2417 ElementId::from(external_file.buffer_id.to_proto() as usize),
2418 HighlightedLabel::new(
2419 name,
2420 string_match
2421 .map(|string_match| string_match.positions.clone())
2422 .unwrap_or_default(),
2423 )
2424 .color(color)
2425 .into_any_element(),
2426 icon.unwrap_or_else(empty_icon),
2427 )
2428 }
2429 };
2430
2431 self.entry_element(
2432 PanelEntry::Fs(rendered_entry.clone()),
2433 item_id,
2434 depth,
2435 icon,
2436 is_active,
2437 label_element,
2438 window,
2439 cx,
2440 )
2441 }
2442
2443 fn render_folded_dirs(
2444 &self,
2445 folded_dir: &FoldedDirsEntry,
2446 depth: usize,
2447 string_match: Option<&StringMatch>,
2448 window: &mut Window,
2449 cx: &mut Context<OutlinePanel>,
2450 ) -> Stateful<Div> {
2451 let settings = OutlinePanelSettings::get_global(cx);
2452 let is_active = match self.selected_entry() {
2453 Some(PanelEntry::FoldedDirs(selected_dirs)) => {
2454 selected_dirs.worktree_id == folded_dir.worktree_id
2455 && selected_dirs.entries == folded_dir.entries
2456 }
2457 _ => false,
2458 };
2459 let (item_id, label_element, icon) = {
2460 let name = self.dir_names_string(&folded_dir.entries, folded_dir.worktree_id, cx);
2461
2462 let is_expanded = folded_dir.entries.iter().all(|dir| {
2463 !self
2464 .collapsed_entries
2465 .contains(&CollapsedEntry::Dir(folded_dir.worktree_id, dir.id))
2466 });
2467 let is_ignored = folded_dir.entries.iter().any(|entry| entry.is_ignored);
2468 let git_status = folded_dir
2469 .entries
2470 .first()
2471 .map(|entry| entry.git_summary)
2472 .unwrap_or_default();
2473 let color = entry_git_aware_label_color(git_status, is_ignored, is_active);
2474 let icon = if settings.folder_icons {
2475 FileIcons::get_folder_icon(is_expanded, &Path::new(&name), cx)
2476 } else {
2477 FileIcons::get_chevron_icon(is_expanded, cx)
2478 }
2479 .map(Icon::from_path)
2480 .map(|icon| icon.color(color).into_any_element());
2481 (
2482 ElementId::from(
2483 folded_dir
2484 .entries
2485 .last()
2486 .map(|entry| entry.id.to_proto())
2487 .unwrap_or_else(|| folded_dir.worktree_id.to_proto())
2488 as usize,
2489 ),
2490 HighlightedLabel::new(
2491 name,
2492 string_match
2493 .map(|string_match| string_match.positions.clone())
2494 .unwrap_or_default(),
2495 )
2496 .color(color)
2497 .into_any_element(),
2498 icon.unwrap_or_else(empty_icon),
2499 )
2500 };
2501
2502 self.entry_element(
2503 PanelEntry::FoldedDirs(folded_dir.clone()),
2504 item_id,
2505 depth,
2506 icon,
2507 is_active,
2508 label_element,
2509 window,
2510 cx,
2511 )
2512 }
2513
2514 fn render_search_match(
2515 &mut self,
2516 multi_buffer_snapshot: Option<&MultiBufferSnapshot>,
2517 match_range: &Range<editor::Anchor>,
2518 render_data: &Arc<OnceLock<SearchData>>,
2519 kind: SearchKind,
2520 depth: usize,
2521 string_match: Option<&StringMatch>,
2522 window: &mut Window,
2523 cx: &mut Context<Self>,
2524 ) -> Option<Stateful<Div>> {
2525 let search_data = match render_data.get() {
2526 Some(search_data) => search_data,
2527 None => {
2528 if let ItemsDisplayMode::Search(search_state) = &mut self.mode
2529 && let Some(multi_buffer_snapshot) = multi_buffer_snapshot
2530 {
2531 search_state
2532 .highlight_search_match_tx
2533 .try_send(HighlightArguments {
2534 multi_buffer_snapshot: multi_buffer_snapshot.clone(),
2535 match_range: match_range.clone(),
2536 search_data: Arc::clone(render_data),
2537 })
2538 .ok();
2539 }
2540 return None;
2541 }
2542 };
2543 let search_matches = string_match
2544 .iter()
2545 .flat_map(|string_match| string_match.ranges())
2546 .collect::<Vec<_>>();
2547 let match_ranges = if search_matches.is_empty() {
2548 &search_data.search_match_indices
2549 } else {
2550 &search_matches
2551 };
2552 let label_element = outline::render_item(
2553 &OutlineItem {
2554 depth,
2555 annotation_range: None,
2556 range: search_data.context_range.clone(),
2557 text: search_data.context_text.clone(),
2558 source_range_for_text: search_data.context_range.clone(),
2559 highlight_ranges: search_data
2560 .highlights_data
2561 .get()
2562 .cloned()
2563 .unwrap_or_default(),
2564 name_ranges: search_data.search_match_indices.clone(),
2565 body_range: Some(search_data.context_range.clone()),
2566 },
2567 match_ranges.iter().cloned(),
2568 cx,
2569 );
2570 let truncated_contents_label = || Label::new(TRUNCATED_CONTEXT_MARK);
2571 let entire_label = h_flex()
2572 .justify_center()
2573 .p_0()
2574 .when(search_data.truncated_left, |parent| {
2575 parent.child(truncated_contents_label())
2576 })
2577 .child(label_element)
2578 .when(search_data.truncated_right, |parent| {
2579 parent.child(truncated_contents_label())
2580 })
2581 .into_any_element();
2582
2583 let is_active = match self.selected_entry() {
2584 Some(PanelEntry::Search(SearchEntry {
2585 match_range: selected_match_range,
2586 ..
2587 })) => match_range == selected_match_range,
2588 _ => false,
2589 };
2590 Some(self.entry_element(
2591 PanelEntry::Search(SearchEntry {
2592 kind,
2593 match_range: match_range.clone(),
2594 render_data: render_data.clone(),
2595 }),
2596 ElementId::from(SharedString::from(format!("search-{match_range:?}"))),
2597 depth,
2598 empty_icon(),
2599 is_active,
2600 entire_label,
2601 window,
2602 cx,
2603 ))
2604 }
2605
2606 fn entry_element(
2607 &self,
2608 rendered_entry: PanelEntry,
2609 item_id: ElementId,
2610 depth: usize,
2611 icon_element: AnyElement,
2612 is_active: bool,
2613 label_element: gpui::AnyElement,
2614 window: &mut Window,
2615 cx: &mut Context<OutlinePanel>,
2616 ) -> Stateful<Div> {
2617 let settings = OutlinePanelSettings::get_global(cx);
2618 div()
2619 .text_ui(cx)
2620 .id(item_id.clone())
2621 .on_click({
2622 let clicked_entry = rendered_entry.clone();
2623 cx.listener(move |outline_panel, event: &gpui::ClickEvent, window, cx| {
2624 if event.is_right_click() || event.first_focus() {
2625 return;
2626 }
2627
2628 let change_focus = event.click_count() > 1;
2629 outline_panel.toggle_expanded(&clicked_entry, window, cx);
2630
2631 outline_panel.scroll_editor_to_entry(
2632 &clicked_entry,
2633 true,
2634 change_focus,
2635 window,
2636 cx,
2637 );
2638 })
2639 })
2640 .cursor_pointer()
2641 .child(
2642 ListItem::new(item_id)
2643 .indent_level(depth)
2644 .indent_step_size(px(settings.indent_size))
2645 .toggle_state(is_active)
2646 .child(
2647 h_flex()
2648 .child(h_flex().w(px(16.)).justify_center().child(icon_element))
2649 .child(h_flex().h_6().child(label_element).ml_1()),
2650 )
2651 .on_secondary_mouse_down(cx.listener(
2652 move |outline_panel, event: &MouseDownEvent, window, cx| {
2653 // Stop propagation to prevent the catch-all context menu for the project
2654 // panel from being deployed.
2655 cx.stop_propagation();
2656 outline_panel.deploy_context_menu(
2657 event.position,
2658 rendered_entry.clone(),
2659 window,
2660 cx,
2661 )
2662 },
2663 )),
2664 )
2665 .border_1()
2666 .border_r_2()
2667 .rounded_none()
2668 .hover(|style| {
2669 if is_active {
2670 style
2671 } else {
2672 let hover_color = cx.theme().colors().ghost_element_hover;
2673 style.bg(hover_color).border_color(hover_color)
2674 }
2675 })
2676 .when(
2677 is_active && self.focus_handle.contains_focused(window, cx),
2678 |div| div.border_color(cx.theme().colors().panel_focused_border),
2679 )
2680 }
2681
2682 fn entry_name(&self, worktree_id: &WorktreeId, entry: &Entry, cx: &App) -> String {
2683 match self.project.read(cx).worktree_for_id(*worktree_id, cx) {
2684 Some(worktree) => {
2685 let worktree = worktree.read(cx);
2686 match worktree.snapshot().root_entry() {
2687 Some(root_entry) => {
2688 if root_entry.id == entry.id {
2689 file_name(worktree.abs_path().as_ref())
2690 } else {
2691 let path = worktree.absolutize(entry.path.as_ref());
2692 file_name(&path)
2693 }
2694 }
2695 None => {
2696 let path = worktree.absolutize(entry.path.as_ref());
2697 file_name(&path)
2698 }
2699 }
2700 }
2701 None => file_name(entry.path.as_std_path()),
2702 }
2703 }
2704
2705 fn update_fs_entries(
2706 &mut self,
2707 active_editor: Entity<Editor>,
2708 debounce: Option<Duration>,
2709 window: &mut Window,
2710 cx: &mut Context<Self>,
2711 ) {
2712 if !self.active {
2713 return;
2714 }
2715
2716 let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
2717 let active_multi_buffer = active_editor.read(cx).buffer().clone();
2718 let new_entries = self.new_entries_for_fs_update.clone();
2719 let repo_snapshots = self.project.update(cx, |project, cx| {
2720 project.git_store().read(cx).repo_snapshots(cx)
2721 });
2722 self.fs_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| {
2723 if let Some(debounce) = debounce {
2724 cx.background_executor().timer(debounce).await;
2725 }
2726
2727 let mut new_collapsed_entries = HashSet::default();
2728 let mut new_unfolded_dirs = HashMap::default();
2729 let mut root_entries = HashSet::default();
2730 let mut new_buffers = HashMap::<BufferId, BufferOutlines>::default();
2731 let Ok(buffer_excerpts) = outline_panel.update(cx, |outline_panel, cx| {
2732 let git_store = outline_panel.project.read(cx).git_store().clone();
2733 new_collapsed_entries = outline_panel.collapsed_entries.clone();
2734 new_unfolded_dirs = outline_panel.unfolded_dirs.clone();
2735 let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx);
2736
2737 multi_buffer_snapshot.excerpts().fold(
2738 HashMap::default(),
2739 |mut buffer_excerpts, excerpt_range| {
2740 let Some(buffer_snapshot) = multi_buffer_snapshot
2741 .buffer_for_id(excerpt_range.context.start.buffer_id)
2742 else {
2743 return buffer_excerpts;
2744 };
2745 let buffer_id = buffer_snapshot.remote_id();
2746 let file = File::from_dyn(buffer_snapshot.file());
2747 let entry_id = file.and_then(|file| file.project_entry_id());
2748 let worktree = file.map(|file| file.worktree.read(cx).snapshot());
2749 let is_new = new_entries.contains(&buffer_id)
2750 || !outline_panel.buffers.contains_key(&buffer_id);
2751 let is_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx);
2752 let status = git_store
2753 .read(cx)
2754 .repository_and_path_for_buffer_id(buffer_id, cx)
2755 .and_then(|(repo, path)| {
2756 Some(repo.read(cx).status_for_path(&path)?.status)
2757 });
2758 buffer_excerpts
2759 .entry(buffer_id)
2760 .or_insert_with(|| {
2761 (is_new, is_folded, Vec::new(), entry_id, worktree, status)
2762 })
2763 .2
2764 .push(excerpt_range.clone());
2765
2766 new_buffers
2767 .entry(buffer_id)
2768 .or_insert_with(|| {
2769 let outlines = match outline_panel.buffers.get(&buffer_id) {
2770 Some(old_buffer) => match &old_buffer.outlines {
2771 OutlineState::Outlines(outlines) => {
2772 OutlineState::Outlines(outlines.clone())
2773 }
2774 OutlineState::Invalidated(_) => OutlineState::NotFetched,
2775 OutlineState::NotFetched => OutlineState::NotFetched,
2776 },
2777 None => OutlineState::NotFetched,
2778 };
2779 BufferOutlines {
2780 outlines,
2781 excerpts: Vec::new(),
2782 }
2783 })
2784 .excerpts
2785 .push(excerpt_range);
2786 buffer_excerpts
2787 },
2788 )
2789 }) else {
2790 return;
2791 };
2792
2793 let Some((
2794 new_collapsed_entries,
2795 new_unfolded_dirs,
2796 new_fs_entries,
2797 new_depth_map,
2798 new_children_count,
2799 )) = cx
2800 .background_spawn(async move {
2801 let mut processed_external_buffers = HashSet::default();
2802 let mut new_worktree_entries =
2803 BTreeMap::<WorktreeId, HashMap<ProjectEntryId, GitEntry>>::default();
2804 let mut worktree_excerpts = HashMap::<
2805 WorktreeId,
2806 HashMap<ProjectEntryId, (BufferId, Vec<ExcerptRange<Anchor>>)>,
2807 >::default();
2808 let mut external_excerpts = HashMap::default();
2809
2810 for (buffer_id, (is_new, is_folded, excerpts, entry_id, worktree, status)) in
2811 buffer_excerpts
2812 {
2813 if is_folded {
2814 match &worktree {
2815 Some(worktree) => {
2816 new_collapsed_entries
2817 .insert(CollapsedEntry::File(worktree.id(), buffer_id));
2818 }
2819 None => {
2820 new_collapsed_entries
2821 .insert(CollapsedEntry::ExternalFile(buffer_id));
2822 }
2823 }
2824 } else if is_new {
2825 match &worktree {
2826 Some(worktree) => {
2827 new_collapsed_entries
2828 .remove(&CollapsedEntry::File(worktree.id(), buffer_id));
2829 }
2830 None => {
2831 new_collapsed_entries
2832 .remove(&CollapsedEntry::ExternalFile(buffer_id));
2833 }
2834 }
2835 }
2836
2837 if let Some(worktree) = worktree {
2838 let worktree_id = worktree.id();
2839 let unfolded_dirs = new_unfolded_dirs.entry(worktree_id).or_default();
2840
2841 match entry_id.and_then(|id| worktree.entry_for_id(id)).cloned() {
2842 Some(entry) => {
2843 let entry = GitEntry {
2844 git_summary: status
2845 .map(|status| status.summary())
2846 .unwrap_or_default(),
2847 entry,
2848 };
2849 let mut traversal = GitTraversal::new(
2850 &repo_snapshots,
2851 worktree.traverse_from_path(
2852 true,
2853 true,
2854 true,
2855 entry.path.as_ref(),
2856 ),
2857 );
2858
2859 let mut entries_to_add = HashMap::default();
2860 worktree_excerpts
2861 .entry(worktree_id)
2862 .or_default()
2863 .insert(entry.id, (buffer_id, excerpts));
2864 let mut current_entry = entry;
2865 loop {
2866 if current_entry.is_dir() {
2867 let is_root =
2868 worktree.root_entry().map(|entry| entry.id)
2869 == Some(current_entry.id);
2870 if is_root {
2871 root_entries.insert(current_entry.id);
2872 if auto_fold_dirs {
2873 unfolded_dirs.insert(current_entry.id);
2874 }
2875 }
2876 if is_new {
2877 new_collapsed_entries.remove(&CollapsedEntry::Dir(
2878 worktree_id,
2879 current_entry.id,
2880 ));
2881 }
2882 }
2883
2884 let new_entry_added = entries_to_add
2885 .insert(current_entry.id, current_entry)
2886 .is_none();
2887 if new_entry_added
2888 && traversal.back_to_parent()
2889 && let Some(parent_entry) = traversal.entry()
2890 {
2891 current_entry = parent_entry.to_owned();
2892 continue;
2893 }
2894 break;
2895 }
2896 new_worktree_entries
2897 .entry(worktree_id)
2898 .or_insert_with(HashMap::default)
2899 .extend(entries_to_add);
2900 }
2901 None => {
2902 if processed_external_buffers.insert(buffer_id) {
2903 external_excerpts
2904 .entry(buffer_id)
2905 .or_insert_with(Vec::new)
2906 .extend(excerpts);
2907 }
2908 }
2909 }
2910 } else if processed_external_buffers.insert(buffer_id) {
2911 external_excerpts
2912 .entry(buffer_id)
2913 .or_insert_with(Vec::new)
2914 .extend(excerpts);
2915 }
2916 }
2917
2918 let mut new_children_count =
2919 HashMap::<WorktreeId, HashMap<Arc<RelPath>, FsChildren>>::default();
2920
2921 let worktree_entries = new_worktree_entries
2922 .into_iter()
2923 .map(|(worktree_id, entries)| {
2924 let mut entries = entries.into_values().collect::<Vec<_>>();
2925 entries.sort_by(|a, b| a.path.as_ref().cmp(b.path.as_ref()));
2926 (worktree_id, entries)
2927 })
2928 .flat_map(|(worktree_id, entries)| {
2929 {
2930 entries
2931 .into_iter()
2932 .filter_map(|entry| {
2933 if auto_fold_dirs && let Some(parent) = entry.path.parent()
2934 {
2935 let children = new_children_count
2936 .entry(worktree_id)
2937 .or_default()
2938 .entry(Arc::from(parent))
2939 .or_default();
2940 if entry.is_dir() {
2941 children.dirs += 1;
2942 } else {
2943 children.files += 1;
2944 }
2945 }
2946
2947 if entry.is_dir() {
2948 Some(FsEntry::Directory(FsEntryDirectory {
2949 worktree_id,
2950 entry,
2951 }))
2952 } else {
2953 let (buffer_id, excerpts) = worktree_excerpts
2954 .get_mut(&worktree_id)
2955 .and_then(|worktree_excerpts| {
2956 worktree_excerpts.remove(&entry.id)
2957 })?;
2958 Some(FsEntry::File(FsEntryFile {
2959 worktree_id,
2960 buffer_id,
2961 entry,
2962 excerpts,
2963 }))
2964 }
2965 })
2966 .collect::<Vec<_>>()
2967 }
2968 })
2969 .collect::<Vec<_>>();
2970
2971 let mut visited_dirs = Vec::new();
2972 let mut new_depth_map = HashMap::default();
2973 let new_visible_entries = external_excerpts
2974 .into_iter()
2975 .sorted_by_key(|(id, _)| *id)
2976 .map(|(buffer_id, excerpts)| {
2977 FsEntry::ExternalFile(FsEntryExternalFile {
2978 buffer_id,
2979 excerpts,
2980 })
2981 })
2982 .chain(worktree_entries)
2983 .filter(|visible_item| {
2984 match visible_item {
2985 FsEntry::Directory(directory) => {
2986 let parent_id = back_to_common_visited_parent(
2987 &mut visited_dirs,
2988 &directory.worktree_id,
2989 &directory.entry,
2990 );
2991
2992 let mut depth = 0;
2993 if !root_entries.contains(&directory.entry.id) {
2994 if auto_fold_dirs {
2995 let children = new_children_count
2996 .get(&directory.worktree_id)
2997 .and_then(|children_count| {
2998 children_count.get(&directory.entry.path)
2999 })
3000 .copied()
3001 .unwrap_or_default();
3002
3003 if !children.may_be_fold_part()
3004 || (children.dirs == 0
3005 && visited_dirs
3006 .last()
3007 .map(|(parent_dir_id, _)| {
3008 new_unfolded_dirs
3009 .get(&directory.worktree_id)
3010 .is_none_or(|unfolded_dirs| {
3011 unfolded_dirs
3012 .contains(parent_dir_id)
3013 })
3014 })
3015 .unwrap_or(true))
3016 {
3017 new_unfolded_dirs
3018 .entry(directory.worktree_id)
3019 .or_default()
3020 .insert(directory.entry.id);
3021 }
3022 }
3023
3024 depth = parent_id
3025 .and_then(|(worktree_id, id)| {
3026 new_depth_map.get(&(worktree_id, id)).copied()
3027 })
3028 .unwrap_or(0)
3029 + 1;
3030 };
3031 visited_dirs
3032 .push((directory.entry.id, directory.entry.path.clone()));
3033 new_depth_map
3034 .insert((directory.worktree_id, directory.entry.id), depth);
3035 }
3036 FsEntry::File(FsEntryFile {
3037 worktree_id,
3038 entry: file_entry,
3039 ..
3040 }) => {
3041 let parent_id = back_to_common_visited_parent(
3042 &mut visited_dirs,
3043 worktree_id,
3044 file_entry,
3045 );
3046 let depth = if root_entries.contains(&file_entry.id) {
3047 0
3048 } else {
3049 parent_id
3050 .and_then(|(worktree_id, id)| {
3051 new_depth_map.get(&(worktree_id, id)).copied()
3052 })
3053 .unwrap_or(0)
3054 + 1
3055 };
3056 new_depth_map.insert((*worktree_id, file_entry.id), depth);
3057 }
3058 FsEntry::ExternalFile(..) => {
3059 visited_dirs.clear();
3060 }
3061 }
3062
3063 true
3064 })
3065 .collect::<Vec<_>>();
3066
3067 anyhow::Ok((
3068 new_collapsed_entries,
3069 new_unfolded_dirs,
3070 new_visible_entries,
3071 new_depth_map,
3072 new_children_count,
3073 ))
3074 })
3075 .await
3076 .log_err()
3077 else {
3078 return;
3079 };
3080
3081 outline_panel
3082 .update_in(cx, |outline_panel, window, cx| {
3083 outline_panel.new_entries_for_fs_update.clear();
3084 outline_panel.buffers = new_buffers;
3085 outline_panel.collapsed_entries = new_collapsed_entries;
3086 outline_panel.unfolded_dirs = new_unfolded_dirs;
3087 outline_panel.fs_entries = new_fs_entries;
3088 outline_panel.fs_entries_depth = new_depth_map;
3089 outline_panel.fs_children_count = new_children_count;
3090 outline_panel.update_non_fs_items(window, cx);
3091
3092 // Only update cached entries if we don't have outlines to fetch
3093 // If we do have outlines to fetch, let fetch_outdated_outlines handle the update
3094 if outline_panel.buffers_to_fetch().is_empty() {
3095 outline_panel.update_cached_entries(debounce, window, cx);
3096 }
3097
3098 cx.notify();
3099 })
3100 .ok();
3101 });
3102 }
3103
3104 fn replace_active_editor(
3105 &mut self,
3106 new_active_item: Box<dyn ItemHandle>,
3107 new_active_editor: Entity<Editor>,
3108 window: &mut Window,
3109 cx: &mut Context<Self>,
3110 ) {
3111 self.clear_previous(window, cx);
3112
3113 let default_expansion_depth =
3114 OutlinePanelSettings::get_global(cx).expand_outlines_with_depth;
3115 // We'll apply the expansion depth after outlines are loaded
3116 self.pending_default_expansion_depth = Some(default_expansion_depth);
3117
3118 let buffer_search_subscription = cx.subscribe_in(
3119 &new_active_editor,
3120 window,
3121 |outline_panel: &mut Self,
3122 _,
3123 e: &SearchEvent,
3124 window: &mut Window,
3125 cx: &mut Context<Self>| {
3126 if matches!(e, SearchEvent::MatchesInvalidated) {
3127 let update_cached_items = outline_panel.update_search_matches(window, cx);
3128 if update_cached_items {
3129 outline_panel.selected_entry.invalidate();
3130 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
3131 }
3132 };
3133 outline_panel.autoscroll(cx);
3134 },
3135 );
3136 self.active_item = Some(ActiveItem {
3137 _buffer_search_subscription: buffer_search_subscription,
3138 _editor_subscription: subscribe_for_editor_events(&new_active_editor, window, cx),
3139 item_handle: new_active_item.downgrade_item(),
3140 active_editor: new_active_editor.downgrade(),
3141 });
3142 self.new_entries_for_fs_update.extend(
3143 new_active_editor
3144 .read(cx)
3145 .buffer()
3146 .read(cx)
3147 .snapshot(cx)
3148 .excerpts()
3149 .map(|excerpt| excerpt.context.start.buffer_id),
3150 );
3151 self.selected_entry.invalidate();
3152 self.update_fs_entries(new_active_editor, None, window, cx);
3153 }
3154
3155 fn clear_previous(&mut self, window: &mut Window, cx: &mut App) {
3156 self.fs_entries_update_task = Task::ready(());
3157 self.outline_fetch_tasks.clear();
3158 self.cached_entries_update_task = Task::ready(());
3159 self.reveal_selection_task = Task::ready(Ok(()));
3160 self.filter_editor
3161 .update(cx, |editor, cx| editor.clear(window, cx));
3162 self.collapsed_entries.clear();
3163 self.unfolded_dirs.clear();
3164 self.active_item = None;
3165 self.fs_entries.clear();
3166 self.fs_entries_depth.clear();
3167 self.fs_children_count.clear();
3168 self.buffers.clear();
3169 self.cached_entries = Vec::new();
3170 self.selected_entry = SelectedEntry::None;
3171 self.pinned = false;
3172 self.mode = ItemsDisplayMode::Outline;
3173 self.pending_default_expansion_depth = None;
3174 }
3175
3176 fn location_for_editor_selection(
3177 &self,
3178 editor: &Entity<Editor>,
3179 window: &mut Window,
3180 cx: &mut Context<Self>,
3181 ) -> Option<PanelEntry> {
3182 let editor_snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
3183 let multi_buffer = editor.read(cx).buffer();
3184 let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
3185 let anchor = editor.update(cx, |editor, _| editor.selections.newest_anchor().head());
3186 let selection_display_point = anchor.to_display_point(&editor_snapshot);
3187 let (anchor, _) = multi_buffer_snapshot.anchor_to_buffer_anchor(anchor)?;
3188
3189 if editor.read(cx).is_buffer_folded(anchor.buffer_id, cx) {
3190 return self
3191 .fs_entries
3192 .iter()
3193 .find(|fs_entry| match fs_entry {
3194 FsEntry::Directory(..) => false,
3195 FsEntry::File(FsEntryFile {
3196 buffer_id: other_buffer_id,
3197 ..
3198 })
3199 | FsEntry::ExternalFile(FsEntryExternalFile {
3200 buffer_id: other_buffer_id,
3201 ..
3202 }) => anchor.buffer_id == *other_buffer_id,
3203 })
3204 .cloned()
3205 .map(PanelEntry::Fs);
3206 }
3207
3208 match &self.mode {
3209 ItemsDisplayMode::Search(search_state) => search_state
3210 .matches
3211 .iter()
3212 .rev()
3213 .min_by_key(|&(match_range, _)| {
3214 let match_display_range =
3215 match_range.clone().to_display_points(&editor_snapshot);
3216 let start_distance = if selection_display_point < match_display_range.start {
3217 match_display_range.start - selection_display_point
3218 } else {
3219 selection_display_point - match_display_range.start
3220 };
3221 let end_distance = if selection_display_point < match_display_range.end {
3222 match_display_range.end - selection_display_point
3223 } else {
3224 selection_display_point - match_display_range.end
3225 };
3226 start_distance + end_distance
3227 })
3228 .and_then(|(closest_range, _)| {
3229 self.cached_entries.iter().find_map(|cached_entry| {
3230 if let PanelEntry::Search(SearchEntry { match_range, .. }) =
3231 &cached_entry.entry
3232 {
3233 if match_range == closest_range {
3234 Some(cached_entry.entry.clone())
3235 } else {
3236 None
3237 }
3238 } else {
3239 None
3240 }
3241 })
3242 }),
3243 ItemsDisplayMode::Outline => self.outline_location(
3244 anchor,
3245 multi_buffer_snapshot,
3246 editor_snapshot,
3247 selection_display_point,
3248 cx,
3249 ),
3250 }
3251 }
3252
3253 fn outline_location(
3254 &self,
3255 selection_anchor: Anchor,
3256 multi_buffer_snapshot: editor::MultiBufferSnapshot,
3257 editor_snapshot: editor::EditorSnapshot,
3258 selection_display_point: DisplayPoint,
3259 cx: &App,
3260 ) -> Option<PanelEntry> {
3261 let excerpt_outlines = self
3262 .buffers
3263 .get(&selection_anchor.buffer_id)
3264 .into_iter()
3265 .flat_map(|buffer| buffer.iter_outlines())
3266 .flat_map(|outline| {
3267 let range = multi_buffer_snapshot
3268 .buffer_anchor_range_to_anchor_range(outline.range.clone())?;
3269 Some((
3270 range.start.to_display_point(&editor_snapshot)
3271 ..range.end.to_display_point(&editor_snapshot),
3272 outline,
3273 ))
3274 })
3275 .collect::<Vec<_>>();
3276
3277 let mut matching_outline_indices = Vec::new();
3278 let mut children = HashMap::default();
3279 let mut parents_stack = Vec::<(&Range<DisplayPoint>, &&Outline, usize)>::new();
3280
3281 for (i, (outline_range, outline)) in excerpt_outlines.iter().enumerate() {
3282 if outline_range
3283 .to_inclusive()
3284 .contains(&selection_display_point)
3285 {
3286 matching_outline_indices.push(i);
3287 } else if (outline_range.start.row()..outline_range.end.row())
3288 .to_inclusive()
3289 .contains(&selection_display_point.row())
3290 {
3291 matching_outline_indices.push(i);
3292 }
3293
3294 while let Some((parent_range, parent_outline, _)) = parents_stack.last() {
3295 if parent_outline.depth >= outline.depth
3296 || !parent_range.contains(&outline_range.start)
3297 {
3298 parents_stack.pop();
3299 } else {
3300 break;
3301 }
3302 }
3303 if let Some((_, _, parent_index)) = parents_stack.last_mut() {
3304 children
3305 .entry(*parent_index)
3306 .or_insert_with(Vec::new)
3307 .push(i);
3308 }
3309 parents_stack.push((outline_range, outline, i));
3310 }
3311
3312 let outline_item = matching_outline_indices
3313 .into_iter()
3314 .flat_map(|i| Some((i, excerpt_outlines.get(i)?)))
3315 .filter(|(i, _)| {
3316 children
3317 .get(i)
3318 .map(|children| {
3319 children.iter().all(|child_index| {
3320 excerpt_outlines
3321 .get(*child_index)
3322 .map(|(child_range, _)| child_range.start > selection_display_point)
3323 .unwrap_or(false)
3324 })
3325 })
3326 .unwrap_or(true)
3327 })
3328 .min_by_key(|(_, (outline_range, outline))| {
3329 let distance_from_start = if outline_range.start > selection_display_point {
3330 outline_range.start - selection_display_point
3331 } else {
3332 selection_display_point - outline_range.start
3333 };
3334 let distance_from_end = if outline_range.end > selection_display_point {
3335 outline_range.end - selection_display_point
3336 } else {
3337 selection_display_point - outline_range.end
3338 };
3339
3340 // An outline item's range can extend to the same row the next
3341 // item starts on, so when the cursor is at the start of that
3342 // row, prefer the item that starts there over any item whose
3343 // range merely overlaps that row.
3344 let cursor_not_at_outline_start = outline_range.start != selection_display_point;
3345 (
3346 cursor_not_at_outline_start,
3347 cmp::Reverse(outline.depth),
3348 distance_from_start,
3349 distance_from_end,
3350 )
3351 })
3352 .map(|(_, (_, outline))| *outline)
3353 .cloned();
3354
3355 let closest_container = match outline_item {
3356 Some(outline) => PanelEntry::Outline(OutlineEntry::Outline(outline)),
3357 None => {
3358 self.cached_entries.iter().rev().find_map(|cached_entry| {
3359 match &cached_entry.entry {
3360 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
3361 if excerpt.context.start.buffer_id == selection_anchor.buffer_id
3362 && let Some(buffer_snapshot) =
3363 self.buffer_snapshot_for_id(excerpt.context.start.buffer_id, cx)
3364 && excerpt.contains(&selection_anchor, &buffer_snapshot)
3365 {
3366 Some(cached_entry.entry.clone())
3367 } else {
3368 None
3369 }
3370 }
3371 PanelEntry::Fs(
3372 FsEntry::ExternalFile(FsEntryExternalFile {
3373 buffer_id: file_buffer_id,
3374 excerpts: file_excerpts,
3375 ..
3376 })
3377 | FsEntry::File(FsEntryFile {
3378 buffer_id: file_buffer_id,
3379 excerpts: file_excerpts,
3380 ..
3381 }),
3382 ) => {
3383 if *file_buffer_id == selection_anchor.buffer_id
3384 && let Some(buffer_snapshot) =
3385 self.buffer_snapshot_for_id(*file_buffer_id, cx)
3386 && file_excerpts.iter().any(|excerpt| {
3387 excerpt.contains(&selection_anchor, &buffer_snapshot)
3388 })
3389 {
3390 Some(cached_entry.entry.clone())
3391 } else {
3392 None
3393 }
3394 }
3395 _ => None,
3396 }
3397 })?
3398 }
3399 };
3400 Some(closest_container)
3401 }
3402
3403 fn fetch_outdated_outlines(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3404 let buffers_to_fetch = self.buffers_to_fetch();
3405 if buffers_to_fetch.is_empty() {
3406 return;
3407 }
3408
3409 let first_update = Arc::new(AtomicBool::new(true));
3410 for buffer_id in buffers_to_fetch {
3411 let outline_task = self.active_editor().map(|editor| {
3412 editor.update(cx, |editor, cx| editor.buffer_outline_items(buffer_id, cx))
3413 });
3414
3415 let first_update = first_update.clone();
3416
3417 self.outline_fetch_tasks.insert(
3418 buffer_id,
3419 cx.spawn_in(window, async move |outline_panel, cx| {
3420 let Some(outline_task) = outline_task else {
3421 return;
3422 };
3423 let fetched_outlines = outline_task.await;
3424 let outlines_with_children = fetched_outlines
3425 .windows(2)
3426 .filter_map(|window| {
3427 let current = &window[0];
3428 let next = &window[1];
3429 if next.depth > current.depth {
3430 Some((current.range.clone(), current.depth))
3431 } else {
3432 None
3433 }
3434 })
3435 .collect::<HashSet<_>>();
3436
3437 outline_panel
3438 .update_in(cx, |outline_panel, window, cx| {
3439 let pending_default_depth =
3440 outline_panel.pending_default_expansion_depth.take();
3441
3442 let debounce =
3443 if first_update.fetch_and(false, atomic::Ordering::AcqRel) {
3444 None
3445 } else {
3446 Some(UPDATE_DEBOUNCE)
3447 };
3448
3449 if let Some(buffer) = outline_panel.buffers.get_mut(&buffer_id) {
3450 buffer.outlines = OutlineState::Outlines(fetched_outlines.clone());
3451
3452 if let Some(default_depth) = pending_default_depth
3453 && let OutlineState::Outlines(outlines) = &buffer.outlines
3454 {
3455 outlines
3456 .iter()
3457 .filter(|outline| {
3458 (default_depth == 0 || outline.depth >= default_depth)
3459 && outlines_with_children.contains(&(
3460 outline.range.clone(),
3461 outline.depth,
3462 ))
3463 })
3464 .for_each(|outline| {
3465 outline_panel.collapsed_entries.insert(
3466 CollapsedEntry::Outline(outline.range.clone()),
3467 );
3468 });
3469 }
3470 }
3471
3472 outline_panel.update_cached_entries(debounce, window, cx);
3473 })
3474 .ok();
3475 }),
3476 );
3477 }
3478 }
3479
3480 fn is_singleton_active(&self, cx: &App) -> bool {
3481 self.active_editor()
3482 .is_some_and(|active_editor| active_editor.read(cx).buffer().read(cx).is_singleton())
3483 }
3484
3485 fn invalidate_outlines(&mut self, ids: &[BufferId]) {
3486 self.outline_fetch_tasks.clear();
3487 let mut ids = ids.iter().collect::<HashSet<_>>();
3488 for (buffer_id, buffer) in self.buffers.iter_mut() {
3489 if ids.remove(&buffer_id) {
3490 buffer.invalidate_outlines();
3491 }
3492 if ids.is_empty() {
3493 break;
3494 }
3495 }
3496 }
3497
3498 fn buffers_to_fetch(&self) -> HashSet<BufferId> {
3499 self.fs_entries
3500 .iter()
3501 .fold(HashSet::default(), |mut buffers_to_fetch, fs_entry| {
3502 match fs_entry {
3503 FsEntry::File(FsEntryFile { buffer_id, .. })
3504 | FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }) => {
3505 if let Some(buffer) = self.buffers.get(buffer_id)
3506 && buffer.should_fetch_outlines()
3507 {
3508 buffers_to_fetch.insert(*buffer_id);
3509 }
3510 }
3511 FsEntry::Directory(..) => {}
3512 }
3513 buffers_to_fetch
3514 })
3515 }
3516
3517 fn buffer_snapshot_for_id(&self, buffer_id: BufferId, cx: &App) -> Option<BufferSnapshot> {
3518 let editor = self.active_editor()?;
3519 Some(
3520 editor
3521 .read(cx)
3522 .buffer()
3523 .read(cx)
3524 .buffer(buffer_id)?
3525 .read(cx)
3526 .snapshot(),
3527 )
3528 }
3529
3530 fn abs_path(&self, entry: &PanelEntry, cx: &App) -> Option<PathBuf> {
3531 match entry {
3532 PanelEntry::Fs(
3533 FsEntry::File(FsEntryFile { buffer_id, .. })
3534 | FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }),
3535 ) => self
3536 .buffer_snapshot_for_id(*buffer_id, cx)
3537 .and_then(|buffer_snapshot| {
3538 let file = File::from_dyn(buffer_snapshot.file())?;
3539 Some(file.worktree.read(cx).absolutize(&file.path))
3540 }),
3541 PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory {
3542 worktree_id, entry, ..
3543 })) => Some(
3544 self.project
3545 .read(cx)
3546 .worktree_for_id(*worktree_id, cx)?
3547 .read(cx)
3548 .absolutize(&entry.path),
3549 ),
3550 PanelEntry::FoldedDirs(FoldedDirsEntry {
3551 worktree_id,
3552 entries: dirs,
3553 ..
3554 }) => dirs.last().and_then(|entry| {
3555 self.project
3556 .read(cx)
3557 .worktree_for_id(*worktree_id, cx)
3558 .map(|worktree| worktree.read(cx).absolutize(&entry.path))
3559 }),
3560 PanelEntry::Search(_) | PanelEntry::Outline(..) => None,
3561 }
3562 }
3563
3564 fn relative_path(&self, entry: &FsEntry, cx: &App) -> Option<Arc<RelPath>> {
3565 match entry {
3566 FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }) => {
3567 let buffer_snapshot = self.buffer_snapshot_for_id(*buffer_id, cx)?;
3568 Some(buffer_snapshot.file()?.path().clone())
3569 }
3570 FsEntry::Directory(FsEntryDirectory { entry, .. }) => Some(entry.path.clone()),
3571 FsEntry::File(FsEntryFile { entry, .. }) => Some(entry.path.clone()),
3572 }
3573 }
3574
3575 fn update_cached_entries(
3576 &mut self,
3577 debounce: Option<Duration>,
3578 window: &mut Window,
3579 cx: &mut Context<OutlinePanel>,
3580 ) {
3581 if !self.active {
3582 return;
3583 }
3584
3585 let is_singleton = self.is_singleton_active(cx);
3586 let query = self.query(cx);
3587 self.cached_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| {
3588 if let Some(debounce) = debounce {
3589 cx.background_executor().timer(debounce).await;
3590 }
3591 let Some(new_cached_entries) = outline_panel
3592 .update_in(cx, |outline_panel, window, cx| {
3593 outline_panel.generate_cached_entries(is_singleton, query, window, cx)
3594 })
3595 .ok()
3596 else {
3597 return;
3598 };
3599 let (new_cached_entries, max_width_item_index) = new_cached_entries.await;
3600 outline_panel
3601 .update_in(cx, |outline_panel, window, cx| {
3602 outline_panel.cached_entries = new_cached_entries;
3603 outline_panel.max_width_item_index = max_width_item_index;
3604 if (outline_panel.selected_entry.is_invalidated()
3605 || matches!(outline_panel.selected_entry, SelectedEntry::None))
3606 && let Some(new_selected_entry) =
3607 outline_panel.active_editor().and_then(|active_editor| {
3608 outline_panel.location_for_editor_selection(
3609 &active_editor,
3610 window,
3611 cx,
3612 )
3613 })
3614 {
3615 outline_panel.select_entry(new_selected_entry, false, window, cx);
3616 }
3617
3618 outline_panel.autoscroll(cx);
3619 cx.notify();
3620 })
3621 .ok();
3622 });
3623 }
3624
3625 fn generate_cached_entries(
3626 &self,
3627 is_singleton: bool,
3628 query: Option<String>,
3629 window: &mut Window,
3630 cx: &mut Context<Self>,
3631 ) -> Task<(Vec<CachedEntry>, Option<usize>)> {
3632 let project = self.project.clone();
3633 let Some(active_editor) = self.active_editor() else {
3634 return Task::ready((Vec::new(), None));
3635 };
3636 cx.spawn_in(window, async move |outline_panel, cx| {
3637 let mut generation_state = GenerationState::default();
3638
3639 let Ok(()) = outline_panel.update(cx, |outline_panel, cx| {
3640 let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
3641 let mut folded_dirs_entry = None::<(usize, FoldedDirsEntry)>;
3642 let track_matches = query.is_some();
3643
3644 #[derive(Debug)]
3645 struct ParentStats {
3646 path: Arc<RelPath>,
3647 folded: bool,
3648 expanded: bool,
3649 depth: usize,
3650 }
3651 let mut parent_dirs = Vec::<ParentStats>::new();
3652 for entry in outline_panel.fs_entries.clone() {
3653 let is_expanded = outline_panel.is_expanded(&entry);
3654 let (depth, should_add) = match &entry {
3655 FsEntry::Directory(directory_entry) => {
3656 let mut should_add = true;
3657 let is_root = project
3658 .read(cx)
3659 .worktree_for_id(directory_entry.worktree_id, cx)
3660 .is_some_and(|worktree| {
3661 worktree.read(cx).root_entry() == Some(&directory_entry.entry)
3662 });
3663 let folded = auto_fold_dirs
3664 && !is_root
3665 && outline_panel
3666 .unfolded_dirs
3667 .get(&directory_entry.worktree_id)
3668 .is_none_or(|unfolded_dirs| {
3669 !unfolded_dirs.contains(&directory_entry.entry.id)
3670 });
3671 let fs_depth = outline_panel
3672 .fs_entries_depth
3673 .get(&(directory_entry.worktree_id, directory_entry.entry.id))
3674 .copied()
3675 .unwrap_or(0);
3676 while let Some(parent) = parent_dirs.last() {
3677 if !is_root && directory_entry.entry.path.starts_with(&parent.path)
3678 {
3679 break;
3680 }
3681 parent_dirs.pop();
3682 }
3683 let auto_fold = match parent_dirs.last() {
3684 Some(parent) => {
3685 parent.folded
3686 && Some(parent.path.as_ref())
3687 == directory_entry.entry.path.parent()
3688 && outline_panel
3689 .fs_children_count
3690 .get(&directory_entry.worktree_id)
3691 .and_then(|entries| {
3692 entries.get(&directory_entry.entry.path)
3693 })
3694 .copied()
3695 .unwrap_or_default()
3696 .may_be_fold_part()
3697 }
3698 None => false,
3699 };
3700 let folded = folded || auto_fold;
3701 let (depth, parent_expanded, parent_folded) = match parent_dirs.last() {
3702 Some(parent) => {
3703 let parent_folded = parent.folded;
3704 let parent_expanded = parent.expanded;
3705 let new_depth = if parent_folded {
3706 parent.depth
3707 } else {
3708 parent.depth + 1
3709 };
3710 parent_dirs.push(ParentStats {
3711 path: directory_entry.entry.path.clone(),
3712 folded,
3713 expanded: parent_expanded && is_expanded,
3714 depth: new_depth,
3715 });
3716 (new_depth, parent_expanded, parent_folded)
3717 }
3718 None => {
3719 parent_dirs.push(ParentStats {
3720 path: directory_entry.entry.path.clone(),
3721 folded,
3722 expanded: is_expanded,
3723 depth: fs_depth,
3724 });
3725 (fs_depth, true, false)
3726 }
3727 };
3728
3729 if let Some((folded_depth, mut folded_dirs)) = folded_dirs_entry.take()
3730 {
3731 if folded
3732 && directory_entry.worktree_id == folded_dirs.worktree_id
3733 && directory_entry.entry.path.parent()
3734 == folded_dirs
3735 .entries
3736 .last()
3737 .map(|entry| entry.path.as_ref())
3738 {
3739 folded_dirs.entries.push(directory_entry.entry.clone());
3740 folded_dirs_entry = Some((folded_depth, folded_dirs))
3741 } else {
3742 if !is_singleton {
3743 let start_of_collapsed_dir_sequence = !parent_expanded
3744 && parent_dirs
3745 .iter()
3746 .rev()
3747 .nth(folded_dirs.entries.len() + 1)
3748 .is_none_or(|parent| parent.expanded);
3749 if start_of_collapsed_dir_sequence
3750 || parent_expanded
3751 || query.is_some()
3752 {
3753 if parent_folded {
3754 folded_dirs
3755 .entries
3756 .push(directory_entry.entry.clone());
3757 should_add = false;
3758 }
3759 let new_folded_dirs =
3760 PanelEntry::FoldedDirs(folded_dirs.clone());
3761 outline_panel.push_entry(
3762 &mut generation_state,
3763 track_matches,
3764 new_folded_dirs,
3765 folded_depth,
3766 cx,
3767 );
3768 }
3769 }
3770
3771 folded_dirs_entry = if parent_folded {
3772 None
3773 } else {
3774 Some((
3775 depth,
3776 FoldedDirsEntry {
3777 worktree_id: directory_entry.worktree_id,
3778 entries: vec![directory_entry.entry.clone()],
3779 },
3780 ))
3781 };
3782 }
3783 } else if folded {
3784 folded_dirs_entry = Some((
3785 depth,
3786 FoldedDirsEntry {
3787 worktree_id: directory_entry.worktree_id,
3788 entries: vec![directory_entry.entry.clone()],
3789 },
3790 ));
3791 }
3792
3793 let should_add =
3794 should_add && parent_expanded && folded_dirs_entry.is_none();
3795 (depth, should_add)
3796 }
3797 FsEntry::ExternalFile(..) => {
3798 if let Some((folded_depth, folded_dir)) = folded_dirs_entry.take() {
3799 let parent_expanded = parent_dirs
3800 .iter()
3801 .rev()
3802 .find(|parent| {
3803 folded_dir
3804 .entries
3805 .iter()
3806 .all(|entry| entry.path != parent.path)
3807 })
3808 .is_none_or(|parent| parent.expanded);
3809 if !is_singleton && (parent_expanded || query.is_some()) {
3810 outline_panel.push_entry(
3811 &mut generation_state,
3812 track_matches,
3813 PanelEntry::FoldedDirs(folded_dir),
3814 folded_depth,
3815 cx,
3816 );
3817 }
3818 }
3819 parent_dirs.clear();
3820 (0, true)
3821 }
3822 FsEntry::File(file) => {
3823 if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() {
3824 let parent_expanded = parent_dirs
3825 .iter()
3826 .rev()
3827 .find(|parent| {
3828 folded_dirs
3829 .entries
3830 .iter()
3831 .all(|entry| entry.path != parent.path)
3832 })
3833 .is_none_or(|parent| parent.expanded);
3834 if !is_singleton && (parent_expanded || query.is_some()) {
3835 outline_panel.push_entry(
3836 &mut generation_state,
3837 track_matches,
3838 PanelEntry::FoldedDirs(folded_dirs),
3839 folded_depth,
3840 cx,
3841 );
3842 }
3843 }
3844
3845 let fs_depth = outline_panel
3846 .fs_entries_depth
3847 .get(&(file.worktree_id, file.entry.id))
3848 .copied()
3849 .unwrap_or(0);
3850 while let Some(parent) = parent_dirs.last() {
3851 if file.entry.path.starts_with(&parent.path) {
3852 break;
3853 }
3854 parent_dirs.pop();
3855 }
3856 match parent_dirs.last() {
3857 Some(parent) => {
3858 let new_depth = parent.depth + 1;
3859 (new_depth, parent.expanded)
3860 }
3861 None => (fs_depth, true),
3862 }
3863 }
3864 };
3865
3866 if !is_singleton
3867 && (should_add || (query.is_some() && folded_dirs_entry.is_none()))
3868 {
3869 outline_panel.push_entry(
3870 &mut generation_state,
3871 track_matches,
3872 PanelEntry::Fs(entry.clone()),
3873 depth,
3874 cx,
3875 );
3876 }
3877
3878 match outline_panel.mode {
3879 ItemsDisplayMode::Search(_) => {
3880 if is_singleton || query.is_some() || (should_add && is_expanded) {
3881 outline_panel.add_search_entries(
3882 &mut generation_state,
3883 &active_editor,
3884 entry.clone(),
3885 depth,
3886 query.clone(),
3887 is_singleton,
3888 cx,
3889 );
3890 }
3891 }
3892 ItemsDisplayMode::Outline => {
3893 let excerpts_to_consider =
3894 if is_singleton || query.is_some() || (should_add && is_expanded) {
3895 match &entry {
3896 FsEntry::File(FsEntryFile {
3897 buffer_id,
3898 excerpts,
3899 ..
3900 })
3901 | FsEntry::ExternalFile(FsEntryExternalFile {
3902 buffer_id,
3903 excerpts,
3904 ..
3905 }) => Some((*buffer_id, excerpts)),
3906 _ => None,
3907 }
3908 } else {
3909 None
3910 };
3911 if let Some((buffer_id, _entry_excerpts)) = excerpts_to_consider
3912 && !active_editor.read(cx).is_buffer_folded(buffer_id, cx)
3913 {
3914 outline_panel.add_buffer_entries(
3915 &mut generation_state,
3916 buffer_id,
3917 depth,
3918 track_matches,
3919 is_singleton,
3920 query.as_deref(),
3921 cx,
3922 );
3923 }
3924 }
3925 }
3926
3927 if is_singleton
3928 && matches!(entry, FsEntry::File(..) | FsEntry::ExternalFile(..))
3929 && !generation_state.entries.iter().any(|item| {
3930 matches!(item.entry, PanelEntry::Outline(..) | PanelEntry::Search(_))
3931 })
3932 {
3933 outline_panel.push_entry(
3934 &mut generation_state,
3935 track_matches,
3936 PanelEntry::Fs(entry.clone()),
3937 0,
3938 cx,
3939 );
3940 }
3941 }
3942
3943 if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() {
3944 let parent_expanded = parent_dirs
3945 .iter()
3946 .rev()
3947 .find(|parent| {
3948 folded_dirs
3949 .entries
3950 .iter()
3951 .all(|entry| entry.path != parent.path)
3952 })
3953 .is_none_or(|parent| parent.expanded);
3954 if parent_expanded || query.is_some() {
3955 outline_panel.push_entry(
3956 &mut generation_state,
3957 track_matches,
3958 PanelEntry::FoldedDirs(folded_dirs),
3959 folded_depth,
3960 cx,
3961 );
3962 }
3963 }
3964 }) else {
3965 return (Vec::new(), None);
3966 };
3967
3968 let Some(query) = query else {
3969 return (
3970 generation_state.entries,
3971 generation_state
3972 .max_width_estimate_and_index
3973 .map(|(_, index)| index),
3974 );
3975 };
3976
3977 let mut matched_ids = match_strings(
3978 &generation_state.match_candidates,
3979 &query,
3980 true,
3981 true,
3982 usize::MAX,
3983 &AtomicBool::default(),
3984 cx.background_executor().clone(),
3985 )
3986 .await
3987 .into_iter()
3988 .map(|string_match| (string_match.candidate_id, string_match))
3989 .collect::<HashMap<_, _>>();
3990
3991 let mut id = 0;
3992 generation_state.entries.retain_mut(|cached_entry| {
3993 let retain = match matched_ids.remove(&id) {
3994 Some(string_match) => {
3995 cached_entry.string_match = Some(string_match);
3996 true
3997 }
3998 None => false,
3999 };
4000 id += 1;
4001 retain
4002 });
4003
4004 (
4005 generation_state.entries,
4006 generation_state
4007 .max_width_estimate_and_index
4008 .map(|(_, index)| index),
4009 )
4010 })
4011 }
4012
4013 fn push_entry(
4014 &self,
4015 state: &mut GenerationState,
4016 track_matches: bool,
4017 entry: PanelEntry,
4018 depth: usize,
4019 cx: &mut App,
4020 ) {
4021 let entry = if let PanelEntry::FoldedDirs(folded_dirs_entry) = &entry {
4022 match folded_dirs_entry.entries.len() {
4023 0 => {
4024 debug_panic!("Empty folded dirs receiver");
4025 return;
4026 }
4027 1 => PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory {
4028 worktree_id: folded_dirs_entry.worktree_id,
4029 entry: folded_dirs_entry.entries[0].clone(),
4030 })),
4031 _ => entry,
4032 }
4033 } else {
4034 entry
4035 };
4036
4037 if track_matches {
4038 let id = state.entries.len();
4039 match &entry {
4040 PanelEntry::Fs(fs_entry) => {
4041 if let Some(file_name) = self
4042 .relative_path(fs_entry, cx)
4043 .and_then(|path| Some(path.file_name()?.to_string()))
4044 {
4045 state
4046 .match_candidates
4047 .push(StringMatchCandidate::new(id, &file_name));
4048 }
4049 }
4050 PanelEntry::FoldedDirs(folded_dir_entry) => {
4051 let dir_names = self.dir_names_string(
4052 &folded_dir_entry.entries,
4053 folded_dir_entry.worktree_id,
4054 cx,
4055 );
4056 {
4057 state
4058 .match_candidates
4059 .push(StringMatchCandidate::new(id, &dir_names));
4060 }
4061 }
4062 PanelEntry::Outline(OutlineEntry::Outline(outline_entry)) => state
4063 .match_candidates
4064 .push(StringMatchCandidate::new(id, &outline_entry.text)),
4065 PanelEntry::Outline(OutlineEntry::Excerpt(_)) => {}
4066 PanelEntry::Search(new_search_entry) => {
4067 if let Some(search_data) = new_search_entry.render_data.get() {
4068 state
4069 .match_candidates
4070 .push(StringMatchCandidate::new(id, &search_data.context_text));
4071 }
4072 }
4073 }
4074 }
4075
4076 let width_estimate = self.width_estimate(depth, &entry, cx);
4077 if Some(width_estimate)
4078 > state
4079 .max_width_estimate_and_index
4080 .map(|(estimate, _)| estimate)
4081 {
4082 state.max_width_estimate_and_index = Some((width_estimate, state.entries.len()));
4083 }
4084 state.entries.push(CachedEntry {
4085 depth,
4086 entry,
4087 string_match: None,
4088 });
4089 }
4090
4091 fn dir_names_string(&self, entries: &[GitEntry], worktree_id: WorktreeId, cx: &App) -> String {
4092 let dir_names_segment = entries
4093 .iter()
4094 .map(|entry| self.entry_name(&worktree_id, entry, cx))
4095 .collect::<PathBuf>();
4096 dir_names_segment.to_string_lossy().into_owned()
4097 }
4098
4099 fn query(&self, cx: &App) -> Option<String> {
4100 let query = self.filter_editor.read(cx).text(cx);
4101 if query.trim().is_empty() {
4102 None
4103 } else {
4104 Some(query)
4105 }
4106 }
4107
4108 fn is_expanded(&self, entry: &FsEntry) -> bool {
4109 let entry_to_check = match entry {
4110 FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }) => {
4111 CollapsedEntry::ExternalFile(*buffer_id)
4112 }
4113 FsEntry::File(FsEntryFile {
4114 worktree_id,
4115 buffer_id,
4116 ..
4117 }) => CollapsedEntry::File(*worktree_id, *buffer_id),
4118 FsEntry::Directory(FsEntryDirectory {
4119 worktree_id, entry, ..
4120 }) => CollapsedEntry::Dir(*worktree_id, entry.id),
4121 };
4122 !self.collapsed_entries.contains(&entry_to_check)
4123 }
4124
4125 fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context<OutlinePanel>) -> bool {
4126 if !self.active {
4127 return false;
4128 }
4129
4130 let mut update_cached_items = false;
4131 update_cached_items |= self.update_search_matches(window, cx);
4132 self.fetch_outdated_outlines(window, cx);
4133 if update_cached_items {
4134 self.selected_entry.invalidate();
4135 }
4136 update_cached_items
4137 }
4138
4139 fn update_search_matches(
4140 &mut self,
4141 window: &mut Window,
4142 cx: &mut Context<OutlinePanel>,
4143 ) -> bool {
4144 if !self.active {
4145 return false;
4146 }
4147
4148 let project_search = self
4149 .active_item()
4150 .and_then(|item| item.downcast::<ProjectSearchView>());
4151 let project_search_matches = project_search
4152 .as_ref()
4153 .map(|project_search| project_search.read(cx).get_matches(cx))
4154 .unwrap_or_default();
4155
4156 let buffer_search = self
4157 .active_item()
4158 .as_deref()
4159 .and_then(|active_item| {
4160 self.workspace
4161 .upgrade()
4162 .and_then(|workspace| workspace.read(cx).pane_for(active_item))
4163 })
4164 .and_then(|pane| {
4165 pane.read(cx)
4166 .toolbar()
4167 .read(cx)
4168 .item_of_type::<BufferSearchBar>()
4169 });
4170 let buffer_search_matches = self
4171 .active_editor()
4172 .map(|active_editor| {
4173 active_editor.update(cx, |editor, cx| editor.get_matches(window, cx).0)
4174 })
4175 .unwrap_or_default();
4176
4177 let mut update_cached_entries = false;
4178 if buffer_search_matches.is_empty() && project_search_matches.is_empty() {
4179 if matches!(self.mode, ItemsDisplayMode::Search(_)) {
4180 self.mode = ItemsDisplayMode::Outline;
4181 update_cached_entries = true;
4182 }
4183 } else {
4184 let (kind, new_search_matches, new_search_query) = if buffer_search_matches.is_empty() {
4185 (
4186 SearchKind::Project,
4187 project_search_matches,
4188 project_search
4189 .map(|project_search| project_search.read(cx).search_query_text(cx))
4190 .unwrap_or_default(),
4191 )
4192 } else {
4193 (
4194 SearchKind::Buffer,
4195 buffer_search_matches,
4196 buffer_search
4197 .map(|buffer_search| buffer_search.read(cx).query(cx))
4198 .unwrap_or_default(),
4199 )
4200 };
4201
4202 let mut previous_matches = HashMap::default();
4203 update_cached_entries = match &mut self.mode {
4204 ItemsDisplayMode::Search(current_search_state) => {
4205 let update = current_search_state.query != new_search_query
4206 || current_search_state.kind != kind
4207 || current_search_state.matches.is_empty()
4208 || current_search_state.matches.iter().enumerate().any(
4209 |(i, (match_range, _))| new_search_matches.get(i) != Some(match_range),
4210 );
4211 if current_search_state.kind == kind {
4212 previous_matches.extend(current_search_state.matches.drain(..));
4213 }
4214 update
4215 }
4216 ItemsDisplayMode::Outline => true,
4217 };
4218 self.mode = ItemsDisplayMode::Search(SearchState::new(
4219 kind,
4220 new_search_query,
4221 previous_matches,
4222 new_search_matches,
4223 cx.theme().syntax().clone(),
4224 window,
4225 cx,
4226 ));
4227 }
4228 update_cached_entries
4229 }
4230
4231 fn add_buffer_entries(
4232 &mut self,
4233 state: &mut GenerationState,
4234 buffer_id: BufferId,
4235 parent_depth: usize,
4236 track_matches: bool,
4237 is_singleton: bool,
4238 query: Option<&str>,
4239 cx: &mut Context<Self>,
4240 ) {
4241 let Some(buffer) = self.buffers.get(&buffer_id) else {
4242 return;
4243 };
4244
4245 let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx);
4246
4247 for excerpt in &buffer.excerpts {
4248 let excerpt_depth = parent_depth + 1;
4249 self.push_entry(
4250 state,
4251 track_matches,
4252 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.clone())),
4253 excerpt_depth,
4254 cx,
4255 );
4256
4257 let mut outline_base_depth = excerpt_depth + 1;
4258 if is_singleton {
4259 outline_base_depth = 0;
4260 state.clear();
4261 } else if query.is_none()
4262 && self
4263 .collapsed_entries
4264 .contains(&CollapsedEntry::Excerpt(excerpt.clone()))
4265 {
4266 continue;
4267 }
4268
4269 let mut last_depth_at_level: Vec<Option<Range<Anchor>>> = vec![None; 10];
4270
4271 let all_outlines: Vec<_> = buffer.iter_outlines().collect();
4272
4273 let mut outline_has_children = HashMap::default();
4274 let mut visible_outlines = Vec::new();
4275 let mut collapsed_state: Option<(usize, Range<Anchor>)> = None;
4276
4277 for (i, &outline) in all_outlines.iter().enumerate() {
4278 let has_children = all_outlines
4279 .get(i + 1)
4280 .map(|next| next.depth > outline.depth)
4281 .unwrap_or(false);
4282
4283 outline_has_children.insert((outline.range.clone(), outline.depth), has_children);
4284
4285 let mut should_include = true;
4286
4287 if let Some((collapsed_depth, collapsed_range)) = &collapsed_state {
4288 if outline.depth <= *collapsed_depth {
4289 collapsed_state = None;
4290 } else if let Some(buffer_snapshot) = buffer_snapshot.as_ref() {
4291 let outline_start = outline.range.start;
4292 if outline_start
4293 .cmp(&collapsed_range.start, buffer_snapshot)
4294 .is_ge()
4295 && outline_start
4296 .cmp(&collapsed_range.end, buffer_snapshot)
4297 .is_lt()
4298 {
4299 should_include = false; // Skip - inside collapsed range
4300 } else {
4301 collapsed_state = None;
4302 }
4303 }
4304 }
4305
4306 // Check if this outline itself is collapsed
4307 if should_include
4308 && self
4309 .collapsed_entries
4310 .contains(&CollapsedEntry::Outline(outline.range.clone()))
4311 {
4312 collapsed_state = Some((outline.depth, outline.range.clone()));
4313 }
4314
4315 if should_include {
4316 visible_outlines.push(outline);
4317 }
4318 }
4319
4320 self.outline_children_cache
4321 .entry(buffer_id)
4322 .or_default()
4323 .extend(outline_has_children);
4324
4325 for outline in visible_outlines {
4326 let outline_entry = outline.clone();
4327
4328 if outline.depth < last_depth_at_level.len() {
4329 last_depth_at_level[outline.depth] = Some(outline.range.clone());
4330 // Clear deeper levels when we go back to a shallower depth
4331 for d in (outline.depth + 1)..last_depth_at_level.len() {
4332 last_depth_at_level[d] = None;
4333 }
4334 }
4335
4336 self.push_entry(
4337 state,
4338 track_matches,
4339 PanelEntry::Outline(OutlineEntry::Outline(outline_entry)),
4340 outline_base_depth + outline.depth,
4341 cx,
4342 );
4343 }
4344 }
4345 }
4346
4347 fn add_search_entries(
4348 &mut self,
4349 state: &mut GenerationState,
4350 active_editor: &Entity<Editor>,
4351 parent_entry: FsEntry,
4352 parent_depth: usize,
4353 filter_query: Option<String>,
4354 is_singleton: bool,
4355 cx: &mut Context<Self>,
4356 ) {
4357 let ItemsDisplayMode::Search(search_state) = &mut self.mode else {
4358 return;
4359 };
4360
4361 let kind = search_state.kind;
4362 let related_excerpts = match &parent_entry {
4363 FsEntry::Directory(_) => return,
4364 FsEntry::ExternalFile(external) => &external.excerpts,
4365 FsEntry::File(file) => &file.excerpts,
4366 }
4367 .iter()
4368 .cloned()
4369 .collect::<HashSet<_>>();
4370
4371 let depth = if is_singleton { 0 } else { parent_depth + 1 };
4372 let new_search_matches = search_state.matches.iter().filter(|(match_range, _)| {
4373 let editor = active_editor.read(cx);
4374 let snapshot = editor.buffer().read(cx).snapshot(cx);
4375 if !related_excerpts.iter().any(|excerpt| {
4376 let (Some(start), Some(end)) = (
4377 snapshot.anchor_in_buffer(excerpt.context.start),
4378 snapshot.anchor_in_buffer(excerpt.context.end),
4379 ) else {
4380 return false;
4381 };
4382 let excerpt_range = start..end;
4383 excerpt_range.overlaps(match_range, &snapshot)
4384 }) {
4385 return false;
4386 };
4387 if let Some((buffer_anchor, _)) = snapshot.anchor_to_buffer_anchor(match_range.start)
4388 && editor.is_buffer_folded(buffer_anchor.buffer_id, cx)
4389 {
4390 return false;
4391 }
4392 if let Some((buffer_anchor, _)) = snapshot.anchor_to_buffer_anchor(match_range.end)
4393 && editor.is_buffer_folded(buffer_anchor.buffer_id, cx)
4394 {
4395 return false;
4396 }
4397 true
4398 });
4399
4400 let new_search_entries = new_search_matches
4401 .map(|(match_range, search_data)| SearchEntry {
4402 match_range: match_range.clone(),
4403 kind,
4404 render_data: Arc::clone(search_data),
4405 })
4406 .collect::<Vec<_>>();
4407 for new_search_entry in new_search_entries {
4408 self.push_entry(
4409 state,
4410 filter_query.is_some(),
4411 PanelEntry::Search(new_search_entry),
4412 depth,
4413 cx,
4414 );
4415 }
4416 }
4417
4418 fn active_editor(&self) -> Option<Entity<Editor>> {
4419 self.active_item.as_ref()?.active_editor.upgrade()
4420 }
4421
4422 fn active_item(&self) -> Option<Box<dyn ItemHandle>> {
4423 self.active_item.as_ref()?.item_handle.upgrade()
4424 }
4425
4426 fn should_replace_active_item(&self, new_active_item: &dyn ItemHandle) -> bool {
4427 self.active_item().is_none_or(|active_item| {
4428 !self.pinned && active_item.item_id() != new_active_item.item_id()
4429 })
4430 }
4431
4432 pub fn toggle_active_editor_pin(
4433 &mut self,
4434 _: &ToggleActiveEditorPin,
4435 window: &mut Window,
4436 cx: &mut Context<Self>,
4437 ) {
4438 self.pinned = !self.pinned;
4439 if !self.pinned
4440 && let Some((active_item, active_editor)) = self
4441 .workspace
4442 .upgrade()
4443 .and_then(|workspace| workspace_active_editor(workspace.read(cx), cx))
4444 && self.should_replace_active_item(active_item.as_ref())
4445 {
4446 self.replace_active_editor(active_item, active_editor, window, cx);
4447 }
4448
4449 cx.notify();
4450 }
4451
4452 fn selected_entry(&self) -> Option<&PanelEntry> {
4453 match &self.selected_entry {
4454 SelectedEntry::Invalidated(entry) => entry.as_ref(),
4455 SelectedEntry::Valid(entry, _) => Some(entry),
4456 SelectedEntry::None => None,
4457 }
4458 }
4459
4460 fn select_entry(
4461 &mut self,
4462 entry: PanelEntry,
4463 focus: bool,
4464 window: &mut Window,
4465 cx: &mut Context<Self>,
4466 ) {
4467 if focus {
4468 self.focus_handle.focus(window, cx);
4469 }
4470 let ix = self
4471 .cached_entries
4472 .iter()
4473 .enumerate()
4474 .find(|(_, cached_entry)| &cached_entry.entry == &entry)
4475 .map(|(i, _)| i)
4476 .unwrap_or_default();
4477
4478 self.selected_entry = SelectedEntry::Valid(entry, ix);
4479
4480 self.autoscroll(cx);
4481 cx.notify();
4482 }
4483
4484 fn width_estimate(&self, depth: usize, entry: &PanelEntry, cx: &App) -> u64 {
4485 let item_text_chars = match entry {
4486 PanelEntry::Fs(FsEntry::ExternalFile(external)) => self
4487 .buffer_snapshot_for_id(external.buffer_id, cx)
4488 .and_then(|snapshot| Some(snapshot.file()?.path().file_name()?.len()))
4489 .unwrap_or_default(),
4490 PanelEntry::Fs(FsEntry::Directory(directory)) => directory
4491 .entry
4492 .path
4493 .file_name()
4494 .map(|name| name.len())
4495 .unwrap_or_default(),
4496 PanelEntry::Fs(FsEntry::File(file)) => file
4497 .entry
4498 .path
4499 .file_name()
4500 .map(|name| name.len())
4501 .unwrap_or_default(),
4502 PanelEntry::FoldedDirs(folded_dirs) => {
4503 folded_dirs
4504 .entries
4505 .iter()
4506 .map(|dir| {
4507 dir.path
4508 .file_name()
4509 .map(|name| name.len())
4510 .unwrap_or_default()
4511 })
4512 .sum::<usize>()
4513 + folded_dirs.entries.len().saturating_sub(1) * "/".len()
4514 }
4515 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => self
4516 .excerpt_label(&excerpt, cx)
4517 .map(|label| label.len())
4518 .unwrap_or_default(),
4519 PanelEntry::Outline(OutlineEntry::Outline(entry)) => entry.text.len(),
4520 PanelEntry::Search(search) => search
4521 .render_data
4522 .get()
4523 .map(|data| data.context_text.len())
4524 .unwrap_or_default(),
4525 };
4526
4527 (item_text_chars + depth) as u64
4528 }
4529
4530 fn render_main_contents(
4531 &mut self,
4532 query: Option<String>,
4533 show_indent_guides: bool,
4534 indent_size: f32,
4535 window: &mut Window,
4536 cx: &mut Context<Self>,
4537 ) -> impl IntoElement {
4538 let contents = if self.cached_entries.is_empty() {
4539 let header = if query.is_some() {
4540 "No matches for query"
4541 } else {
4542 "No outlines available"
4543 };
4544
4545 v_flex()
4546 .id("empty-outline-state")
4547 .gap_0p5()
4548 .flex_1()
4549 .justify_center()
4550 .size_full()
4551 .child(h_flex().justify_center().child(Label::new(header)))
4552 .when_some(query, |panel, query| {
4553 panel.child(
4554 h_flex()
4555 .px_0p5()
4556 .justify_center()
4557 .bg(cx.theme().colors().element_selected.opacity(0.2))
4558 .child(Label::new(query)),
4559 )
4560 })
4561 .child(h_flex().justify_center().child({
4562 let keystroke = match self.position(window, cx) {
4563 DockPosition::Left => window.keystroke_text_for(&workspace::ToggleLeftDock),
4564 DockPosition::Bottom => {
4565 window.keystroke_text_for(&workspace::ToggleBottomDock)
4566 }
4567 DockPosition::Right => {
4568 window.keystroke_text_for(&workspace::ToggleRightDock)
4569 }
4570 };
4571 Label::new(format!("Toggle Panel With {keystroke}")).color(Color::Muted)
4572 }))
4573 } else {
4574 let list_contents = {
4575 let items_len = self.cached_entries.len();
4576 let multi_buffer_snapshot = self
4577 .active_editor()
4578 .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx));
4579 uniform_list(
4580 "entries",
4581 items_len,
4582 cx.processor(move |outline_panel, range: Range<usize>, window, cx| {
4583 outline_panel.rendered_entries_len = range.end - range.start;
4584 let entries = outline_panel.cached_entries.get(range);
4585 entries
4586 .map(|entries| entries.to_vec())
4587 .unwrap_or_default()
4588 .into_iter()
4589 .filter_map(|cached_entry| match cached_entry.entry {
4590 PanelEntry::Fs(entry) => Some(outline_panel.render_entry(
4591 &entry,
4592 cached_entry.depth,
4593 cached_entry.string_match.as_ref(),
4594 window,
4595 cx,
4596 )),
4597 PanelEntry::FoldedDirs(folded_dirs_entry) => {
4598 Some(outline_panel.render_folded_dirs(
4599 &folded_dirs_entry,
4600 cached_entry.depth,
4601 cached_entry.string_match.as_ref(),
4602 window,
4603 cx,
4604 ))
4605 }
4606 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
4607 outline_panel.render_excerpt(
4608 &excerpt,
4609 cached_entry.depth,
4610 window,
4611 cx,
4612 )
4613 }
4614 PanelEntry::Outline(OutlineEntry::Outline(entry)) => {
4615 Some(outline_panel.render_outline(
4616 &entry,
4617 cached_entry.depth,
4618 cached_entry.string_match.as_ref(),
4619 window,
4620 cx,
4621 ))
4622 }
4623 PanelEntry::Search(SearchEntry {
4624 match_range,
4625 render_data,
4626 kind,
4627 ..
4628 }) => outline_panel.render_search_match(
4629 multi_buffer_snapshot.as_ref(),
4630 &match_range,
4631 &render_data,
4632 kind,
4633 cached_entry.depth,
4634 cached_entry.string_match.as_ref(),
4635 window,
4636 cx,
4637 ),
4638 })
4639 .collect()
4640 }),
4641 )
4642 .with_sizing_behavior(ListSizingBehavior::Infer)
4643 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4644 .with_width_from_item(self.max_width_item_index)
4645 .track_scroll(&self.scroll_handle)
4646 .when(show_indent_guides, |list| {
4647 list.with_decoration(
4648 ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx))
4649 .with_compute_indents_fn(cx.entity(), |outline_panel, range, _, _| {
4650 let entries = outline_panel.cached_entries.get(range);
4651 if let Some(entries) = entries {
4652 entries.iter().map(|item| item.depth).collect()
4653 } else {
4654 smallvec::SmallVec::new()
4655 }
4656 })
4657 .with_render_fn(cx.entity(), move |outline_panel, params, _, _| {
4658 const LEFT_OFFSET: Pixels = px(14.);
4659
4660 let indent_size = params.indent_size;
4661 let item_height = params.item_height;
4662 let active_indent_guide_ix = find_active_indent_guide_ix(
4663 outline_panel,
4664 ¶ms.indent_guides,
4665 );
4666
4667 params
4668 .indent_guides
4669 .into_iter()
4670 .enumerate()
4671 .map(|(ix, layout)| {
4672 let bounds = Bounds::new(
4673 point(
4674 layout.offset.x * indent_size + LEFT_OFFSET,
4675 layout.offset.y * item_height,
4676 ),
4677 size(px(1.), layout.length * item_height),
4678 );
4679 ui::RenderedIndentGuide {
4680 bounds,
4681 layout,
4682 is_active: active_indent_guide_ix == Some(ix),
4683 hitbox: None,
4684 }
4685 })
4686 .collect()
4687 }),
4688 )
4689 })
4690 };
4691
4692 v_flex()
4693 .flex_shrink()
4694 .size_full()
4695 .child(list_contents.size_full().flex_shrink())
4696 .custom_scrollbars(
4697 Scrollbars::for_settings::<OutlinePanelSettingsScrollbarProxy>()
4698 .tracked_scroll_handle(&self.scroll_handle.clone())
4699 .with_track_along(
4700 ScrollAxes::Horizontal,
4701 cx.theme().colors().panel_background,
4702 )
4703 .tracked_entity(cx.entity_id()),
4704 window,
4705 cx,
4706 )
4707 }
4708 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4709 deferred(
4710 anchored()
4711 .position(*position)
4712 .anchor(gpui::Corner::TopLeft)
4713 .child(menu.clone()),
4714 )
4715 .with_priority(1)
4716 }));
4717
4718 v_flex().w_full().flex_1().overflow_hidden().child(contents)
4719 }
4720
4721 fn render_filter_footer(&mut self, pinned: bool, cx: &mut Context<Self>) -> Div {
4722 let (icon, icon_tooltip) = if pinned {
4723 (IconName::Unpin, "Unpin Outline")
4724 } else {
4725 (IconName::Pin, "Pin Active Outline")
4726 };
4727
4728 let has_query = self.query(cx).is_some();
4729
4730 h_flex()
4731 .p_2()
4732 .h(Tab::container_height(cx))
4733 .justify_between()
4734 .border_b_1()
4735 .border_color(cx.theme().colors().border)
4736 .child(
4737 h_flex()
4738 .w_full()
4739 .gap_1p5()
4740 .child(
4741 Icon::new(IconName::MagnifyingGlass)
4742 .size(IconSize::Small)
4743 .color(Color::Muted),
4744 )
4745 .child(self.filter_editor.clone()),
4746 )
4747 .child(
4748 h_flex()
4749 .when(has_query, |this| {
4750 this.child(
4751 IconButton::new("clear_filter", IconName::Close)
4752 .shape(IconButtonShape::Square)
4753 .tooltip(Tooltip::text("Clear Filter"))
4754 .on_click(cx.listener(|outline_panel, _, window, cx| {
4755 outline_panel.filter_editor.update(cx, |editor, cx| {
4756 editor.set_text("", window, cx);
4757 });
4758 cx.notify();
4759 })),
4760 )
4761 })
4762 .child(
4763 IconButton::new("pin_button", icon)
4764 .tooltip(Tooltip::text(icon_tooltip))
4765 .shape(IconButtonShape::Square)
4766 .on_click(cx.listener(|outline_panel, _, window, cx| {
4767 outline_panel.toggle_active_editor_pin(
4768 &ToggleActiveEditorPin,
4769 window,
4770 cx,
4771 );
4772 })),
4773 ),
4774 )
4775 }
4776
4777 fn buffers_inside_directory(
4778 &self,
4779 dir_worktree: WorktreeId,
4780 dir_entry: &GitEntry,
4781 ) -> HashSet<BufferId> {
4782 if !dir_entry.is_dir() {
4783 debug_panic!("buffers_inside_directory called on a non-directory entry {dir_entry:?}");
4784 return HashSet::default();
4785 }
4786
4787 self.fs_entries
4788 .iter()
4789 .skip_while(|fs_entry| match fs_entry {
4790 FsEntry::Directory(directory) => {
4791 directory.worktree_id != dir_worktree || &directory.entry != dir_entry
4792 }
4793 _ => true,
4794 })
4795 .skip(1)
4796 .take_while(|fs_entry| match fs_entry {
4797 FsEntry::ExternalFile(..) => false,
4798 FsEntry::Directory(directory) => {
4799 directory.worktree_id == dir_worktree
4800 && directory.entry.path.starts_with(&dir_entry.path)
4801 }
4802 FsEntry::File(file) => {
4803 file.worktree_id == dir_worktree && file.entry.path.starts_with(&dir_entry.path)
4804 }
4805 })
4806 .filter_map(|fs_entry| match fs_entry {
4807 FsEntry::File(file) => Some(file.buffer_id),
4808 _ => None,
4809 })
4810 .collect()
4811 }
4812}
4813
4814fn workspace_active_editor(
4815 workspace: &Workspace,
4816 cx: &App,
4817) -> Option<(Box<dyn ItemHandle>, Entity<Editor>)> {
4818 let active_item = workspace.active_item(cx)?;
4819 let active_editor = active_item
4820 .act_as::<Editor>(cx)
4821 .filter(|editor| editor.read(cx).mode().is_full())?;
4822 Some((active_item, active_editor))
4823}
4824
4825fn back_to_common_visited_parent(
4826 visited_dirs: &mut Vec<(ProjectEntryId, Arc<RelPath>)>,
4827 worktree_id: &WorktreeId,
4828 new_entry: &Entry,
4829) -> Option<(WorktreeId, ProjectEntryId)> {
4830 while let Some((visited_dir_id, visited_path)) = visited_dirs.last() {
4831 match new_entry.path.parent() {
4832 Some(parent_path) => {
4833 if parent_path == visited_path.as_ref() {
4834 return Some((*worktree_id, *visited_dir_id));
4835 }
4836 }
4837 None => {
4838 break;
4839 }
4840 }
4841 visited_dirs.pop();
4842 }
4843 None
4844}
4845
4846fn file_name(path: &Path) -> String {
4847 let mut current_path = path;
4848 loop {
4849 if let Some(file_name) = current_path.file_name() {
4850 return file_name.to_string_lossy().into_owned();
4851 }
4852 match current_path.parent() {
4853 Some(parent) => current_path = parent,
4854 None => return path.to_string_lossy().into_owned(),
4855 }
4856 }
4857}
4858
4859impl Panel for OutlinePanel {
4860 fn persistent_name() -> &'static str {
4861 "Outline Panel"
4862 }
4863
4864 fn panel_key() -> &'static str {
4865 OUTLINE_PANEL_KEY
4866 }
4867
4868 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4869 match OutlinePanelSettings::get_global(cx).dock {
4870 DockSide::Left => DockPosition::Left,
4871 DockSide::Right => DockPosition::Right,
4872 }
4873 }
4874
4875 fn position_is_valid(&self, position: DockPosition) -> bool {
4876 matches!(position, DockPosition::Left | DockPosition::Right)
4877 }
4878
4879 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4880 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
4881 let dock = match position {
4882 DockPosition::Left | DockPosition::Bottom => DockSide::Left,
4883 DockPosition::Right => DockSide::Right,
4884 };
4885 settings.outline_panel.get_or_insert_default().dock = Some(dock);
4886 });
4887 }
4888
4889 fn default_size(&self, _: &Window, cx: &App) -> Pixels {
4890 OutlinePanelSettings::get_global(cx).default_width
4891 }
4892
4893 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4894 OutlinePanelSettings::get_global(cx)
4895 .button
4896 .then_some(IconName::ListTree)
4897 }
4898
4899 fn icon_tooltip(&self, _window: &Window, _: &App) -> Option<&'static str> {
4900 Some("Outline Panel")
4901 }
4902
4903 fn toggle_action(&self) -> Box<dyn Action> {
4904 Box::new(ToggleFocus)
4905 }
4906
4907 fn starts_open(&self, _window: &Window, _: &App) -> bool {
4908 self.active
4909 }
4910
4911 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
4912 cx.spawn_in(window, async move |outline_panel, cx| {
4913 outline_panel
4914 .update_in(cx, |outline_panel, window, cx| {
4915 let old_active = outline_panel.active;
4916 outline_panel.active = active;
4917 if old_active != active {
4918 if active
4919 && let Some((active_item, active_editor)) =
4920 outline_panel.workspace.upgrade().and_then(|workspace| {
4921 workspace_active_editor(workspace.read(cx), cx)
4922 })
4923 {
4924 if outline_panel.should_replace_active_item(active_item.as_ref()) {
4925 outline_panel.replace_active_editor(
4926 active_item,
4927 active_editor,
4928 window,
4929 cx,
4930 );
4931 } else {
4932 outline_panel.update_fs_entries(active_editor, None, window, cx)
4933 }
4934 return;
4935 }
4936
4937 if !outline_panel.pinned {
4938 outline_panel.clear_previous(window, cx);
4939 }
4940 }
4941 outline_panel.serialize(cx);
4942 })
4943 .ok();
4944 })
4945 .detach()
4946 }
4947
4948 fn activation_priority(&self) -> u32 {
4949 6
4950 }
4951}
4952
4953impl Focusable for OutlinePanel {
4954 fn focus_handle(&self, cx: &App) -> FocusHandle {
4955 self.filter_editor.focus_handle(cx)
4956 }
4957}
4958
4959impl EventEmitter<Event> for OutlinePanel {}
4960
4961impl EventEmitter<PanelEvent> for OutlinePanel {}
4962
4963impl Render for OutlinePanel {
4964 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4965 let (is_local, is_via_ssh) = self.project.read_with(cx, |project, _| {
4966 (project.is_local(), project.is_via_remote_server())
4967 });
4968 let query = self.query(cx);
4969 let pinned = self.pinned;
4970 let settings = OutlinePanelSettings::get_global(cx);
4971 let indent_size = settings.indent_size;
4972 let show_indent_guides = settings.indent_guides.show == ShowIndentGuides::Always;
4973
4974 let search_query = match &self.mode {
4975 ItemsDisplayMode::Search(search_query) => Some(search_query),
4976 _ => None,
4977 };
4978
4979 let search_query_text = search_query.map(|sq| sq.query.to_string());
4980
4981 v_flex()
4982 .id("outline-panel")
4983 .size_full()
4984 .overflow_hidden()
4985 .relative()
4986 .key_context(self.dispatch_context(window, cx))
4987 .on_action(cx.listener(Self::open_selected_entry))
4988 .on_action(cx.listener(Self::cancel))
4989 .on_action(cx.listener(Self::scroll_up))
4990 .on_action(cx.listener(Self::scroll_down))
4991 .on_action(cx.listener(Self::select_next))
4992 .on_action(cx.listener(Self::scroll_cursor_center))
4993 .on_action(cx.listener(Self::scroll_cursor_top))
4994 .on_action(cx.listener(Self::scroll_cursor_bottom))
4995 .on_action(cx.listener(Self::select_previous))
4996 .on_action(cx.listener(Self::select_first))
4997 .on_action(cx.listener(Self::select_last))
4998 .on_action(cx.listener(Self::select_parent))
4999 .on_action(cx.listener(Self::expand_selected_entry))
5000 .on_action(cx.listener(Self::collapse_selected_entry))
5001 .on_action(cx.listener(Self::expand_all_entries))
5002 .on_action(cx.listener(Self::collapse_all_entries))
5003 .on_action(cx.listener(Self::copy_path))
5004 .on_action(cx.listener(Self::copy_relative_path))
5005 .on_action(cx.listener(Self::toggle_active_editor_pin))
5006 .on_action(cx.listener(Self::unfold_directory))
5007 .on_action(cx.listener(Self::fold_directory))
5008 .on_action(cx.listener(Self::open_excerpts))
5009 .on_action(cx.listener(Self::open_excerpts_split))
5010 .when(is_local, |el| {
5011 el.on_action(cx.listener(Self::reveal_in_finder))
5012 })
5013 .when(is_local || is_via_ssh, |el| {
5014 el.on_action(cx.listener(Self::open_in_terminal))
5015 })
5016 .on_mouse_down(
5017 MouseButton::Right,
5018 cx.listener(move |outline_panel, event: &MouseDownEvent, window, cx| {
5019 if let Some(entry) = outline_panel.selected_entry().cloned() {
5020 outline_panel.deploy_context_menu(event.position, entry, window, cx)
5021 } else if let Some(entry) = outline_panel.fs_entries.first().cloned() {
5022 outline_panel.deploy_context_menu(
5023 event.position,
5024 PanelEntry::Fs(entry),
5025 window,
5026 cx,
5027 )
5028 }
5029 }),
5030 )
5031 .track_focus(&self.focus_handle)
5032 .child(self.render_filter_footer(pinned, cx))
5033 .when_some(search_query_text, |outline_panel, query_text| {
5034 outline_panel.child(
5035 h_flex()
5036 .py_1p5()
5037 .px_2()
5038 .h(Tab::container_height(cx))
5039 .gap_0p5()
5040 .border_b_1()
5041 .border_color(cx.theme().colors().border_variant)
5042 .child(Label::new("Searching:").color(Color::Muted))
5043 .child(Label::new(query_text)),
5044 )
5045 })
5046 .child(self.render_main_contents(query, show_indent_guides, indent_size, window, cx))
5047 }
5048}
5049
5050fn find_active_indent_guide_ix(
5051 outline_panel: &OutlinePanel,
5052 candidates: &[IndentGuideLayout],
5053) -> Option<usize> {
5054 let SelectedEntry::Valid(_, target_ix) = &outline_panel.selected_entry else {
5055 return None;
5056 };
5057 let target_depth = outline_panel
5058 .cached_entries
5059 .get(*target_ix)
5060 .map(|cached_entry| cached_entry.depth)?;
5061
5062 let (target_ix, target_depth) = if let Some(target_depth) = outline_panel
5063 .cached_entries
5064 .get(target_ix + 1)
5065 .filter(|cached_entry| cached_entry.depth > target_depth)
5066 .map(|entry| entry.depth)
5067 {
5068 (target_ix + 1, target_depth.saturating_sub(1))
5069 } else {
5070 (*target_ix, target_depth.saturating_sub(1))
5071 };
5072
5073 candidates
5074 .iter()
5075 .enumerate()
5076 .find(|(_, guide)| {
5077 guide.offset.y <= target_ix
5078 && target_ix < guide.offset.y + guide.length
5079 && guide.offset.x == target_depth
5080 })
5081 .map(|(ix, _)| ix)
5082}
5083
5084fn subscribe_for_editor_events(
5085 editor: &Entity<Editor>,
5086 window: &mut Window,
5087 cx: &mut Context<OutlinePanel>,
5088) -> Subscription {
5089 let debounce = Some(UPDATE_DEBOUNCE);
5090 cx.subscribe_in(
5091 editor,
5092 window,
5093 move |outline_panel, editor, e: &EditorEvent, window, cx| {
5094 if !outline_panel.active {
5095 return;
5096 }
5097 match e {
5098 EditorEvent::SelectionsChanged { local: true } => {
5099 outline_panel.reveal_entry_for_selection(editor.clone(), window, cx);
5100 cx.notify();
5101 }
5102 EditorEvent::BuffersRemoved { removed_buffer_ids } => {
5103 outline_panel
5104 .buffers
5105 .retain(|buffer_id, _| !removed_buffer_ids.contains(buffer_id));
5106 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5107 }
5108 EditorEvent::BufferRangesUpdated { buffer, .. } => {
5109 outline_panel
5110 .new_entries_for_fs_update
5111 .insert(buffer.read(cx).remote_id());
5112 outline_panel.invalidate_outlines(&[buffer.read(cx).remote_id()]);
5113 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5114 }
5115 EditorEvent::BuffersEdited { buffer_ids } => {
5116 outline_panel.invalidate_outlines(buffer_ids);
5117 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
5118 if update_cached_items {
5119 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5120 }
5121 }
5122 EditorEvent::BufferFoldToggled { ids, .. } => {
5123 outline_panel.invalidate_outlines(ids);
5124 let mut latest_unfolded_buffer_id = None;
5125 let mut latest_folded_buffer_id = None;
5126 let mut ignore_selections_change = false;
5127 outline_panel.new_entries_for_fs_update.extend(
5128 ids.iter()
5129 .filter(|id| {
5130 if outline_panel.buffers.contains_key(&id) {
5131 ignore_selections_change |= outline_panel
5132 .preserve_selection_on_buffer_fold_toggles
5133 .remove(&id);
5134 if editor.read(cx).is_buffer_folded(**id, cx) {
5135 latest_folded_buffer_id = Some(**id);
5136 false
5137 } else {
5138 latest_unfolded_buffer_id = Some(**id);
5139 true
5140 }
5141 } else {
5142 false
5143 }
5144 })
5145 .copied(),
5146 );
5147 if !ignore_selections_change
5148 && let Some(entry_to_select) = latest_unfolded_buffer_id
5149 .or(latest_folded_buffer_id)
5150 .and_then(|toggled_buffer_id| {
5151 outline_panel.fs_entries.iter().find_map(
5152 |fs_entry| match fs_entry {
5153 FsEntry::ExternalFile(external) => {
5154 if external.buffer_id == toggled_buffer_id {
5155 Some(fs_entry.clone())
5156 } else {
5157 None
5158 }
5159 }
5160 FsEntry::File(FsEntryFile { buffer_id, .. }) => {
5161 if *buffer_id == toggled_buffer_id {
5162 Some(fs_entry.clone())
5163 } else {
5164 None
5165 }
5166 }
5167 FsEntry::Directory(..) => None,
5168 },
5169 )
5170 })
5171 .map(PanelEntry::Fs)
5172 {
5173 outline_panel.select_entry(entry_to_select, true, window, cx);
5174 }
5175
5176 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5177 }
5178 EditorEvent::Reparsed(buffer_id) => {
5179 if let Some(buffer) = outline_panel.buffers.get_mut(buffer_id) {
5180 buffer.invalidate_outlines();
5181 }
5182 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
5183 if update_cached_items {
5184 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5185 }
5186 }
5187 EditorEvent::OutlineSymbolsChanged => {
5188 for buffer in outline_panel.buffers.values_mut() {
5189 buffer.invalidate_outlines();
5190 }
5191 if matches!(
5192 outline_panel.selected_entry(),
5193 Some(PanelEntry::Outline(..)),
5194 ) {
5195 outline_panel.selected_entry.invalidate();
5196 }
5197 if outline_panel.update_non_fs_items(window, cx) {
5198 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5199 }
5200 }
5201 EditorEvent::TitleChanged => {
5202 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5203 }
5204 _ => {}
5205 }
5206 },
5207 )
5208}
5209
5210fn empty_icon() -> AnyElement {
5211 h_flex()
5212 .size(IconSize::default().rems())
5213 .invisible()
5214 .flex_none()
5215 .into_any_element()
5216}
5217
5218#[derive(Debug, Default)]
5219struct GenerationState {
5220 entries: Vec<CachedEntry>,
5221 match_candidates: Vec<StringMatchCandidate>,
5222 max_width_estimate_and_index: Option<(u64, usize)>,
5223}
5224
5225impl GenerationState {
5226 fn clear(&mut self) {
5227 self.entries.clear();
5228 self.match_candidates.clear();
5229 self.max_width_estimate_and_index = None;
5230 }
5231}
5232
5233#[cfg(test)]
5234mod tests {
5235 use db::indoc;
5236 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle};
5237 use language::{self, FakeLspAdapter, markdown_lang, rust_lang};
5238 use pretty_assertions::assert_eq;
5239 use project::FakeFs;
5240 use search::{
5241 buffer_search,
5242 project_search::{self, perform_project_search},
5243 };
5244 use serde_json::json;
5245 use smol::stream::StreamExt as _;
5246 use util::path;
5247 use workspace::{MultiWorkspace, OpenOptions, OpenVisible, ToolbarItemView};
5248
5249 use super::*;
5250
5251 const SELECTED_MARKER: &str = " <==== selected";
5252
5253 #[gpui::test(iterations = 10)]
5254 async fn test_project_search_results_toggling(cx: &mut TestAppContext) {
5255 init_test(cx);
5256
5257 let fs = FakeFs::new(cx.background_executor.clone());
5258 let root = path!("/rust-analyzer");
5259 populate_with_test_ra_project(&fs, root).await;
5260 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5261 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
5262 let (window, workspace) = add_outline_panel(&project, cx).await;
5263 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5264 let outline_panel = outline_panel(&workspace, cx);
5265 outline_panel.update_in(cx, |outline_panel, window, cx| {
5266 outline_panel.set_active(true, window, cx)
5267 });
5268
5269 workspace.update_in(cx, |workspace, window, cx| {
5270 ProjectSearchView::deploy_search(
5271 workspace,
5272 &workspace::DeploySearch::default(),
5273 window,
5274 cx,
5275 )
5276 });
5277 let search_view = workspace.update_in(cx, |workspace, _window, cx| {
5278 workspace
5279 .active_pane()
5280 .read(cx)
5281 .items()
5282 .find_map(|item| item.downcast::<ProjectSearchView>())
5283 .expect("Project search view expected to appear after new search event trigger")
5284 });
5285
5286 let query = "param_names_for_lifetime_elision_hints";
5287 perform_project_search(&search_view, query, cx);
5288 search_view.update(cx, |search_view, cx| {
5289 search_view
5290 .results_editor()
5291 .update(cx, |results_editor, cx| {
5292 assert_eq!(
5293 results_editor.display_text(cx).match_indices(query).count(),
5294 9
5295 );
5296 });
5297 });
5298
5299 let all_matches = r#"rust-analyzer/
5300 crates/
5301 ide/src/
5302 inlay_hints/
5303 fn_lifetime_fn.rs
5304 search: match config.«param_names_for_lifetime_elision_hints» {
5305 search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
5306 search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
5307 search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
5308 inlay_hints.rs
5309 search: pub «param_names_for_lifetime_elision_hints»: bool,
5310 search: «param_names_for_lifetime_elision_hints»: self
5311 static_index.rs
5312 search: «param_names_for_lifetime_elision_hints»: false,
5313 rust-analyzer/src/
5314 cli/
5315 analysis_stats.rs
5316 search: «param_names_for_lifetime_elision_hints»: true,
5317 config.rs
5318 search: «param_names_for_lifetime_elision_hints»: self"#
5319 .to_string();
5320
5321 let select_first_in_all_matches = |line_to_select: &str| {
5322 assert!(
5323 all_matches.contains(line_to_select),
5324 "`{line_to_select}` was not found in all matches `{all_matches}`"
5325 );
5326 all_matches.replacen(
5327 line_to_select,
5328 &format!("{line_to_select}{SELECTED_MARKER}"),
5329 1,
5330 )
5331 };
5332
5333 cx.executor()
5334 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5335 cx.run_until_parked();
5336 outline_panel.update(cx, |outline_panel, cx| {
5337 assert_eq!(
5338 display_entries(
5339 &project,
5340 &snapshot(outline_panel, cx),
5341 &outline_panel.cached_entries,
5342 outline_panel.selected_entry(),
5343 cx,
5344 ),
5345 select_first_in_all_matches(
5346 "search: match config.«param_names_for_lifetime_elision_hints» {"
5347 )
5348 );
5349 });
5350
5351 outline_panel.update_in(cx, |outline_panel, window, cx| {
5352 outline_panel.select_parent(&SelectParent, window, cx);
5353 assert_eq!(
5354 display_entries(
5355 &project,
5356 &snapshot(outline_panel, cx),
5357 &outline_panel.cached_entries,
5358 outline_panel.selected_entry(),
5359 cx,
5360 ),
5361 select_first_in_all_matches("fn_lifetime_fn.rs")
5362 );
5363 });
5364 outline_panel.update_in(cx, |outline_panel, window, cx| {
5365 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5366 });
5367 cx.executor()
5368 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5369 cx.run_until_parked();
5370 outline_panel.update(cx, |outline_panel, cx| {
5371 assert_eq!(
5372 display_entries(
5373 &project,
5374 &snapshot(outline_panel, cx),
5375 &outline_panel.cached_entries,
5376 outline_panel.selected_entry(),
5377 cx,
5378 ),
5379 format!(
5380 r#"rust-analyzer/
5381 crates/
5382 ide/src/
5383 inlay_hints/
5384 fn_lifetime_fn.rs{SELECTED_MARKER}
5385 inlay_hints.rs
5386 search: pub «param_names_for_lifetime_elision_hints»: bool,
5387 search: «param_names_for_lifetime_elision_hints»: self
5388 static_index.rs
5389 search: «param_names_for_lifetime_elision_hints»: false,
5390 rust-analyzer/src/
5391 cli/
5392 analysis_stats.rs
5393 search: «param_names_for_lifetime_elision_hints»: true,
5394 config.rs
5395 search: «param_names_for_lifetime_elision_hints»: self"#,
5396 )
5397 );
5398 });
5399
5400 outline_panel.update_in(cx, |outline_panel, window, cx| {
5401 outline_panel.expand_all_entries(&ExpandAllEntries, window, cx);
5402 });
5403 cx.executor()
5404 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5405 cx.run_until_parked();
5406 outline_panel.update_in(cx, |outline_panel, window, cx| {
5407 outline_panel.select_parent(&SelectParent, window, cx);
5408 assert_eq!(
5409 display_entries(
5410 &project,
5411 &snapshot(outline_panel, cx),
5412 &outline_panel.cached_entries,
5413 outline_panel.selected_entry(),
5414 cx,
5415 ),
5416 select_first_in_all_matches("inlay_hints/")
5417 );
5418 });
5419
5420 outline_panel.update_in(cx, |outline_panel, window, cx| {
5421 outline_panel.select_parent(&SelectParent, window, cx);
5422 assert_eq!(
5423 display_entries(
5424 &project,
5425 &snapshot(outline_panel, cx),
5426 &outline_panel.cached_entries,
5427 outline_panel.selected_entry(),
5428 cx,
5429 ),
5430 select_first_in_all_matches("ide/src/")
5431 );
5432 });
5433
5434 outline_panel.update_in(cx, |outline_panel, window, cx| {
5435 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5436 });
5437 cx.executor()
5438 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5439 cx.run_until_parked();
5440 outline_panel.update(cx, |outline_panel, cx| {
5441 assert_eq!(
5442 display_entries(
5443 &project,
5444 &snapshot(outline_panel, cx),
5445 &outline_panel.cached_entries,
5446 outline_panel.selected_entry(),
5447 cx,
5448 ),
5449 format!(
5450 r#"rust-analyzer/
5451 crates/
5452 ide/src/{SELECTED_MARKER}
5453 rust-analyzer/src/
5454 cli/
5455 analysis_stats.rs
5456 search: «param_names_for_lifetime_elision_hints»: true,
5457 config.rs
5458 search: «param_names_for_lifetime_elision_hints»: self"#,
5459 )
5460 );
5461 });
5462 outline_panel.update_in(cx, |outline_panel, window, cx| {
5463 outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
5464 });
5465 cx.executor()
5466 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5467 cx.run_until_parked();
5468 outline_panel.update(cx, |outline_panel, cx| {
5469 assert_eq!(
5470 display_entries(
5471 &project,
5472 &snapshot(outline_panel, cx),
5473 &outline_panel.cached_entries,
5474 outline_panel.selected_entry(),
5475 cx,
5476 ),
5477 select_first_in_all_matches("ide/src/")
5478 );
5479 });
5480 }
5481
5482 #[gpui::test(iterations = 10)]
5483 async fn test_item_filtering(cx: &mut TestAppContext) {
5484 init_test(cx);
5485
5486 let fs = FakeFs::new(cx.background_executor.clone());
5487 let root = path!("/rust-analyzer");
5488 populate_with_test_ra_project(&fs, root).await;
5489 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5490 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
5491 let (window, workspace) = add_outline_panel(&project, cx).await;
5492 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5493 let outline_panel = outline_panel(&workspace, cx);
5494 outline_panel.update_in(cx, |outline_panel, window, cx| {
5495 outline_panel.set_active(true, window, cx)
5496 });
5497
5498 workspace.update_in(cx, |workspace, window, cx| {
5499 ProjectSearchView::deploy_search(
5500 workspace,
5501 &workspace::DeploySearch::default(),
5502 window,
5503 cx,
5504 )
5505 });
5506 let search_view = workspace.update_in(cx, |workspace, _window, cx| {
5507 workspace
5508 .active_pane()
5509 .read(cx)
5510 .items()
5511 .find_map(|item| item.downcast::<ProjectSearchView>())
5512 .expect("Project search view expected to appear after new search event trigger")
5513 });
5514
5515 let query = "param_names_for_lifetime_elision_hints";
5516 perform_project_search(&search_view, query, cx);
5517 search_view.update(cx, |search_view, cx| {
5518 search_view
5519 .results_editor()
5520 .update(cx, |results_editor, cx| {
5521 assert_eq!(
5522 results_editor.display_text(cx).match_indices(query).count(),
5523 9
5524 );
5525 });
5526 });
5527 let all_matches = r#"rust-analyzer/
5528 crates/
5529 ide/src/
5530 inlay_hints/
5531 fn_lifetime_fn.rs
5532 search: match config.«param_names_for_lifetime_elision_hints» {
5533 search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
5534 search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
5535 search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
5536 inlay_hints.rs
5537 search: pub «param_names_for_lifetime_elision_hints»: bool,
5538 search: «param_names_for_lifetime_elision_hints»: self
5539 static_index.rs
5540 search: «param_names_for_lifetime_elision_hints»: false,
5541 rust-analyzer/src/
5542 cli/
5543 analysis_stats.rs
5544 search: «param_names_for_lifetime_elision_hints»: true,
5545 config.rs
5546 search: «param_names_for_lifetime_elision_hints»: self"#
5547 .to_string();
5548
5549 cx.executor()
5550 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5551 cx.run_until_parked();
5552 outline_panel.update(cx, |outline_panel, cx| {
5553 assert_eq!(
5554 display_entries(
5555 &project,
5556 &snapshot(outline_panel, cx),
5557 &outline_panel.cached_entries,
5558 None,
5559 cx,
5560 ),
5561 all_matches,
5562 );
5563 });
5564
5565 let filter_text = "a";
5566 outline_panel.update_in(cx, |outline_panel, window, cx| {
5567 outline_panel.filter_editor.update(cx, |filter_editor, cx| {
5568 filter_editor.set_text(filter_text, window, cx);
5569 });
5570 });
5571 cx.executor()
5572 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5573 cx.run_until_parked();
5574
5575 outline_panel.update(cx, |outline_panel, cx| {
5576 assert_eq!(
5577 display_entries(
5578 &project,
5579 &snapshot(outline_panel, cx),
5580 &outline_panel.cached_entries,
5581 None,
5582 cx,
5583 ),
5584 all_matches
5585 .lines()
5586 .skip(1) // `/rust-analyzer/` is a root entry with path `` and it will be filtered out
5587 .filter(|item| item.contains(filter_text))
5588 .collect::<Vec<_>>()
5589 .join("\n"),
5590 );
5591 });
5592
5593 outline_panel.update_in(cx, |outline_panel, window, cx| {
5594 outline_panel.filter_editor.update(cx, |filter_editor, cx| {
5595 filter_editor.set_text("", window, cx);
5596 });
5597 });
5598 cx.executor()
5599 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5600 cx.run_until_parked();
5601 outline_panel.update(cx, |outline_panel, cx| {
5602 assert_eq!(
5603 display_entries(
5604 &project,
5605 &snapshot(outline_panel, cx),
5606 &outline_panel.cached_entries,
5607 None,
5608 cx,
5609 ),
5610 all_matches,
5611 );
5612 });
5613 }
5614
5615 #[gpui::test(iterations = 10)]
5616 async fn test_item_opening(cx: &mut TestAppContext) {
5617 init_test(cx);
5618
5619 let fs = FakeFs::new(cx.background_executor.clone());
5620 let root = path!("/rust-analyzer");
5621 populate_with_test_ra_project(&fs, root).await;
5622 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5623 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
5624 let (window, workspace) = add_outline_panel(&project, cx).await;
5625 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5626 let outline_panel = outline_panel(&workspace, cx);
5627 outline_panel.update_in(cx, |outline_panel, window, cx| {
5628 outline_panel.set_active(true, window, cx)
5629 });
5630
5631 workspace.update_in(cx, |workspace, window, cx| {
5632 ProjectSearchView::deploy_search(
5633 workspace,
5634 &workspace::DeploySearch::default(),
5635 window,
5636 cx,
5637 )
5638 });
5639 let search_view = workspace.update_in(cx, |workspace, _window, cx| {
5640 workspace
5641 .active_pane()
5642 .read(cx)
5643 .items()
5644 .find_map(|item| item.downcast::<ProjectSearchView>())
5645 .expect("Project search view expected to appear after new search event trigger")
5646 });
5647
5648 let query = "param_names_for_lifetime_elision_hints";
5649 perform_project_search(&search_view, query, cx);
5650 search_view.update(cx, |search_view, cx| {
5651 search_view
5652 .results_editor()
5653 .update(cx, |results_editor, cx| {
5654 assert_eq!(
5655 results_editor.display_text(cx).match_indices(query).count(),
5656 9
5657 );
5658 });
5659 });
5660 let all_matches = r#"rust-analyzer/
5661 crates/
5662 ide/src/
5663 inlay_hints/
5664 fn_lifetime_fn.rs
5665 search: match config.«param_names_for_lifetime_elision_hints» {
5666 search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
5667 search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
5668 search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
5669 inlay_hints.rs
5670 search: pub «param_names_for_lifetime_elision_hints»: bool,
5671 search: «param_names_for_lifetime_elision_hints»: self
5672 static_index.rs
5673 search: «param_names_for_lifetime_elision_hints»: false,
5674 rust-analyzer/src/
5675 cli/
5676 analysis_stats.rs
5677 search: «param_names_for_lifetime_elision_hints»: true,
5678 config.rs
5679 search: «param_names_for_lifetime_elision_hints»: self"#
5680 .to_string();
5681 let select_first_in_all_matches = |line_to_select: &str| {
5682 assert!(
5683 all_matches.contains(line_to_select),
5684 "`{line_to_select}` was not found in all matches `{all_matches}`"
5685 );
5686 all_matches.replacen(
5687 line_to_select,
5688 &format!("{line_to_select}{SELECTED_MARKER}"),
5689 1,
5690 )
5691 };
5692 let clear_outline_metadata = |input: &str| {
5693 input
5694 .replace("search: ", "")
5695 .replace("«", "")
5696 .replace("»", "")
5697 };
5698
5699 cx.executor()
5700 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5701 cx.run_until_parked();
5702
5703 let active_editor = outline_panel.read_with(cx, |outline_panel, _| {
5704 outline_panel
5705 .active_editor()
5706 .expect("should have an active editor open")
5707 });
5708 let initial_outline_selection =
5709 "search: match config.«param_names_for_lifetime_elision_hints» {";
5710 outline_panel.update_in(cx, |outline_panel, window, cx| {
5711 assert_eq!(
5712 display_entries(
5713 &project,
5714 &snapshot(outline_panel, cx),
5715 &outline_panel.cached_entries,
5716 outline_panel.selected_entry(),
5717 cx,
5718 ),
5719 select_first_in_all_matches(initial_outline_selection)
5720 );
5721 assert_eq!(
5722 selected_row_text(&active_editor, cx),
5723 clear_outline_metadata(initial_outline_selection),
5724 "Should place the initial editor selection on the corresponding search result"
5725 );
5726
5727 outline_panel.select_next(&SelectNext, window, cx);
5728 outline_panel.select_next(&SelectNext, window, cx);
5729 });
5730
5731 let navigated_outline_selection =
5732 "search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {";
5733 outline_panel.update(cx, |outline_panel, cx| {
5734 assert_eq!(
5735 display_entries(
5736 &project,
5737 &snapshot(outline_panel, cx),
5738 &outline_panel.cached_entries,
5739 outline_panel.selected_entry(),
5740 cx,
5741 ),
5742 select_first_in_all_matches(navigated_outline_selection)
5743 );
5744 });
5745 cx.executor()
5746 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5747 outline_panel.update(cx, |_, cx| {
5748 assert_eq!(
5749 selected_row_text(&active_editor, cx),
5750 clear_outline_metadata(navigated_outline_selection),
5751 "Should still have the initial caret position after SelectNext calls"
5752 );
5753 });
5754
5755 outline_panel.update_in(cx, |outline_panel, window, cx| {
5756 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
5757 });
5758 outline_panel.update(cx, |_outline_panel, cx| {
5759 assert_eq!(
5760 selected_row_text(&active_editor, cx),
5761 clear_outline_metadata(navigated_outline_selection),
5762 "After opening, should move the caret to the opened outline entry's position"
5763 );
5764 });
5765
5766 outline_panel.update_in(cx, |outline_panel, window, cx| {
5767 outline_panel.select_next(&SelectNext, window, cx);
5768 });
5769 let next_navigated_outline_selection = "search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },";
5770 outline_panel.update(cx, |outline_panel, cx| {
5771 assert_eq!(
5772 display_entries(
5773 &project,
5774 &snapshot(outline_panel, cx),
5775 &outline_panel.cached_entries,
5776 outline_panel.selected_entry(),
5777 cx,
5778 ),
5779 select_first_in_all_matches(next_navigated_outline_selection)
5780 );
5781 });
5782 cx.executor()
5783 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5784 outline_panel.update(cx, |_outline_panel, cx| {
5785 assert_eq!(
5786 selected_row_text(&active_editor, cx),
5787 clear_outline_metadata(next_navigated_outline_selection),
5788 "Should again preserve the selection after another SelectNext call"
5789 );
5790 });
5791
5792 outline_panel.update_in(cx, |outline_panel, window, cx| {
5793 outline_panel.open_excerpts(&editor::actions::OpenExcerpts, window, cx);
5794 });
5795 cx.executor()
5796 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5797 cx.run_until_parked();
5798 let new_active_editor = outline_panel.read_with(cx, |outline_panel, _| {
5799 outline_panel
5800 .active_editor()
5801 .expect("should have an active editor open")
5802 });
5803 outline_panel.update(cx, |outline_panel, cx| {
5804 assert_ne!(
5805 active_editor, new_active_editor,
5806 "After opening an excerpt, new editor should be open"
5807 );
5808 assert_eq!(
5809 display_entries(
5810 &project,
5811 &snapshot(outline_panel, cx),
5812 &outline_panel.cached_entries,
5813 outline_panel.selected_entry(),
5814 cx,
5815 ),
5816 "outline: pub(super) fn hints
5817outline: fn hints_lifetimes_named <==== selected"
5818 );
5819 assert_eq!(
5820 selected_row_text(&new_active_editor, cx),
5821 clear_outline_metadata(next_navigated_outline_selection),
5822 "When opening the excerpt, should navigate to the place corresponding the outline entry"
5823 );
5824 });
5825 }
5826
5827 #[gpui::test]
5828 async fn test_multiple_worktrees(cx: &mut TestAppContext) {
5829 init_test(cx);
5830
5831 let fs = FakeFs::new(cx.background_executor.clone());
5832 fs.insert_tree(
5833 path!("/root"),
5834 json!({
5835 "one": {
5836 "a.txt": "aaa aaa"
5837 },
5838 "two": {
5839 "b.txt": "a aaa"
5840 }
5841
5842 }),
5843 )
5844 .await;
5845 let project = Project::test(fs.clone(), [Path::new(path!("/root/one"))], cx).await;
5846 let (window, workspace) = add_outline_panel(&project, cx).await;
5847 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5848 let outline_panel = outline_panel(&workspace, cx);
5849 outline_panel.update_in(cx, |outline_panel, window, cx| {
5850 outline_panel.set_active(true, window, cx)
5851 });
5852
5853 let items = workspace
5854 .update_in(cx, |workspace, window, cx| {
5855 workspace.open_paths(
5856 vec![PathBuf::from(path!("/root/two"))],
5857 OpenOptions {
5858 visible: Some(OpenVisible::OnlyDirectories),
5859 ..Default::default()
5860 },
5861 None,
5862 window,
5863 cx,
5864 )
5865 })
5866 .await;
5867 assert_eq!(items.len(), 1, "Were opening another worktree directory");
5868 assert!(
5869 items[0].is_none(),
5870 "Directory should be opened successfully"
5871 );
5872
5873 workspace.update_in(cx, |workspace, window, cx| {
5874 ProjectSearchView::deploy_search(
5875 workspace,
5876 &workspace::DeploySearch::default(),
5877 window,
5878 cx,
5879 )
5880 });
5881 let search_view = workspace.update_in(cx, |workspace, _window, cx| {
5882 workspace
5883 .active_pane()
5884 .read(cx)
5885 .items()
5886 .find_map(|item| item.downcast::<ProjectSearchView>())
5887 .expect("Project search view expected to appear after new search event trigger")
5888 });
5889
5890 let query = "aaa";
5891 perform_project_search(&search_view, query, cx);
5892 search_view.update(cx, |search_view, cx| {
5893 search_view
5894 .results_editor()
5895 .update(cx, |results_editor, cx| {
5896 assert_eq!(
5897 results_editor.display_text(cx).match_indices(query).count(),
5898 3
5899 );
5900 });
5901 });
5902
5903 cx.executor()
5904 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5905 cx.run_until_parked();
5906 outline_panel.update(cx, |outline_panel, cx| {
5907 assert_eq!(
5908 display_entries(
5909 &project,
5910 &snapshot(outline_panel, cx),
5911 &outline_panel.cached_entries,
5912 outline_panel.selected_entry(),
5913 cx,
5914 ),
5915 format!(
5916 r#"one/
5917 a.txt
5918 search: «aaa» aaa <==== selected
5919 search: aaa «aaa»
5920two/
5921 b.txt
5922 search: a «aaa»"#,
5923 ),
5924 );
5925 });
5926
5927 outline_panel.update_in(cx, |outline_panel, window, cx| {
5928 outline_panel.select_previous(&SelectPrevious, window, cx);
5929 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5930 });
5931 cx.executor()
5932 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5933 cx.run_until_parked();
5934 outline_panel.update(cx, |outline_panel, cx| {
5935 assert_eq!(
5936 display_entries(
5937 &project,
5938 &snapshot(outline_panel, cx),
5939 &outline_panel.cached_entries,
5940 outline_panel.selected_entry(),
5941 cx,
5942 ),
5943 format!(
5944 r#"one/
5945 a.txt <==== selected
5946two/
5947 b.txt
5948 search: a «aaa»"#,
5949 ),
5950 );
5951 });
5952
5953 outline_panel.update_in(cx, |outline_panel, window, cx| {
5954 outline_panel.select_next(&SelectNext, window, cx);
5955 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5956 });
5957 cx.executor()
5958 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5959 cx.run_until_parked();
5960 outline_panel.update(cx, |outline_panel, cx| {
5961 assert_eq!(
5962 display_entries(
5963 &project,
5964 &snapshot(outline_panel, cx),
5965 &outline_panel.cached_entries,
5966 outline_panel.selected_entry(),
5967 cx,
5968 ),
5969 format!(
5970 r#"one/
5971 a.txt
5972two/ <==== selected"#,
5973 ),
5974 );
5975 });
5976
5977 outline_panel.update_in(cx, |outline_panel, window, cx| {
5978 outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
5979 });
5980 cx.executor()
5981 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5982 cx.run_until_parked();
5983 outline_panel.update(cx, |outline_panel, cx| {
5984 assert_eq!(
5985 display_entries(
5986 &project,
5987 &snapshot(outline_panel, cx),
5988 &outline_panel.cached_entries,
5989 outline_panel.selected_entry(),
5990 cx,
5991 ),
5992 format!(
5993 r#"one/
5994 a.txt
5995two/ <==== selected
5996 b.txt
5997 search: a «aaa»"#,
5998 )
5999 );
6000 });
6001 }
6002
6003 #[gpui::test]
6004 async fn test_navigating_in_singleton(cx: &mut TestAppContext) {
6005 init_test(cx);
6006
6007 let root = path!("/root");
6008 let fs = FakeFs::new(cx.background_executor.clone());
6009 fs.insert_tree(
6010 root,
6011 json!({
6012 "src": {
6013 "lib.rs": indoc!("
6014#[derive(Clone, Debug, PartialEq, Eq, Hash)]
6015struct OutlineEntryExcerpt {
6016 id: ExcerptId,
6017 buffer_id: BufferId,
6018 range: ExcerptRange<language::Anchor>,
6019}"),
6020 }
6021 }),
6022 )
6023 .await;
6024 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
6025 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
6026 let (window, workspace) = add_outline_panel(&project, cx).await;
6027 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6028 let outline_panel = outline_panel(&workspace, cx);
6029 cx.update(|window, cx| {
6030 outline_panel.update(cx, |outline_panel, cx| {
6031 outline_panel.set_active(true, window, cx)
6032 });
6033 });
6034
6035 let _editor = workspace
6036 .update_in(cx, |workspace, window, cx| {
6037 workspace.open_abs_path(
6038 PathBuf::from(path!("/root/src/lib.rs")),
6039 OpenOptions {
6040 visible: Some(OpenVisible::All),
6041 ..Default::default()
6042 },
6043 window,
6044 cx,
6045 )
6046 })
6047 .await
6048 .expect("Failed to open Rust source file")
6049 .downcast::<Editor>()
6050 .expect("Should open an editor for Rust source file");
6051
6052 cx.executor()
6053 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6054 cx.run_until_parked();
6055 outline_panel.update(cx, |outline_panel, cx| {
6056 assert_eq!(
6057 display_entries(
6058 &project,
6059 &snapshot(outline_panel, cx),
6060 &outline_panel.cached_entries,
6061 outline_panel.selected_entry(),
6062 cx,
6063 ),
6064 indoc!(
6065 "
6066outline: struct OutlineEntryExcerpt
6067 outline: id
6068 outline: buffer_id
6069 outline: range"
6070 )
6071 );
6072 });
6073
6074 cx.update(|window, cx| {
6075 outline_panel.update(cx, |outline_panel, cx| {
6076 outline_panel.select_next(&SelectNext, window, cx);
6077 });
6078 });
6079 cx.executor()
6080 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6081 cx.run_until_parked();
6082 outline_panel.update(cx, |outline_panel, cx| {
6083 assert_eq!(
6084 display_entries(
6085 &project,
6086 &snapshot(outline_panel, cx),
6087 &outline_panel.cached_entries,
6088 outline_panel.selected_entry(),
6089 cx,
6090 ),
6091 indoc!(
6092 "
6093outline: struct OutlineEntryExcerpt <==== selected
6094 outline: id
6095 outline: buffer_id
6096 outline: range"
6097 )
6098 );
6099 });
6100
6101 cx.update(|window, cx| {
6102 outline_panel.update(cx, |outline_panel, cx| {
6103 outline_panel.select_next(&SelectNext, window, cx);
6104 });
6105 });
6106 cx.executor()
6107 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6108 cx.run_until_parked();
6109 outline_panel.update(cx, |outline_panel, cx| {
6110 assert_eq!(
6111 display_entries(
6112 &project,
6113 &snapshot(outline_panel, cx),
6114 &outline_panel.cached_entries,
6115 outline_panel.selected_entry(),
6116 cx,
6117 ),
6118 indoc!(
6119 "
6120outline: struct OutlineEntryExcerpt
6121 outline: id <==== selected
6122 outline: buffer_id
6123 outline: range"
6124 )
6125 );
6126 });
6127
6128 cx.update(|window, cx| {
6129 outline_panel.update(cx, |outline_panel, cx| {
6130 outline_panel.select_next(&SelectNext, window, cx);
6131 });
6132 });
6133 cx.executor()
6134 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6135 cx.run_until_parked();
6136 outline_panel.update(cx, |outline_panel, cx| {
6137 assert_eq!(
6138 display_entries(
6139 &project,
6140 &snapshot(outline_panel, cx),
6141 &outline_panel.cached_entries,
6142 outline_panel.selected_entry(),
6143 cx,
6144 ),
6145 indoc!(
6146 "
6147outline: struct OutlineEntryExcerpt
6148 outline: id
6149 outline: buffer_id <==== selected
6150 outline: range"
6151 )
6152 );
6153 });
6154
6155 cx.update(|window, cx| {
6156 outline_panel.update(cx, |outline_panel, cx| {
6157 outline_panel.select_next(&SelectNext, window, cx);
6158 });
6159 });
6160 cx.executor()
6161 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6162 cx.run_until_parked();
6163 outline_panel.update(cx, |outline_panel, cx| {
6164 assert_eq!(
6165 display_entries(
6166 &project,
6167 &snapshot(outline_panel, cx),
6168 &outline_panel.cached_entries,
6169 outline_panel.selected_entry(),
6170 cx,
6171 ),
6172 indoc!(
6173 "
6174outline: struct OutlineEntryExcerpt
6175 outline: id
6176 outline: buffer_id
6177 outline: range <==== selected"
6178 )
6179 );
6180 });
6181
6182 cx.update(|window, cx| {
6183 outline_panel.update(cx, |outline_panel, cx| {
6184 outline_panel.select_next(&SelectNext, window, cx);
6185 });
6186 });
6187 cx.executor()
6188 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6189 cx.run_until_parked();
6190 outline_panel.update(cx, |outline_panel, cx| {
6191 assert_eq!(
6192 display_entries(
6193 &project,
6194 &snapshot(outline_panel, cx),
6195 &outline_panel.cached_entries,
6196 outline_panel.selected_entry(),
6197 cx,
6198 ),
6199 indoc!(
6200 "
6201outline: struct OutlineEntryExcerpt <==== selected
6202 outline: id
6203 outline: buffer_id
6204 outline: range"
6205 )
6206 );
6207 });
6208
6209 cx.update(|window, cx| {
6210 outline_panel.update(cx, |outline_panel, cx| {
6211 outline_panel.select_previous(&SelectPrevious, window, cx);
6212 });
6213 });
6214 cx.executor()
6215 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6216 cx.run_until_parked();
6217 outline_panel.update(cx, |outline_panel, cx| {
6218 assert_eq!(
6219 display_entries(
6220 &project,
6221 &snapshot(outline_panel, cx),
6222 &outline_panel.cached_entries,
6223 outline_panel.selected_entry(),
6224 cx,
6225 ),
6226 indoc!(
6227 "
6228outline: struct OutlineEntryExcerpt
6229 outline: id
6230 outline: buffer_id
6231 outline: range <==== selected"
6232 )
6233 );
6234 });
6235
6236 cx.update(|window, cx| {
6237 outline_panel.update(cx, |outline_panel, cx| {
6238 outline_panel.select_previous(&SelectPrevious, window, cx);
6239 });
6240 });
6241 cx.executor()
6242 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6243 cx.run_until_parked();
6244 outline_panel.update(cx, |outline_panel, cx| {
6245 assert_eq!(
6246 display_entries(
6247 &project,
6248 &snapshot(outline_panel, cx),
6249 &outline_panel.cached_entries,
6250 outline_panel.selected_entry(),
6251 cx,
6252 ),
6253 indoc!(
6254 "
6255outline: struct OutlineEntryExcerpt
6256 outline: id
6257 outline: buffer_id <==== selected
6258 outline: range"
6259 )
6260 );
6261 });
6262
6263 cx.update(|window, cx| {
6264 outline_panel.update(cx, |outline_panel, cx| {
6265 outline_panel.select_previous(&SelectPrevious, window, cx);
6266 });
6267 });
6268 cx.executor()
6269 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6270 cx.run_until_parked();
6271 outline_panel.update(cx, |outline_panel, cx| {
6272 assert_eq!(
6273 display_entries(
6274 &project,
6275 &snapshot(outline_panel, cx),
6276 &outline_panel.cached_entries,
6277 outline_panel.selected_entry(),
6278 cx,
6279 ),
6280 indoc!(
6281 "
6282outline: struct OutlineEntryExcerpt
6283 outline: id <==== selected
6284 outline: buffer_id
6285 outline: range"
6286 )
6287 );
6288 });
6289
6290 cx.update(|window, cx| {
6291 outline_panel.update(cx, |outline_panel, cx| {
6292 outline_panel.select_previous(&SelectPrevious, window, cx);
6293 });
6294 });
6295 cx.executor()
6296 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6297 cx.run_until_parked();
6298 outline_panel.update(cx, |outline_panel, cx| {
6299 assert_eq!(
6300 display_entries(
6301 &project,
6302 &snapshot(outline_panel, cx),
6303 &outline_panel.cached_entries,
6304 outline_panel.selected_entry(),
6305 cx,
6306 ),
6307 indoc!(
6308 "
6309outline: struct OutlineEntryExcerpt <==== selected
6310 outline: id
6311 outline: buffer_id
6312 outline: range"
6313 )
6314 );
6315 });
6316
6317 cx.update(|window, cx| {
6318 outline_panel.update(cx, |outline_panel, cx| {
6319 outline_panel.select_previous(&SelectPrevious, window, cx);
6320 });
6321 });
6322 cx.executor()
6323 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6324 cx.run_until_parked();
6325 outline_panel.update(cx, |outline_panel, cx| {
6326 assert_eq!(
6327 display_entries(
6328 &project,
6329 &snapshot(outline_panel, cx),
6330 &outline_panel.cached_entries,
6331 outline_panel.selected_entry(),
6332 cx,
6333 ),
6334 indoc!(
6335 "
6336outline: struct OutlineEntryExcerpt
6337 outline: id
6338 outline: buffer_id
6339 outline: range <==== selected"
6340 )
6341 );
6342 });
6343 }
6344
6345 #[gpui::test(iterations = 10)]
6346 async fn test_frontend_repo_structure(cx: &mut TestAppContext) {
6347 init_test(cx);
6348
6349 let root = path!("/frontend-project");
6350 let fs = FakeFs::new(cx.background_executor.clone());
6351 fs.insert_tree(
6352 root,
6353 json!({
6354 "public": {
6355 "lottie": {
6356 "syntax-tree.json": r#"{ "something": "static" }"#
6357 }
6358 },
6359 "src": {
6360 "app": {
6361 "(site)": {
6362 "(about)": {
6363 "jobs": {
6364 "[slug]": {
6365 "page.tsx": r#"static"#
6366 }
6367 }
6368 },
6369 "(blog)": {
6370 "post": {
6371 "[slug]": {
6372 "page.tsx": r#"static"#
6373 }
6374 }
6375 },
6376 }
6377 },
6378 "components": {
6379 "ErrorBoundary.tsx": r#"static"#,
6380 }
6381 }
6382
6383 }),
6384 )
6385 .await;
6386 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
6387 let (window, workspace) = add_outline_panel(&project, cx).await;
6388 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6389 let outline_panel = outline_panel(&workspace, cx);
6390 outline_panel.update_in(cx, |outline_panel, window, cx| {
6391 outline_panel.set_active(true, window, cx)
6392 });
6393
6394 workspace.update_in(cx, |workspace, window, cx| {
6395 ProjectSearchView::deploy_search(
6396 workspace,
6397 &workspace::DeploySearch::default(),
6398 window,
6399 cx,
6400 )
6401 });
6402 let search_view = workspace.update_in(cx, |workspace, _window, cx| {
6403 workspace
6404 .active_pane()
6405 .read(cx)
6406 .items()
6407 .find_map(|item| item.downcast::<ProjectSearchView>())
6408 .expect("Project search view expected to appear after new search event trigger")
6409 });
6410
6411 let query = "static";
6412 perform_project_search(&search_view, query, cx);
6413 search_view.update(cx, |search_view, cx| {
6414 search_view
6415 .results_editor()
6416 .update(cx, |results_editor, cx| {
6417 assert_eq!(
6418 results_editor.display_text(cx).match_indices(query).count(),
6419 4
6420 );
6421 });
6422 });
6423
6424 cx.executor()
6425 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6426 cx.run_until_parked();
6427 outline_panel.update(cx, |outline_panel, cx| {
6428 assert_eq!(
6429 display_entries(
6430 &project,
6431 &snapshot(outline_panel, cx),
6432 &outline_panel.cached_entries,
6433 outline_panel.selected_entry(),
6434 cx,
6435 ),
6436 format!(
6437 r#"frontend-project/
6438 public/lottie/
6439 syntax-tree.json
6440 search: {{ "something": "«static»" }} <==== selected
6441 src/
6442 app/(site)/
6443 (about)/jobs/[slug]/
6444 page.tsx
6445 search: «static»
6446 (blog)/post/[slug]/
6447 page.tsx
6448 search: «static»
6449 components/
6450 ErrorBoundary.tsx
6451 search: «static»"#
6452 )
6453 );
6454 });
6455
6456 outline_panel.update_in(cx, |outline_panel, window, cx| {
6457 // Move to 5th element in the list, 3 items down.
6458 for _ in 0..2 {
6459 outline_panel.select_next(&SelectNext, window, cx);
6460 }
6461 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
6462 });
6463 cx.executor()
6464 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6465 cx.run_until_parked();
6466 outline_panel.update(cx, |outline_panel, cx| {
6467 assert_eq!(
6468 display_entries(
6469 &project,
6470 &snapshot(outline_panel, cx),
6471 &outline_panel.cached_entries,
6472 outline_panel.selected_entry(),
6473 cx,
6474 ),
6475 format!(
6476 r#"frontend-project/
6477 public/lottie/
6478 syntax-tree.json
6479 search: {{ "something": "«static»" }}
6480 src/
6481 app/(site)/ <==== selected
6482 components/
6483 ErrorBoundary.tsx
6484 search: «static»"#
6485 )
6486 );
6487 });
6488
6489 outline_panel.update_in(cx, |outline_panel, window, cx| {
6490 // Move to the next visible non-FS entry
6491 for _ in 0..3 {
6492 outline_panel.select_next(&SelectNext, window, cx);
6493 }
6494 });
6495 cx.run_until_parked();
6496 outline_panel.update(cx, |outline_panel, cx| {
6497 assert_eq!(
6498 display_entries(
6499 &project,
6500 &snapshot(outline_panel, cx),
6501 &outline_panel.cached_entries,
6502 outline_panel.selected_entry(),
6503 cx,
6504 ),
6505 format!(
6506 r#"frontend-project/
6507 public/lottie/
6508 syntax-tree.json
6509 search: {{ "something": "«static»" }}
6510 src/
6511 app/(site)/
6512 components/
6513 ErrorBoundary.tsx
6514 search: «static» <==== selected"#
6515 )
6516 );
6517 });
6518
6519 outline_panel.update_in(cx, |outline_panel, window, cx| {
6520 outline_panel
6521 .active_editor()
6522 .expect("Should have an active editor")
6523 .update(cx, |editor, cx| {
6524 editor.toggle_fold(&editor::actions::ToggleFold, window, cx)
6525 });
6526 });
6527 cx.executor()
6528 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6529 cx.run_until_parked();
6530 outline_panel.update(cx, |outline_panel, cx| {
6531 assert_eq!(
6532 display_entries(
6533 &project,
6534 &snapshot(outline_panel, cx),
6535 &outline_panel.cached_entries,
6536 outline_panel.selected_entry(),
6537 cx,
6538 ),
6539 format!(
6540 r#"frontend-project/
6541 public/lottie/
6542 syntax-tree.json
6543 search: {{ "something": "«static»" }}
6544 src/
6545 app/(site)/
6546 components/
6547 ErrorBoundary.tsx <==== selected"#
6548 )
6549 );
6550 });
6551
6552 outline_panel.update_in(cx, |outline_panel, window, cx| {
6553 outline_panel
6554 .active_editor()
6555 .expect("Should have an active editor")
6556 .update(cx, |editor, cx| {
6557 editor.toggle_fold(&editor::actions::ToggleFold, window, cx)
6558 });
6559 });
6560 cx.executor()
6561 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6562 cx.run_until_parked();
6563 outline_panel.update(cx, |outline_panel, cx| {
6564 assert_eq!(
6565 display_entries(
6566 &project,
6567 &snapshot(outline_panel, cx),
6568 &outline_panel.cached_entries,
6569 outline_panel.selected_entry(),
6570 cx,
6571 ),
6572 format!(
6573 r#"frontend-project/
6574 public/lottie/
6575 syntax-tree.json
6576 search: {{ "something": "«static»" }}
6577 src/
6578 app/(site)/
6579 components/
6580 ErrorBoundary.tsx <==== selected
6581 search: «static»"#
6582 )
6583 );
6584 });
6585
6586 outline_panel.update_in(cx, |outline_panel, window, cx| {
6587 outline_panel.collapse_all_entries(&CollapseAllEntries, window, cx);
6588 });
6589 cx.executor()
6590 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6591 cx.run_until_parked();
6592 outline_panel.update(cx, |outline_panel, cx| {
6593 assert_eq!(
6594 display_entries(
6595 &project,
6596 &snapshot(outline_panel, cx),
6597 &outline_panel.cached_entries,
6598 outline_panel.selected_entry(),
6599 cx,
6600 ),
6601 format!(r#"frontend-project/"#)
6602 );
6603 });
6604
6605 outline_panel.update_in(cx, |outline_panel, window, cx| {
6606 outline_panel.expand_all_entries(&ExpandAllEntries, window, cx);
6607 });
6608 cx.executor()
6609 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6610 cx.run_until_parked();
6611 outline_panel.update(cx, |outline_panel, cx| {
6612 assert_eq!(
6613 display_entries(
6614 &project,
6615 &snapshot(outline_panel, cx),
6616 &outline_panel.cached_entries,
6617 outline_panel.selected_entry(),
6618 cx,
6619 ),
6620 format!(
6621 r#"frontend-project/
6622 public/lottie/
6623 syntax-tree.json
6624 search: {{ "something": "«static»" }}
6625 src/
6626 app/(site)/
6627 (about)/jobs/[slug]/
6628 page.tsx
6629 search: «static»
6630 (blog)/post/[slug]/
6631 page.tsx
6632 search: «static»
6633 components/
6634 ErrorBoundary.tsx <==== selected
6635 search: «static»"#
6636 )
6637 );
6638 });
6639 }
6640
6641 async fn add_outline_panel(
6642 project: &Entity<Project>,
6643 cx: &mut TestAppContext,
6644 ) -> (WindowHandle<MultiWorkspace>, Entity<Workspace>) {
6645 let window =
6646 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6647 let workspace = window
6648 .read_with(cx, |mw, _| mw.workspace().clone())
6649 .unwrap();
6650
6651 let workspace_weak = workspace.downgrade();
6652 let outline_panel = window
6653 .update(cx, |_, window, cx| {
6654 cx.spawn_in(window, async move |_this, cx| {
6655 OutlinePanel::load(workspace_weak, cx.clone()).await
6656 })
6657 })
6658 .unwrap()
6659 .await
6660 .expect("Failed to load outline panel");
6661
6662 window
6663 .update(cx, |multi_workspace, window, cx| {
6664 multi_workspace.workspace().update(cx, |workspace, cx| {
6665 workspace.add_panel(outline_panel, window, cx);
6666 });
6667 })
6668 .unwrap();
6669 (window, workspace)
6670 }
6671
6672 fn outline_panel(
6673 workspace: &Entity<Workspace>,
6674 cx: &mut VisualTestContext,
6675 ) -> Entity<OutlinePanel> {
6676 workspace.update_in(cx, |workspace, _window, cx| {
6677 workspace
6678 .panel::<OutlinePanel>(cx)
6679 .expect("no outline panel")
6680 })
6681 }
6682
6683 fn display_entries(
6684 project: &Entity<Project>,
6685 multi_buffer_snapshot: &MultiBufferSnapshot,
6686 cached_entries: &[CachedEntry],
6687 selected_entry: Option<&PanelEntry>,
6688 cx: &mut App,
6689 ) -> String {
6690 let project = project.read(cx);
6691 let mut display_string = String::new();
6692 for entry in cached_entries {
6693 if !display_string.is_empty() {
6694 display_string += "\n";
6695 }
6696 for _ in 0..entry.depth {
6697 display_string += " ";
6698 }
6699 display_string += &match &entry.entry {
6700 PanelEntry::Fs(entry) => match entry {
6701 FsEntry::ExternalFile(_) => {
6702 panic!("Did not cover external files with tests")
6703 }
6704 FsEntry::Directory(directory) => {
6705 let path = if let Some(worktree) = project
6706 .worktree_for_id(directory.worktree_id, cx)
6707 .filter(|worktree| {
6708 worktree.read(cx).root_entry() == Some(&directory.entry.entry)
6709 }) {
6710 worktree
6711 .read(cx)
6712 .root_name()
6713 .join(&directory.entry.path)
6714 .as_unix_str()
6715 .to_string()
6716 } else {
6717 directory
6718 .entry
6719 .path
6720 .file_name()
6721 .unwrap_or_default()
6722 .to_string()
6723 };
6724 format!("{path}/")
6725 }
6726 FsEntry::File(file) => file
6727 .entry
6728 .path
6729 .file_name()
6730 .map(|name| name.to_string())
6731 .unwrap_or_default(),
6732 },
6733 PanelEntry::FoldedDirs(folded_dirs) => folded_dirs
6734 .entries
6735 .iter()
6736 .filter_map(|dir| dir.path.file_name())
6737 .map(|name| name.to_string() + "/")
6738 .collect(),
6739 PanelEntry::Outline(outline_entry) => match outline_entry {
6740 OutlineEntry::Excerpt(_) => continue,
6741 OutlineEntry::Outline(outline_entry) => {
6742 format!("outline: {}", outline_entry.text)
6743 }
6744 },
6745 PanelEntry::Search(search_entry) => {
6746 let search_data = search_entry.render_data.get_or_init(|| {
6747 SearchData::new(&search_entry.match_range, multi_buffer_snapshot)
6748 });
6749 let mut search_result = String::new();
6750 let mut last_end = 0;
6751 for range in &search_data.search_match_indices {
6752 search_result.push_str(&search_data.context_text[last_end..range.start]);
6753 search_result.push('«');
6754 search_result.push_str(&search_data.context_text[range.start..range.end]);
6755 search_result.push('»');
6756 last_end = range.end;
6757 }
6758 search_result.push_str(&search_data.context_text[last_end..]);
6759
6760 format!("search: {search_result}")
6761 }
6762 };
6763
6764 if Some(&entry.entry) == selected_entry {
6765 display_string += SELECTED_MARKER;
6766 }
6767 }
6768 display_string
6769 }
6770
6771 fn init_test(cx: &mut TestAppContext) {
6772 cx.update(|cx| {
6773 let settings = SettingsStore::test(cx);
6774 cx.set_global(settings);
6775
6776 theme_settings::init(theme::LoadThemes::JustBase, cx);
6777
6778 editor::init(cx);
6779 project_search::init(cx);
6780 buffer_search::init(cx);
6781 super::init(cx);
6782 });
6783 }
6784
6785 // Based on https://github.com/rust-lang/rust-analyzer/
6786 async fn populate_with_test_ra_project(fs: &FakeFs, root: &str) {
6787 fs.insert_tree(
6788 root,
6789 json!({
6790 "crates": {
6791 "ide": {
6792 "src": {
6793 "inlay_hints": {
6794 "fn_lifetime_fn.rs": r##"
6795 pub(super) fn hints(
6796 acc: &mut Vec<InlayHint>,
6797 config: &InlayHintsConfig,
6798 func: ast::Fn,
6799 ) -> Option<()> {
6800 // ... snip
6801
6802 let mut used_names: FxHashMap<SmolStr, usize> =
6803 match config.param_names_for_lifetime_elision_hints {
6804 true => generic_param_list
6805 .iter()
6806 .flat_map(|gpl| gpl.lifetime_params())
6807 .filter_map(|param| param.lifetime())
6808 .filter_map(|lt| Some((SmolStr::from(lt.text().as_str().get(1..)?), 0)))
6809 .collect(),
6810 false => Default::default(),
6811 };
6812 {
6813 let mut potential_lt_refs = potential_lt_refs.iter().filter(|&&(.., is_elided)| is_elided);
6814 if self_param.is_some() && potential_lt_refs.next().is_some() {
6815 allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
6816 // self can't be used as a lifetime, so no need to check for collisions
6817 "'self".into()
6818 } else {
6819 gen_idx_name()
6820 });
6821 }
6822 potential_lt_refs.for_each(|(name, ..)| {
6823 let name = match name {
6824 Some(it) if config.param_names_for_lifetime_elision_hints => {
6825 if let Some(c) = used_names.get_mut(it.text().as_str()) {
6826 *c += 1;
6827 SmolStr::from(format!("'{text}{c}", text = it.text().as_str()))
6828 } else {
6829 used_names.insert(it.text().as_str().into(), 0);
6830 SmolStr::from_iter(["\'", it.text().as_str()])
6831 }
6832 }
6833 _ => gen_idx_name(),
6834 };
6835 allocated_lifetimes.push(name);
6836 });
6837 }
6838
6839 // ... snip
6840 }
6841
6842 // ... snip
6843
6844 #[test]
6845 fn hints_lifetimes_named() {
6846 check_with_config(
6847 InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
6848 r#"
6849 fn nested_in<'named>(named: & &X< &()>) {}
6850 // ^'named1, 'named2, 'named3, $
6851 //^'named1 ^'named2 ^'named3
6852 "#,
6853 );
6854 }
6855
6856 // ... snip
6857 "##,
6858 },
6859 "inlay_hints.rs": r#"
6860 #[derive(Clone, Debug, PartialEq, Eq)]
6861 pub struct InlayHintsConfig {
6862 // ... snip
6863 pub param_names_for_lifetime_elision_hints: bool,
6864 pub max_length: Option<usize>,
6865 // ... snip
6866 }
6867
6868 impl Config {
6869 pub fn inlay_hints(&self) -> InlayHintsConfig {
6870 InlayHintsConfig {
6871 // ... snip
6872 param_names_for_lifetime_elision_hints: self
6873 .inlayHints_lifetimeElisionHints_useParameterNames()
6874 .to_owned(),
6875 max_length: self.inlayHints_maxLength().to_owned(),
6876 // ... snip
6877 }
6878 }
6879 }
6880 "#,
6881 "static_index.rs": r#"
6882// ... snip
6883 fn add_file(&mut self, file_id: FileId) {
6884 let current_crate = crates_for(self.db, file_id).pop().map(Into::into);
6885 let folds = self.analysis.folding_ranges(file_id).unwrap();
6886 let inlay_hints = self
6887 .analysis
6888 .inlay_hints(
6889 &InlayHintsConfig {
6890 // ... snip
6891 closure_style: hir::ClosureStyle::ImplFn,
6892 param_names_for_lifetime_elision_hints: false,
6893 binding_mode_hints: false,
6894 max_length: Some(25),
6895 closure_capture_hints: false,
6896 // ... snip
6897 },
6898 file_id,
6899 None,
6900 )
6901 .unwrap();
6902 // ... snip
6903 }
6904// ... snip
6905 "#
6906 }
6907 },
6908 "rust-analyzer": {
6909 "src": {
6910 "cli": {
6911 "analysis_stats.rs": r#"
6912 // ... snip
6913 for &file_id in &file_ids {
6914 _ = analysis.inlay_hints(
6915 &InlayHintsConfig {
6916 // ... snip
6917 implicit_drop_hints: true,
6918 lifetime_elision_hints: ide::LifetimeElisionHints::Always,
6919 param_names_for_lifetime_elision_hints: true,
6920 hide_named_constructor_hints: false,
6921 hide_closure_initialization_hints: false,
6922 closure_style: hir::ClosureStyle::ImplFn,
6923 max_length: Some(25),
6924 closing_brace_hints_min_lines: Some(20),
6925 fields_to_resolve: InlayFieldsToResolve::empty(),
6926 range_exclusive_hints: true,
6927 },
6928 file_id.into(),
6929 None,
6930 );
6931 }
6932 // ... snip
6933 "#,
6934 },
6935 "config.rs": r#"
6936 config_data! {
6937 /// Configs that only make sense when they are set by a client. As such they can only be defined
6938 /// by setting them using client's settings (e.g `settings.json` on VS Code).
6939 client: struct ClientDefaultConfigData <- ClientConfigInput -> {
6940 // ... snip
6941 /// Maximum length for inlay hints. Set to null to have an unlimited length.
6942 inlayHints_maxLength: Option<usize> = Some(25),
6943 // ... snip
6944 /// Whether to prefer using parameter names as the name for elided lifetime hints if possible.
6945 inlayHints_lifetimeElisionHints_useParameterNames: bool = false,
6946 // ... snip
6947 }
6948 }
6949
6950 impl Config {
6951 // ... snip
6952 pub fn inlay_hints(&self) -> InlayHintsConfig {
6953 InlayHintsConfig {
6954 // ... snip
6955 param_names_for_lifetime_elision_hints: self
6956 .inlayHints_lifetimeElisionHints_useParameterNames()
6957 .to_owned(),
6958 max_length: self.inlayHints_maxLength().to_owned(),
6959 // ... snip
6960 }
6961 }
6962 // ... snip
6963 }
6964 "#
6965 }
6966 }
6967 }
6968 }),
6969 )
6970 .await;
6971 }
6972
6973 fn snapshot(outline_panel: &OutlinePanel, cx: &App) -> MultiBufferSnapshot {
6974 outline_panel
6975 .active_editor()
6976 .unwrap()
6977 .read(cx)
6978 .buffer()
6979 .read(cx)
6980 .snapshot(cx)
6981 }
6982
6983 fn selected_row_text(editor: &Entity<Editor>, cx: &mut App) -> String {
6984 editor.update(cx, |editor, cx| {
6985 let selections = editor.selections.all::<language::Point>(&editor.display_snapshot(cx));
6986 assert_eq!(selections.len(), 1, "Active editor should have exactly one selection after any outline panel interactions");
6987 let selection = selections.first().unwrap();
6988 let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
6989 let line_start = language::Point::new(selection.start.row, 0);
6990 let line_end = multi_buffer_snapshot.clip_point(language::Point::new(selection.end.row, u32::MAX), language::Bias::Right);
6991 multi_buffer_snapshot.text_for_range(line_start..line_end).collect::<String>().trim().to_owned()
6992 })
6993 }
6994
6995 #[gpui::test]
6996 async fn test_outline_keyboard_expand_collapse(cx: &mut TestAppContext) {
6997 init_test(cx);
6998
6999 let fs = FakeFs::new(cx.background_executor.clone());
7000 fs.insert_tree(
7001 "/test",
7002 json!({
7003 "src": {
7004 "lib.rs": indoc!("
7005 mod outer {
7006 pub struct OuterStruct {
7007 field: String,
7008 }
7009 impl OuterStruct {
7010 pub fn new() -> Self {
7011 Self { field: String::new() }
7012 }
7013 pub fn method(&self) {
7014 println!(\"{}\", self.field);
7015 }
7016 }
7017 mod inner {
7018 pub fn inner_function() {
7019 let x = 42;
7020 println!(\"{}\", x);
7021 }
7022 pub struct InnerStruct {
7023 value: i32,
7024 }
7025 }
7026 }
7027 fn main() {
7028 let s = outer::OuterStruct::new();
7029 s.method();
7030 }
7031 "),
7032 }
7033 }),
7034 )
7035 .await;
7036
7037 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7038 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
7039 let (window, workspace) = add_outline_panel(&project, cx).await;
7040 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7041 let outline_panel = outline_panel(&workspace, cx);
7042
7043 outline_panel.update_in(cx, |outline_panel, window, cx| {
7044 outline_panel.set_active(true, window, cx)
7045 });
7046
7047 workspace
7048 .update_in(cx, |workspace, window, cx| {
7049 workspace.open_abs_path(
7050 PathBuf::from("/test/src/lib.rs"),
7051 OpenOptions {
7052 visible: Some(OpenVisible::All),
7053 ..Default::default()
7054 },
7055 window,
7056 cx,
7057 )
7058 })
7059 .await
7060 .unwrap();
7061
7062 cx.executor()
7063 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7064 cx.run_until_parked();
7065
7066 // Force another update cycle to ensure outlines are fetched
7067 outline_panel.update_in(cx, |panel, window, cx| {
7068 panel.update_non_fs_items(window, cx);
7069 panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
7070 });
7071 cx.executor()
7072 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7073 cx.run_until_parked();
7074
7075 outline_panel.update(cx, |outline_panel, cx| {
7076 assert_eq!(
7077 display_entries(
7078 &project,
7079 &snapshot(outline_panel, cx),
7080 &outline_panel.cached_entries,
7081 outline_panel.selected_entry(),
7082 cx,
7083 ),
7084 indoc!(
7085 "
7086outline: mod outer <==== selected
7087 outline: pub struct OuterStruct
7088 outline: field
7089 outline: impl OuterStruct
7090 outline: pub fn new
7091 outline: pub fn method
7092 outline: mod inner
7093 outline: pub fn inner_function
7094 outline: pub struct InnerStruct
7095 outline: value
7096outline: fn main"
7097 )
7098 );
7099 });
7100
7101 let parent_outline = outline_panel
7102 .read_with(cx, |panel, _cx| {
7103 panel
7104 .cached_entries
7105 .iter()
7106 .find_map(|entry| match &entry.entry {
7107 PanelEntry::Outline(OutlineEntry::Outline(outline))
7108 if panel
7109 .outline_children_cache
7110 .get(&outline.range.start.buffer_id)
7111 .and_then(|children_map| {
7112 let key = (outline.range.clone(), outline.depth);
7113 children_map.get(&key)
7114 })
7115 .copied()
7116 .unwrap_or(false) =>
7117 {
7118 Some(entry.entry.clone())
7119 }
7120 _ => None,
7121 })
7122 })
7123 .expect("Should find an outline with children");
7124
7125 outline_panel.update_in(cx, |panel, window, cx| {
7126 panel.select_entry(parent_outline.clone(), true, window, cx);
7127 panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
7128 });
7129 cx.executor()
7130 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7131 cx.run_until_parked();
7132
7133 outline_panel.update(cx, |outline_panel, cx| {
7134 assert_eq!(
7135 display_entries(
7136 &project,
7137 &snapshot(outline_panel, cx),
7138 &outline_panel.cached_entries,
7139 outline_panel.selected_entry(),
7140 cx,
7141 ),
7142 indoc!(
7143 "
7144outline: mod outer <==== selected
7145outline: fn main"
7146 )
7147 );
7148 });
7149
7150 outline_panel.update_in(cx, |panel, window, cx| {
7151 panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
7152 });
7153 cx.executor()
7154 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7155 cx.run_until_parked();
7156
7157 outline_panel.update(cx, |outline_panel, cx| {
7158 assert_eq!(
7159 display_entries(
7160 &project,
7161 &snapshot(outline_panel, cx),
7162 &outline_panel.cached_entries,
7163 outline_panel.selected_entry(),
7164 cx,
7165 ),
7166 indoc!(
7167 "
7168outline: mod outer <==== selected
7169 outline: pub struct OuterStruct
7170 outline: field
7171 outline: impl OuterStruct
7172 outline: pub fn new
7173 outline: pub fn method
7174 outline: mod inner
7175 outline: pub fn inner_function
7176 outline: pub struct InnerStruct
7177 outline: value
7178outline: fn main"
7179 )
7180 );
7181 });
7182
7183 outline_panel.update_in(cx, |panel, window, cx| {
7184 panel.collapsed_entries.clear();
7185 panel.update_cached_entries(None, window, cx);
7186 });
7187 cx.executor()
7188 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7189 cx.run_until_parked();
7190
7191 outline_panel.update_in(cx, |panel, window, cx| {
7192 let outlines_with_children: Vec<_> = panel
7193 .cached_entries
7194 .iter()
7195 .filter_map(|entry| match &entry.entry {
7196 PanelEntry::Outline(OutlineEntry::Outline(outline))
7197 if panel
7198 .outline_children_cache
7199 .get(&outline.range.start.buffer_id)
7200 .and_then(|children_map| {
7201 let key = (outline.range.clone(), outline.depth);
7202 children_map.get(&key)
7203 })
7204 .copied()
7205 .unwrap_or(false) =>
7206 {
7207 Some(entry.entry.clone())
7208 }
7209 _ => None,
7210 })
7211 .collect();
7212
7213 for outline in outlines_with_children {
7214 panel.select_entry(outline, false, window, cx);
7215 panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
7216 }
7217 });
7218 cx.executor()
7219 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7220 cx.run_until_parked();
7221
7222 outline_panel.update(cx, |outline_panel, cx| {
7223 assert_eq!(
7224 display_entries(
7225 &project,
7226 &snapshot(outline_panel, cx),
7227 &outline_panel.cached_entries,
7228 outline_panel.selected_entry(),
7229 cx,
7230 ),
7231 indoc!(
7232 "
7233outline: mod outer
7234outline: fn main"
7235 )
7236 );
7237 });
7238
7239 let collapsed_entries_count =
7240 outline_panel.read_with(cx, |panel, _| panel.collapsed_entries.len());
7241 assert!(
7242 collapsed_entries_count > 0,
7243 "Should have collapsed entries tracked"
7244 );
7245 }
7246
7247 #[gpui::test]
7248 async fn test_outline_click_toggle_behavior(cx: &mut TestAppContext) {
7249 init_test(cx);
7250
7251 let fs = FakeFs::new(cx.background_executor.clone());
7252 fs.insert_tree(
7253 "/test",
7254 json!({
7255 "src": {
7256 "main.rs": indoc!("
7257 struct Config {
7258 name: String,
7259 value: i32,
7260 }
7261 impl Config {
7262 fn new(name: String) -> Self {
7263 Self { name, value: 0 }
7264 }
7265 fn get_value(&self) -> i32 {
7266 self.value
7267 }
7268 }
7269 enum Status {
7270 Active,
7271 Inactive,
7272 }
7273 fn process_config(config: Config) -> Status {
7274 if config.get_value() > 0 {
7275 Status::Active
7276 } else {
7277 Status::Inactive
7278 }
7279 }
7280 fn main() {
7281 let config = Config::new(\"test\".to_string());
7282 let status = process_config(config);
7283 }
7284 "),
7285 }
7286 }),
7287 )
7288 .await;
7289
7290 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7291 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
7292
7293 let (window, workspace) = add_outline_panel(&project, cx).await;
7294 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7295 let outline_panel = outline_panel(&workspace, cx);
7296
7297 outline_panel.update_in(cx, |outline_panel, window, cx| {
7298 outline_panel.set_active(true, window, cx)
7299 });
7300
7301 let _editor = workspace
7302 .update_in(cx, |workspace, window, cx| {
7303 workspace.open_abs_path(
7304 PathBuf::from("/test/src/main.rs"),
7305 OpenOptions {
7306 visible: Some(OpenVisible::All),
7307 ..Default::default()
7308 },
7309 window,
7310 cx,
7311 )
7312 })
7313 .await
7314 .unwrap();
7315
7316 cx.executor()
7317 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7318 cx.run_until_parked();
7319
7320 outline_panel.update(cx, |outline_panel, _cx| {
7321 outline_panel.selected_entry = SelectedEntry::None;
7322 });
7323
7324 // Check initial state - all entries should be expanded by default
7325 outline_panel.update(cx, |outline_panel, cx| {
7326 assert_eq!(
7327 display_entries(
7328 &project,
7329 &snapshot(outline_panel, cx),
7330 &outline_panel.cached_entries,
7331 outline_panel.selected_entry(),
7332 cx,
7333 ),
7334 indoc!(
7335 "
7336outline: struct Config
7337 outline: name
7338 outline: value
7339outline: impl Config
7340 outline: fn new
7341 outline: fn get_value
7342outline: enum Status
7343 outline: Active
7344 outline: Inactive
7345outline: fn process_config
7346outline: fn main"
7347 )
7348 );
7349 });
7350
7351 outline_panel.update(cx, |outline_panel, _cx| {
7352 outline_panel.selected_entry = SelectedEntry::None;
7353 });
7354
7355 cx.update(|window, cx| {
7356 outline_panel.update(cx, |outline_panel, cx| {
7357 outline_panel.select_first(&SelectFirst, window, cx);
7358 });
7359 });
7360
7361 cx.executor()
7362 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7363 cx.run_until_parked();
7364
7365 outline_panel.update(cx, |outline_panel, cx| {
7366 assert_eq!(
7367 display_entries(
7368 &project,
7369 &snapshot(outline_panel, cx),
7370 &outline_panel.cached_entries,
7371 outline_panel.selected_entry(),
7372 cx,
7373 ),
7374 indoc!(
7375 "
7376outline: struct Config <==== selected
7377 outline: name
7378 outline: value
7379outline: impl Config
7380 outline: fn new
7381 outline: fn get_value
7382outline: enum Status
7383 outline: Active
7384 outline: Inactive
7385outline: fn process_config
7386outline: fn main"
7387 )
7388 );
7389 });
7390
7391 cx.update(|window, cx| {
7392 outline_panel.update(cx, |outline_panel, cx| {
7393 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
7394 });
7395 });
7396
7397 cx.executor()
7398 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7399 cx.run_until_parked();
7400
7401 outline_panel.update(cx, |outline_panel, cx| {
7402 assert_eq!(
7403 display_entries(
7404 &project,
7405 &snapshot(outline_panel, cx),
7406 &outline_panel.cached_entries,
7407 outline_panel.selected_entry(),
7408 cx,
7409 ),
7410 indoc!(
7411 "
7412outline: struct Config <==== selected
7413outline: impl Config
7414 outline: fn new
7415 outline: fn get_value
7416outline: enum Status
7417 outline: Active
7418 outline: Inactive
7419outline: fn process_config
7420outline: fn main"
7421 )
7422 );
7423 });
7424
7425 cx.update(|window, cx| {
7426 outline_panel.update(cx, |outline_panel, cx| {
7427 outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
7428 });
7429 });
7430
7431 cx.executor()
7432 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7433 cx.run_until_parked();
7434
7435 outline_panel.update(cx, |outline_panel, cx| {
7436 assert_eq!(
7437 display_entries(
7438 &project,
7439 &snapshot(outline_panel, cx),
7440 &outline_panel.cached_entries,
7441 outline_panel.selected_entry(),
7442 cx,
7443 ),
7444 indoc!(
7445 "
7446outline: struct Config <==== selected
7447 outline: name
7448 outline: value
7449outline: impl Config
7450 outline: fn new
7451 outline: fn get_value
7452outline: enum Status
7453 outline: Active
7454 outline: Inactive
7455outline: fn process_config
7456outline: fn main"
7457 )
7458 );
7459 });
7460 }
7461
7462 #[gpui::test]
7463 async fn test_outline_expand_collapse_all(cx: &mut TestAppContext) {
7464 init_test(cx);
7465
7466 let fs = FakeFs::new(cx.background_executor.clone());
7467 fs.insert_tree(
7468 "/test",
7469 json!({
7470 "src": {
7471 "lib.rs": indoc!("
7472 mod outer {
7473 pub struct OuterStruct {
7474 field: String,
7475 }
7476 impl OuterStruct {
7477 pub fn new() -> Self {
7478 Self { field: String::new() }
7479 }
7480 pub fn method(&self) {
7481 println!(\"{}\", self.field);
7482 }
7483 }
7484 mod inner {
7485 pub fn inner_function() {
7486 let x = 42;
7487 println!(\"{}\", x);
7488 }
7489 pub struct InnerStruct {
7490 value: i32,
7491 }
7492 }
7493 }
7494 fn main() {
7495 let s = outer::OuterStruct::new();
7496 s.method();
7497 }
7498 "),
7499 }
7500 }),
7501 )
7502 .await;
7503
7504 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7505 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
7506 let (window, workspace) = add_outline_panel(&project, cx).await;
7507 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7508 let outline_panel = outline_panel(&workspace, cx);
7509
7510 outline_panel.update_in(cx, |outline_panel, window, cx| {
7511 outline_panel.set_active(true, window, cx)
7512 });
7513
7514 workspace
7515 .update_in(cx, |workspace, window, cx| {
7516 workspace.open_abs_path(
7517 PathBuf::from("/test/src/lib.rs"),
7518 OpenOptions {
7519 visible: Some(OpenVisible::All),
7520 ..Default::default()
7521 },
7522 window,
7523 cx,
7524 )
7525 })
7526 .await
7527 .unwrap();
7528
7529 cx.executor()
7530 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7531 cx.run_until_parked();
7532
7533 // Force another update cycle to ensure outlines are fetched
7534 outline_panel.update_in(cx, |panel, window, cx| {
7535 panel.update_non_fs_items(window, cx);
7536 panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
7537 });
7538 cx.executor()
7539 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7540 cx.run_until_parked();
7541
7542 outline_panel.update(cx, |outline_panel, cx| {
7543 assert_eq!(
7544 display_entries(
7545 &project,
7546 &snapshot(outline_panel, cx),
7547 &outline_panel.cached_entries,
7548 outline_panel.selected_entry(),
7549 cx,
7550 ),
7551 indoc!(
7552 "
7553outline: mod outer <==== selected
7554 outline: pub struct OuterStruct
7555 outline: field
7556 outline: impl OuterStruct
7557 outline: pub fn new
7558 outline: pub fn method
7559 outline: mod inner
7560 outline: pub fn inner_function
7561 outline: pub struct InnerStruct
7562 outline: value
7563outline: fn main"
7564 )
7565 );
7566 });
7567
7568 let _parent_outline = outline_panel
7569 .read_with(cx, |panel, _cx| {
7570 panel
7571 .cached_entries
7572 .iter()
7573 .find_map(|entry| match &entry.entry {
7574 PanelEntry::Outline(OutlineEntry::Outline(outline))
7575 if panel
7576 .outline_children_cache
7577 .get(&outline.range.start.buffer_id)
7578 .and_then(|children_map| {
7579 let key = (outline.range.clone(), outline.depth);
7580 children_map.get(&key)
7581 })
7582 .copied()
7583 .unwrap_or(false) =>
7584 {
7585 Some(entry.entry.clone())
7586 }
7587 _ => None,
7588 })
7589 })
7590 .expect("Should find an outline with children");
7591
7592 // Collapse all entries
7593 outline_panel.update_in(cx, |panel, window, cx| {
7594 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
7595 });
7596 cx.executor()
7597 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7598 cx.run_until_parked();
7599
7600 let expected_collapsed_output = indoc!(
7601 "
7602 outline: mod outer <==== selected
7603 outline: fn main"
7604 );
7605
7606 outline_panel.update(cx, |panel, cx| {
7607 assert_eq! {
7608 display_entries(
7609 &project,
7610 &snapshot(panel, cx),
7611 &panel.cached_entries,
7612 panel.selected_entry(),
7613 cx,
7614 ),
7615 expected_collapsed_output
7616 };
7617 });
7618
7619 // Expand all entries
7620 outline_panel.update_in(cx, |panel, window, cx| {
7621 panel.expand_all_entries(&ExpandAllEntries, window, cx);
7622 });
7623 cx.executor()
7624 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7625 cx.run_until_parked();
7626
7627 let expected_expanded_output = indoc!(
7628 "
7629 outline: mod outer <==== selected
7630 outline: pub struct OuterStruct
7631 outline: field
7632 outline: impl OuterStruct
7633 outline: pub fn new
7634 outline: pub fn method
7635 outline: mod inner
7636 outline: pub fn inner_function
7637 outline: pub struct InnerStruct
7638 outline: value
7639 outline: fn main"
7640 );
7641
7642 outline_panel.update(cx, |panel, cx| {
7643 assert_eq! {
7644 display_entries(
7645 &project,
7646 &snapshot(panel, cx),
7647 &panel.cached_entries,
7648 panel.selected_entry(),
7649 cx,
7650 ),
7651 expected_expanded_output
7652 };
7653 });
7654 }
7655
7656 #[gpui::test]
7657 async fn test_buffer_search(cx: &mut TestAppContext) {
7658 init_test(cx);
7659
7660 let fs = FakeFs::new(cx.background_executor.clone());
7661 fs.insert_tree(
7662 "/test",
7663 json!({
7664 "foo.txt": r#"<_constitution>
7665
7666</_constitution>
7667
7668
7669
7670## 📊 Output
7671
7672| Field | Meaning |
7673"#
7674 }),
7675 )
7676 .await;
7677
7678 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7679 let (window, workspace) = add_outline_panel(&project, cx).await;
7680 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7681
7682 let editor = workspace
7683 .update_in(cx, |workspace, window, cx| {
7684 workspace.open_abs_path(
7685 PathBuf::from("/test/foo.txt"),
7686 OpenOptions {
7687 visible: Some(OpenVisible::All),
7688 ..OpenOptions::default()
7689 },
7690 window,
7691 cx,
7692 )
7693 })
7694 .await
7695 .unwrap()
7696 .downcast::<Editor>()
7697 .unwrap();
7698
7699 let search_bar = workspace.update_in(cx, |_, window, cx| {
7700 cx.new(|cx| {
7701 let mut search_bar = BufferSearchBar::new(None, window, cx);
7702 search_bar.set_active_pane_item(Some(&editor), window, cx);
7703 search_bar.show(window, cx);
7704 search_bar
7705 })
7706 });
7707
7708 let outline_panel = outline_panel(&workspace, cx);
7709
7710 outline_panel.update_in(cx, |outline_panel, window, cx| {
7711 outline_panel.set_active(true, window, cx)
7712 });
7713
7714 search_bar
7715 .update_in(cx, |search_bar, window, cx| {
7716 search_bar.search(" ", None, true, window, cx)
7717 })
7718 .await
7719 .unwrap();
7720
7721 cx.executor()
7722 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7723 cx.run_until_parked();
7724
7725 outline_panel.update(cx, |outline_panel, cx| {
7726 assert_eq!(
7727 display_entries(
7728 &project,
7729 &snapshot(outline_panel, cx),
7730 &outline_panel.cached_entries,
7731 outline_panel.selected_entry(),
7732 cx,
7733 ),
7734 "search: | Field« » | Meaning | <==== selected
7735search: | Field « » | Meaning |
7736search: | Field « » | Meaning |
7737search: | Field « » | Meaning |
7738search: | Field « »| Meaning |
7739search: | Field | Meaning« » |
7740search: | Field | Meaning « » |
7741search: | Field | Meaning « » |
7742search: | Field | Meaning « » |
7743search: | Field | Meaning « » |
7744search: | Field | Meaning « » |
7745search: | Field | Meaning « » |
7746search: | Field | Meaning « »|"
7747 );
7748 });
7749 }
7750
7751 #[gpui::test]
7752 async fn test_outline_panel_lsp_document_symbols(cx: &mut TestAppContext) {
7753 init_test(cx);
7754
7755 let root = path!("/root");
7756 let fs = FakeFs::new(cx.background_executor.clone());
7757 fs.insert_tree(
7758 root,
7759 json!({
7760 "src": {
7761 "lib.rs": "struct Foo {\n bar: u32,\n baz: String,\n}\n",
7762 }
7763 }),
7764 )
7765 .await;
7766
7767 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
7768 let language_registry = project.read_with(cx, |project, _| {
7769 project.languages().add(rust_lang());
7770 project.languages().clone()
7771 });
7772
7773 let mut fake_language_servers = language_registry.register_fake_lsp(
7774 "Rust",
7775 FakeLspAdapter {
7776 capabilities: lsp::ServerCapabilities {
7777 document_symbol_provider: Some(lsp::OneOf::Left(true)),
7778 ..lsp::ServerCapabilities::default()
7779 },
7780 initializer: Some(Box::new(|fake_language_server| {
7781 fake_language_server
7782 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
7783 move |_, _| async move {
7784 #[allow(deprecated)]
7785 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
7786 lsp::DocumentSymbol {
7787 name: "Foo".to_string(),
7788 detail: None,
7789 kind: lsp::SymbolKind::STRUCT,
7790 tags: None,
7791 deprecated: None,
7792 range: lsp::Range::new(
7793 lsp::Position::new(0, 0),
7794 lsp::Position::new(3, 1),
7795 ),
7796 selection_range: lsp::Range::new(
7797 lsp::Position::new(0, 7),
7798 lsp::Position::new(0, 10),
7799 ),
7800 children: Some(vec![
7801 lsp::DocumentSymbol {
7802 name: "bar".to_string(),
7803 detail: None,
7804 kind: lsp::SymbolKind::FIELD,
7805 tags: None,
7806 deprecated: None,
7807 range: lsp::Range::new(
7808 lsp::Position::new(1, 4),
7809 lsp::Position::new(1, 13),
7810 ),
7811 selection_range: lsp::Range::new(
7812 lsp::Position::new(1, 4),
7813 lsp::Position::new(1, 7),
7814 ),
7815 children: None,
7816 },
7817 lsp::DocumentSymbol {
7818 name: "lsp_only_field".to_string(),
7819 detail: None,
7820 kind: lsp::SymbolKind::FIELD,
7821 tags: None,
7822 deprecated: None,
7823 range: lsp::Range::new(
7824 lsp::Position::new(2, 4),
7825 lsp::Position::new(2, 15),
7826 ),
7827 selection_range: lsp::Range::new(
7828 lsp::Position::new(2, 4),
7829 lsp::Position::new(2, 7),
7830 ),
7831 children: None,
7832 },
7833 ]),
7834 },
7835 ])))
7836 },
7837 );
7838 })),
7839 ..FakeLspAdapter::default()
7840 },
7841 );
7842
7843 let (window, workspace) = add_outline_panel(&project, cx).await;
7844 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7845 let outline_panel = outline_panel(&workspace, cx);
7846 cx.update(|window, cx| {
7847 outline_panel.update(cx, |outline_panel, cx| {
7848 outline_panel.set_active(true, window, cx)
7849 });
7850 });
7851
7852 let _editor = workspace
7853 .update_in(cx, |workspace, window, cx| {
7854 workspace.open_abs_path(
7855 PathBuf::from(path!("/root/src/lib.rs")),
7856 OpenOptions {
7857 visible: Some(OpenVisible::All),
7858 ..OpenOptions::default()
7859 },
7860 window,
7861 cx,
7862 )
7863 })
7864 .await
7865 .expect("Failed to open Rust source file")
7866 .downcast::<Editor>()
7867 .expect("Should open an editor for Rust source file");
7868 let _fake_language_server = fake_language_servers.next().await.unwrap();
7869 cx.executor()
7870 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7871 cx.run_until_parked();
7872
7873 // Step 1: tree-sitter outlines by default
7874 outline_panel.update(cx, |outline_panel, cx| {
7875 assert_eq!(
7876 display_entries(
7877 &project,
7878 &snapshot(outline_panel, cx),
7879 &outline_panel.cached_entries,
7880 outline_panel.selected_entry(),
7881 cx,
7882 ),
7883 indoc!(
7884 "
7885outline: struct Foo <==== selected
7886 outline: bar
7887 outline: baz"
7888 ),
7889 "Step 1: tree-sitter outlines should be displayed by default"
7890 );
7891 });
7892
7893 // Step 2: Switch to LSP document symbols
7894 cx.update(|_, cx| {
7895 settings::SettingsStore::update_global(
7896 cx,
7897 |store: &mut settings::SettingsStore, cx| {
7898 store.update_user_settings(cx, |settings| {
7899 settings.project.all_languages.defaults.document_symbols =
7900 Some(settings::DocumentSymbols::On);
7901 });
7902 },
7903 );
7904 });
7905 cx.executor()
7906 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7907 cx.run_until_parked();
7908
7909 outline_panel.update(cx, |outline_panel, cx| {
7910 assert_eq!(
7911 display_entries(
7912 &project,
7913 &snapshot(outline_panel, cx),
7914 &outline_panel.cached_entries,
7915 outline_panel.selected_entry(),
7916 cx,
7917 ),
7918 indoc!(
7919 "
7920outline: struct Foo <==== selected
7921 outline: bar
7922 outline: lsp_only_field"
7923 ),
7924 "Step 2: After switching to LSP, should see LSP-provided symbols"
7925 );
7926 });
7927
7928 // Step 3: Switch back to tree-sitter
7929 cx.update(|_, cx| {
7930 settings::SettingsStore::update_global(
7931 cx,
7932 |store: &mut settings::SettingsStore, cx| {
7933 store.update_user_settings(cx, |settings| {
7934 settings.project.all_languages.defaults.document_symbols =
7935 Some(settings::DocumentSymbols::Off);
7936 });
7937 },
7938 );
7939 });
7940 cx.executor()
7941 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7942 cx.run_until_parked();
7943
7944 outline_panel.update(cx, |outline_panel, cx| {
7945 assert_eq!(
7946 display_entries(
7947 &project,
7948 &snapshot(outline_panel, cx),
7949 &outline_panel.cached_entries,
7950 outline_panel.selected_entry(),
7951 cx,
7952 ),
7953 indoc!(
7954 "
7955outline: struct Foo <==== selected
7956 outline: bar
7957 outline: baz"
7958 ),
7959 "Step 3: tree-sitter outlines should be restored"
7960 );
7961 });
7962 }
7963
7964 #[gpui::test]
7965 async fn test_markdown_outline_selection_at_heading_boundaries(cx: &mut TestAppContext) {
7966 init_test(cx);
7967
7968 let fs = FakeFs::new(cx.background_executor.clone());
7969 fs.insert_tree(
7970 "/test",
7971 json!({
7972 "doc.md": indoc!("
7973 # Section A
7974
7975 ## Sub Section A
7976
7977 ## Sub Section B
7978
7979 # Section B
7980
7981 ")
7982 }),
7983 )
7984 .await;
7985
7986 let project = Project::test(fs.clone(), [Path::new("/test")], cx).await;
7987 project.read_with(cx, |project, _| project.languages().add(markdown_lang()));
7988 let (window, workspace) = add_outline_panel(&project, cx).await;
7989 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7990 let outline_panel = outline_panel(&workspace, cx);
7991 outline_panel.update_in(cx, |outline_panel, window, cx| {
7992 outline_panel.set_active(true, window, cx)
7993 });
7994
7995 let editor = workspace
7996 .update_in(cx, |workspace, window, cx| {
7997 workspace.open_abs_path(
7998 PathBuf::from("/test/doc.md"),
7999 OpenOptions {
8000 visible: Some(OpenVisible::All),
8001 ..Default::default()
8002 },
8003 window,
8004 cx,
8005 )
8006 })
8007 .await
8008 .unwrap()
8009 .downcast::<Editor>()
8010 .unwrap();
8011
8012 cx.run_until_parked();
8013
8014 outline_panel.update_in(cx, |panel, window, cx| {
8015 panel.update_non_fs_items(window, cx);
8016 panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
8017 });
8018
8019 // Helper function to move the cursor to the first column of a given row
8020 // and return the selected outline entry's text.
8021 let move_cursor_and_get_selection =
8022 |row: u32, cx: &mut VisualTestContext| -> Option<String> {
8023 cx.update(|window, cx| {
8024 editor.update(cx, |editor, cx| {
8025 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
8026 s.select_ranges(Some(
8027 language::Point::new(row, 0)..language::Point::new(row, 0),
8028 ))
8029 });
8030 });
8031 });
8032
8033 cx.run_until_parked();
8034
8035 outline_panel.read_with(cx, |panel, _cx| {
8036 panel.selected_entry().and_then(|entry| match entry {
8037 PanelEntry::Outline(OutlineEntry::Outline(outline)) => {
8038 Some(outline.text.clone())
8039 }
8040 _ => None,
8041 })
8042 })
8043 };
8044
8045 assert_eq!(
8046 move_cursor_and_get_selection(0, cx).as_deref(),
8047 Some("# Section A"),
8048 "Cursor at row 0 should select '# Section A'"
8049 );
8050
8051 assert_eq!(
8052 move_cursor_and_get_selection(2, cx).as_deref(),
8053 Some("## Sub Section A"),
8054 "Cursor at row 2 should select '## Sub Section A'"
8055 );
8056
8057 assert_eq!(
8058 move_cursor_and_get_selection(4, cx).as_deref(),
8059 Some("## Sub Section B"),
8060 "Cursor at row 4 should select '## Sub Section B'"
8061 );
8062
8063 assert_eq!(
8064 move_cursor_and_get_selection(6, cx).as_deref(),
8065 Some("# Section B"),
8066 "Cursor at row 6 should select '# Section B'"
8067 );
8068 }
8069}