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