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 theme::SyntaxTheme;
50use theme_settings::ThemeSettings;
51use ui::{
52 ContextMenu, FluentBuilder, HighlightedLabel, IconButton, IconButtonShape, IndentGuideColors,
53 IndentGuideLayout, KeyBinding, ListItem, ScrollAxes, Scrollbars, Tab, Tooltip, WithScrollbar,
54 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: async_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) = async_channel::unbounded();
180 let (notify_tx, notify_rx) = async_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(
4565 h_flex()
4566 .gap_1()
4567 .justify_center()
4568 .child(Label::new("Toggle Panel With").color(Color::Muted))
4569 .child({
4570 let key_binding = match self.position(window, cx) {
4571 DockPosition::Left => {
4572 KeyBinding::for_action(&workspace::ToggleLeftDock, cx)
4573 .into_any_element()
4574 }
4575 DockPosition::Bottom => {
4576 KeyBinding::for_action(&workspace::ToggleBottomDock, cx)
4577 .into_any_element()
4578 }
4579 DockPosition::Right => {
4580 KeyBinding::for_action(&workspace::ToggleRightDock, cx)
4581 .into_any_element()
4582 }
4583 };
4584
4585 key_binding
4586 }),
4587 )
4588 } else {
4589 let list_contents = {
4590 let items_len = self.cached_entries.len();
4591 let multi_buffer_snapshot = self
4592 .active_editor()
4593 .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx));
4594 uniform_list(
4595 "entries",
4596 items_len,
4597 cx.processor(move |outline_panel, range: Range<usize>, window, cx| {
4598 outline_panel.rendered_entries_len = range.end - range.start;
4599 let entries = outline_panel.cached_entries.get(range);
4600 entries
4601 .map(|entries| entries.to_vec())
4602 .unwrap_or_default()
4603 .into_iter()
4604 .filter_map(|cached_entry| match cached_entry.entry {
4605 PanelEntry::Fs(entry) => Some(outline_panel.render_entry(
4606 &entry,
4607 cached_entry.depth,
4608 cached_entry.string_match.as_ref(),
4609 window,
4610 cx,
4611 )),
4612 PanelEntry::FoldedDirs(folded_dirs_entry) => {
4613 Some(outline_panel.render_folded_dirs(
4614 &folded_dirs_entry,
4615 cached_entry.depth,
4616 cached_entry.string_match.as_ref(),
4617 window,
4618 cx,
4619 ))
4620 }
4621 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
4622 outline_panel.render_excerpt(
4623 &excerpt,
4624 cached_entry.depth,
4625 window,
4626 cx,
4627 )
4628 }
4629 PanelEntry::Outline(OutlineEntry::Outline(entry)) => {
4630 Some(outline_panel.render_outline(
4631 &entry,
4632 cached_entry.depth,
4633 cached_entry.string_match.as_ref(),
4634 window,
4635 cx,
4636 ))
4637 }
4638 PanelEntry::Search(SearchEntry {
4639 match_range,
4640 render_data,
4641 kind,
4642 ..
4643 }) => outline_panel.render_search_match(
4644 multi_buffer_snapshot.as_ref(),
4645 &match_range,
4646 &render_data,
4647 kind,
4648 cached_entry.depth,
4649 cached_entry.string_match.as_ref(),
4650 window,
4651 cx,
4652 ),
4653 })
4654 .collect()
4655 }),
4656 )
4657 .with_sizing_behavior(ListSizingBehavior::Infer)
4658 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4659 .with_width_from_item(self.max_width_item_index)
4660 .track_scroll(&self.scroll_handle)
4661 .when(show_indent_guides, |list| {
4662 list.with_decoration(
4663 ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx))
4664 .with_compute_indents_fn(cx.entity(), |outline_panel, range, _, _| {
4665 let entries = outline_panel.cached_entries.get(range);
4666 if let Some(entries) = entries {
4667 entries.iter().map(|item| item.depth).collect()
4668 } else {
4669 smallvec::SmallVec::new()
4670 }
4671 })
4672 .with_render_fn(cx.entity(), move |outline_panel, params, _, _| {
4673 const LEFT_OFFSET: Pixels = px(14.);
4674
4675 let indent_size = params.indent_size;
4676 let item_height = params.item_height;
4677 let active_indent_guide_ix = find_active_indent_guide_ix(
4678 outline_panel,
4679 ¶ms.indent_guides,
4680 );
4681
4682 params
4683 .indent_guides
4684 .into_iter()
4685 .enumerate()
4686 .map(|(ix, layout)| {
4687 let bounds = Bounds::new(
4688 point(
4689 layout.offset.x * indent_size + LEFT_OFFSET,
4690 layout.offset.y * item_height,
4691 ),
4692 size(px(1.), layout.length * item_height),
4693 );
4694 ui::RenderedIndentGuide {
4695 bounds,
4696 layout,
4697 is_active: active_indent_guide_ix == Some(ix),
4698 hitbox: None,
4699 }
4700 })
4701 .collect()
4702 }),
4703 )
4704 })
4705 };
4706
4707 v_flex()
4708 .flex_shrink()
4709 .size_full()
4710 .child(list_contents.size_full().flex_shrink())
4711 .custom_scrollbars(
4712 Scrollbars::for_settings::<OutlinePanelSettingsScrollbarProxy>()
4713 .tracked_scroll_handle(&self.scroll_handle.clone())
4714 .with_track_along(
4715 ScrollAxes::Horizontal,
4716 cx.theme().colors().panel_background,
4717 )
4718 .tracked_entity(cx.entity_id()),
4719 window,
4720 cx,
4721 )
4722 }
4723 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4724 deferred(
4725 anchored()
4726 .position(*position)
4727 .anchor(gpui::Anchor::TopLeft)
4728 .child(menu.clone()),
4729 )
4730 .with_priority(1)
4731 }));
4732
4733 v_flex().w_full().flex_1().overflow_hidden().child(contents)
4734 }
4735
4736 fn render_filter_footer(&mut self, pinned: bool, cx: &mut Context<Self>) -> Div {
4737 let (pin_button_id, icon, icon_tooltip) = if pinned {
4738 ("unpin_button", IconName::Unpin, "Unpin Outline")
4739 } else {
4740 ("pin_button", IconName::Pin, "Pin Active Outline")
4741 };
4742
4743 let has_query = self.query(cx).is_some();
4744
4745 h_flex()
4746 .p_2()
4747 .h(Tab::container_height(cx))
4748 .justify_between()
4749 .border_b_1()
4750 .border_color(cx.theme().colors().border)
4751 .child(
4752 h_flex()
4753 .w_full()
4754 .gap_1p5()
4755 .child(
4756 Icon::new(IconName::MagnifyingGlass)
4757 .size(IconSize::Small)
4758 .color(Color::Muted),
4759 )
4760 .child(self.filter_editor.clone()),
4761 )
4762 .child(
4763 h_flex()
4764 .when(has_query, |this| {
4765 this.child(
4766 IconButton::new("clear_filter", IconName::Close)
4767 .shape(IconButtonShape::Square)
4768 .tooltip(Tooltip::text("Clear Filter"))
4769 .on_click(cx.listener(|outline_panel, _, window, cx| {
4770 outline_panel.filter_editor.update(cx, |editor, cx| {
4771 editor.set_text("", window, cx);
4772 });
4773 cx.notify();
4774 })),
4775 )
4776 })
4777 .child(
4778 IconButton::new(pin_button_id, icon)
4779 .tooltip(Tooltip::text(icon_tooltip))
4780 .shape(IconButtonShape::Square)
4781 .on_click(cx.listener(|outline_panel, _, window, cx| {
4782 outline_panel.toggle_active_editor_pin(
4783 &ToggleActiveEditorPin,
4784 window,
4785 cx,
4786 );
4787 })),
4788 ),
4789 )
4790 }
4791
4792 fn buffers_inside_directory(
4793 &self,
4794 dir_worktree: WorktreeId,
4795 dir_entry: &GitEntry,
4796 ) -> HashSet<BufferId> {
4797 if !dir_entry.is_dir() {
4798 debug_panic!("buffers_inside_directory called on a non-directory entry {dir_entry:?}");
4799 return HashSet::default();
4800 }
4801
4802 self.fs_entries
4803 .iter()
4804 .skip_while(|fs_entry| match fs_entry {
4805 FsEntry::Directory(directory) => {
4806 directory.worktree_id != dir_worktree || &directory.entry != dir_entry
4807 }
4808 _ => true,
4809 })
4810 .skip(1)
4811 .take_while(|fs_entry| match fs_entry {
4812 FsEntry::ExternalFile(..) => false,
4813 FsEntry::Directory(directory) => {
4814 directory.worktree_id == dir_worktree
4815 && directory.entry.path.starts_with(&dir_entry.path)
4816 }
4817 FsEntry::File(file) => {
4818 file.worktree_id == dir_worktree && file.entry.path.starts_with(&dir_entry.path)
4819 }
4820 })
4821 .filter_map(|fs_entry| match fs_entry {
4822 FsEntry::File(file) => Some(file.buffer_id),
4823 _ => None,
4824 })
4825 .collect()
4826 }
4827}
4828
4829fn workspace_active_editor(
4830 workspace: &Workspace,
4831 cx: &App,
4832) -> Option<(Box<dyn ItemHandle>, Entity<Editor>)> {
4833 let active_item = workspace.active_item(cx)?;
4834 let active_editor = active_item
4835 .act_as::<Editor>(cx)
4836 .filter(|editor| editor.read(cx).mode().is_full())?;
4837 Some((active_item, active_editor))
4838}
4839
4840fn back_to_common_visited_parent(
4841 visited_dirs: &mut Vec<(ProjectEntryId, Arc<RelPath>)>,
4842 worktree_id: &WorktreeId,
4843 new_entry: &Entry,
4844) -> Option<(WorktreeId, ProjectEntryId)> {
4845 while let Some((visited_dir_id, visited_path)) = visited_dirs.last() {
4846 match new_entry.path.parent() {
4847 Some(parent_path) => {
4848 if parent_path == visited_path.as_ref() {
4849 return Some((*worktree_id, *visited_dir_id));
4850 }
4851 }
4852 None => {
4853 break;
4854 }
4855 }
4856 visited_dirs.pop();
4857 }
4858 None
4859}
4860
4861fn file_name(path: &Path) -> String {
4862 let mut current_path = path;
4863 loop {
4864 if let Some(file_name) = current_path.file_name() {
4865 return file_name.to_string_lossy().into_owned();
4866 }
4867 match current_path.parent() {
4868 Some(parent) => current_path = parent,
4869 None => return path.to_string_lossy().into_owned(),
4870 }
4871 }
4872}
4873
4874impl Panel for OutlinePanel {
4875 fn persistent_name() -> &'static str {
4876 "Outline Panel"
4877 }
4878
4879 fn panel_key() -> &'static str {
4880 OUTLINE_PANEL_KEY
4881 }
4882
4883 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4884 match OutlinePanelSettings::get_global(cx).dock {
4885 DockSide::Left => DockPosition::Left,
4886 DockSide::Right => DockPosition::Right,
4887 }
4888 }
4889
4890 fn position_is_valid(&self, position: DockPosition) -> bool {
4891 matches!(position, DockPosition::Left | DockPosition::Right)
4892 }
4893
4894 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4895 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
4896 let dock = match position {
4897 DockPosition::Left | DockPosition::Bottom => DockSide::Left,
4898 DockPosition::Right => DockSide::Right,
4899 };
4900 settings.outline_panel.get_or_insert_default().dock = Some(dock);
4901 });
4902 }
4903
4904 fn default_size(&self, _: &Window, cx: &App) -> Pixels {
4905 OutlinePanelSettings::get_global(cx).default_width
4906 }
4907
4908 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4909 OutlinePanelSettings::get_global(cx)
4910 .button
4911 .then_some(IconName::ListTree)
4912 }
4913
4914 fn icon_tooltip(&self, _window: &Window, _: &App) -> Option<&'static str> {
4915 Some("Outline Panel")
4916 }
4917
4918 fn toggle_action(&self) -> Box<dyn Action> {
4919 Box::new(ToggleFocus)
4920 }
4921
4922 fn starts_open(&self, _window: &Window, _: &App) -> bool {
4923 self.active
4924 }
4925
4926 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
4927 cx.spawn_in(window, async move |outline_panel, cx| {
4928 outline_panel
4929 .update_in(cx, |outline_panel, window, cx| {
4930 let old_active = outline_panel.active;
4931 outline_panel.active = active;
4932 if old_active != active {
4933 if active
4934 && let Some((active_item, active_editor)) =
4935 outline_panel.workspace.upgrade().and_then(|workspace| {
4936 workspace_active_editor(workspace.read(cx), cx)
4937 })
4938 {
4939 if outline_panel.should_replace_active_item(active_item.as_ref()) {
4940 outline_panel.replace_active_editor(
4941 active_item,
4942 active_editor,
4943 window,
4944 cx,
4945 );
4946 } else {
4947 outline_panel.update_fs_entries(active_editor, None, window, cx)
4948 }
4949 return;
4950 }
4951
4952 if !outline_panel.pinned {
4953 outline_panel.clear_previous(window, cx);
4954 }
4955 }
4956 outline_panel.serialize(cx);
4957 })
4958 .ok();
4959 })
4960 .detach()
4961 }
4962
4963 fn activation_priority(&self) -> u32 {
4964 6
4965 }
4966}
4967
4968impl Focusable for OutlinePanel {
4969 fn focus_handle(&self, cx: &App) -> FocusHandle {
4970 self.filter_editor.focus_handle(cx)
4971 }
4972}
4973
4974impl EventEmitter<Event> for OutlinePanel {}
4975
4976impl EventEmitter<PanelEvent> for OutlinePanel {}
4977
4978impl Render for OutlinePanel {
4979 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4980 let (is_local, is_via_ssh) = self.project.read_with(cx, |project, _| {
4981 (project.is_local(), project.is_via_remote_server())
4982 });
4983 let query = self.query(cx);
4984 let pinned = self.pinned;
4985 let settings = OutlinePanelSettings::get_global(cx);
4986 let indent_size = settings.indent_size;
4987 let show_indent_guides = settings.indent_guides.show == ShowIndentGuides::Always;
4988
4989 let search_query = match &self.mode {
4990 ItemsDisplayMode::Search(search_query) => Some(search_query),
4991 _ => None,
4992 };
4993
4994 let search_query_text = search_query.map(|sq| sq.query.to_string());
4995
4996 v_flex()
4997 .id("outline-panel")
4998 .size_full()
4999 .overflow_hidden()
5000 .relative()
5001 .key_context(self.dispatch_context(window, cx))
5002 .on_action(cx.listener(Self::open_selected_entry))
5003 .on_action(cx.listener(Self::cancel))
5004 .on_action(cx.listener(Self::scroll_up))
5005 .on_action(cx.listener(Self::scroll_down))
5006 .on_action(cx.listener(Self::select_next))
5007 .on_action(cx.listener(Self::scroll_cursor_center))
5008 .on_action(cx.listener(Self::scroll_cursor_top))
5009 .on_action(cx.listener(Self::scroll_cursor_bottom))
5010 .on_action(cx.listener(Self::select_previous))
5011 .on_action(cx.listener(Self::select_first))
5012 .on_action(cx.listener(Self::select_last))
5013 .on_action(cx.listener(Self::select_parent))
5014 .on_action(cx.listener(Self::expand_selected_entry))
5015 .on_action(cx.listener(Self::collapse_selected_entry))
5016 .on_action(cx.listener(Self::expand_all_entries))
5017 .on_action(cx.listener(Self::collapse_all_entries))
5018 .on_action(cx.listener(Self::copy_path))
5019 .on_action(cx.listener(Self::copy_relative_path))
5020 .on_action(cx.listener(Self::toggle_active_editor_pin))
5021 .on_action(cx.listener(Self::unfold_directory))
5022 .on_action(cx.listener(Self::fold_directory))
5023 .on_action(cx.listener(Self::open_excerpts))
5024 .on_action(cx.listener(Self::open_excerpts_split))
5025 .when(is_local, |el| {
5026 el.on_action(cx.listener(Self::reveal_in_finder))
5027 })
5028 .when(is_local || is_via_ssh, |el| {
5029 el.on_action(cx.listener(Self::open_in_terminal))
5030 })
5031 .on_mouse_down(
5032 MouseButton::Right,
5033 cx.listener(move |outline_panel, event: &MouseDownEvent, window, cx| {
5034 if let Some(entry) = outline_panel.selected_entry().cloned() {
5035 outline_panel.deploy_context_menu(event.position, entry, window, cx)
5036 } else if let Some(entry) = outline_panel.fs_entries.first().cloned() {
5037 outline_panel.deploy_context_menu(
5038 event.position,
5039 PanelEntry::Fs(entry),
5040 window,
5041 cx,
5042 )
5043 }
5044 }),
5045 )
5046 .track_focus(&self.focus_handle)
5047 .child(self.render_filter_footer(pinned, cx))
5048 .when_some(search_query_text, |outline_panel, query_text| {
5049 outline_panel.child(
5050 h_flex()
5051 .py_1p5()
5052 .px_2()
5053 .h(Tab::container_height(cx))
5054 .gap_0p5()
5055 .border_b_1()
5056 .border_color(cx.theme().colors().border_variant)
5057 .child(Label::new("Searching:").color(Color::Muted))
5058 .child(Label::new(query_text)),
5059 )
5060 })
5061 .child(self.render_main_contents(query, show_indent_guides, indent_size, window, cx))
5062 }
5063}
5064
5065fn find_active_indent_guide_ix(
5066 outline_panel: &OutlinePanel,
5067 candidates: &[IndentGuideLayout],
5068) -> Option<usize> {
5069 let SelectedEntry::Valid(_, target_ix) = &outline_panel.selected_entry else {
5070 return None;
5071 };
5072 let target_depth = outline_panel
5073 .cached_entries
5074 .get(*target_ix)
5075 .map(|cached_entry| cached_entry.depth)?;
5076
5077 let (target_ix, target_depth) = if let Some(target_depth) = outline_panel
5078 .cached_entries
5079 .get(target_ix + 1)
5080 .filter(|cached_entry| cached_entry.depth > target_depth)
5081 .map(|entry| entry.depth)
5082 {
5083 (target_ix + 1, target_depth.saturating_sub(1))
5084 } else {
5085 (*target_ix, target_depth.saturating_sub(1))
5086 };
5087
5088 candidates
5089 .iter()
5090 .enumerate()
5091 .find(|(_, guide)| {
5092 guide.offset.y <= target_ix
5093 && target_ix < guide.offset.y + guide.length
5094 && guide.offset.x == target_depth
5095 })
5096 .map(|(ix, _)| ix)
5097}
5098
5099fn subscribe_for_editor_events(
5100 editor: &Entity<Editor>,
5101 window: &mut Window,
5102 cx: &mut Context<OutlinePanel>,
5103) -> Subscription {
5104 let debounce = Some(UPDATE_DEBOUNCE);
5105 cx.subscribe_in(
5106 editor,
5107 window,
5108 move |outline_panel, editor, e: &EditorEvent, window, cx| {
5109 if !outline_panel.active {
5110 return;
5111 }
5112 match e {
5113 EditorEvent::SelectionsChanged { local: true } => {
5114 outline_panel.reveal_entry_for_selection(editor.clone(), window, cx);
5115 cx.notify();
5116 }
5117 EditorEvent::BuffersRemoved { removed_buffer_ids } => {
5118 outline_panel
5119 .buffers
5120 .retain(|buffer_id, _| !removed_buffer_ids.contains(buffer_id));
5121 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5122 }
5123 EditorEvent::BufferRangesUpdated { buffer, .. } => {
5124 outline_panel
5125 .new_entries_for_fs_update
5126 .insert(buffer.read(cx).remote_id());
5127 outline_panel.invalidate_outlines(&[buffer.read(cx).remote_id()]);
5128 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5129 }
5130 EditorEvent::BuffersEdited { buffer_ids } => {
5131 outline_panel.invalidate_outlines(buffer_ids);
5132 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
5133 if update_cached_items {
5134 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5135 }
5136 }
5137 EditorEvent::BufferFoldToggled { ids, .. } => {
5138 outline_panel.invalidate_outlines(ids);
5139 let mut latest_unfolded_buffer_id = None;
5140 let mut latest_folded_buffer_id = None;
5141 let mut ignore_selections_change = false;
5142 outline_panel.new_entries_for_fs_update.extend(
5143 ids.iter()
5144 .filter(|id| {
5145 if outline_panel.buffers.contains_key(&id) {
5146 ignore_selections_change |= outline_panel
5147 .preserve_selection_on_buffer_fold_toggles
5148 .remove(&id);
5149 if editor.read(cx).is_buffer_folded(**id, cx) {
5150 latest_folded_buffer_id = Some(**id);
5151 false
5152 } else {
5153 latest_unfolded_buffer_id = Some(**id);
5154 true
5155 }
5156 } else {
5157 false
5158 }
5159 })
5160 .copied(),
5161 );
5162 if !ignore_selections_change
5163 && let Some(entry_to_select) = latest_unfolded_buffer_id
5164 .or(latest_folded_buffer_id)
5165 .and_then(|toggled_buffer_id| {
5166 outline_panel.fs_entries.iter().find_map(
5167 |fs_entry| match fs_entry {
5168 FsEntry::ExternalFile(external) => {
5169 if external.buffer_id == toggled_buffer_id {
5170 Some(fs_entry.clone())
5171 } else {
5172 None
5173 }
5174 }
5175 FsEntry::File(FsEntryFile { buffer_id, .. }) => {
5176 if *buffer_id == toggled_buffer_id {
5177 Some(fs_entry.clone())
5178 } else {
5179 None
5180 }
5181 }
5182 FsEntry::Directory(..) => None,
5183 },
5184 )
5185 })
5186 .map(PanelEntry::Fs)
5187 {
5188 outline_panel.select_entry(entry_to_select, true, window, cx);
5189 }
5190
5191 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5192 }
5193 EditorEvent::Reparsed(buffer_id) => {
5194 if let Some(buffer) = outline_panel.buffers.get_mut(buffer_id) {
5195 buffer.invalidate_outlines();
5196 }
5197 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
5198 if update_cached_items {
5199 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5200 }
5201 }
5202 EditorEvent::OutlineSymbolsChanged => {
5203 for buffer in outline_panel.buffers.values_mut() {
5204 buffer.invalidate_outlines();
5205 }
5206 if matches!(
5207 outline_panel.selected_entry(),
5208 Some(PanelEntry::Outline(..)),
5209 ) {
5210 outline_panel.selected_entry.invalidate();
5211 }
5212 if outline_panel.update_non_fs_items(window, cx) {
5213 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5214 }
5215 }
5216 EditorEvent::TitleChanged => {
5217 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5218 }
5219 _ => {}
5220 }
5221 },
5222 )
5223}
5224
5225fn empty_icon() -> AnyElement {
5226 h_flex()
5227 .size(IconSize::default().rems())
5228 .invisible()
5229 .flex_none()
5230 .into_any_element()
5231}
5232
5233#[derive(Debug, Default)]
5234struct GenerationState {
5235 entries: Vec<CachedEntry>,
5236 match_candidates: Vec<StringMatchCandidate>,
5237 max_width_estimate_and_index: Option<(u64, usize)>,
5238}
5239
5240impl GenerationState {
5241 fn clear(&mut self) {
5242 self.entries.clear();
5243 self.match_candidates.clear();
5244 self.max_width_estimate_and_index = None;
5245 }
5246}
5247
5248#[cfg(test)]
5249mod tests {
5250 use db::indoc;
5251 use futures::stream::StreamExt as _;
5252 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle};
5253 use language::{self, FakeLspAdapter, markdown_lang, rust_lang};
5254 use pretty_assertions::assert_eq;
5255 use project::FakeFs;
5256 use search::{
5257 buffer_search,
5258 project_search::{self, perform_project_search},
5259 };
5260 use serde_json::json;
5261 use util::path;
5262 use workspace::{MultiWorkspace, OpenOptions, OpenVisible, ToolbarItemView};
5263
5264 use super::*;
5265
5266 const SELECTED_MARKER: &str = " <==== selected";
5267
5268 #[gpui::test(iterations = 10)]
5269 async fn test_project_search_results_toggling(cx: &mut TestAppContext) {
5270 init_test(cx);
5271
5272 let fs = FakeFs::new(cx.background_executor.clone());
5273 let root = path!("/rust-analyzer");
5274 populate_with_test_ra_project(&fs, root).await;
5275 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5276 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
5277 let (window, workspace) = add_outline_panel(&project, cx).await;
5278 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5279 let outline_panel = outline_panel(&workspace, cx);
5280 outline_panel.update_in(cx, |outline_panel, window, cx| {
5281 outline_panel.set_active(true, window, cx)
5282 });
5283
5284 workspace.update_in(cx, |workspace, window, cx| {
5285 ProjectSearchView::deploy_search(
5286 workspace,
5287 &workspace::DeploySearch::default(),
5288 window,
5289 cx,
5290 )
5291 });
5292 let search_view = workspace.update_in(cx, |workspace, _window, cx| {
5293 workspace
5294 .active_pane()
5295 .read(cx)
5296 .items()
5297 .find_map(|item| item.downcast::<ProjectSearchView>())
5298 .expect("Project search view expected to appear after new search event trigger")
5299 });
5300
5301 let query = "param_names_for_lifetime_elision_hints";
5302 perform_project_search(&search_view, query, cx);
5303 search_view.update(cx, |search_view, cx| {
5304 search_view
5305 .results_editor()
5306 .update(cx, |results_editor, cx| {
5307 assert_eq!(
5308 results_editor.display_text(cx).match_indices(query).count(),
5309 9
5310 );
5311 });
5312 });
5313
5314 let all_matches = r#"rust-analyzer/
5315 crates/
5316 ide/src/
5317 inlay_hints/
5318 fn_lifetime_fn.rs
5319 search: match config.«param_names_for_lifetime_elision_hints» {
5320 search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
5321 search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
5322 search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
5323 inlay_hints.rs
5324 search: pub «param_names_for_lifetime_elision_hints»: bool,
5325 search: «param_names_for_lifetime_elision_hints»: self
5326 static_index.rs
5327 search: «param_names_for_lifetime_elision_hints»: false,
5328 rust-analyzer/src/
5329 cli/
5330 analysis_stats.rs
5331 search: «param_names_for_lifetime_elision_hints»: true,
5332 config.rs
5333 search: «param_names_for_lifetime_elision_hints»: self"#
5334 .to_string();
5335
5336 let select_first_in_all_matches = |line_to_select: &str| {
5337 assert!(
5338 all_matches.contains(line_to_select),
5339 "`{line_to_select}` was not found in all matches `{all_matches}`"
5340 );
5341 all_matches.replacen(
5342 line_to_select,
5343 &format!("{line_to_select}{SELECTED_MARKER}"),
5344 1,
5345 )
5346 };
5347
5348 cx.executor()
5349 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5350 cx.run_until_parked();
5351 outline_panel.update(cx, |outline_panel, cx| {
5352 assert_eq!(
5353 display_entries(
5354 &project,
5355 &snapshot(outline_panel, cx),
5356 &outline_panel.cached_entries,
5357 outline_panel.selected_entry(),
5358 cx,
5359 ),
5360 select_first_in_all_matches(
5361 "search: match config.«param_names_for_lifetime_elision_hints» {"
5362 )
5363 );
5364 });
5365
5366 outline_panel.update_in(cx, |outline_panel, window, cx| {
5367 outline_panel.select_parent(&SelectParent, window, cx);
5368 assert_eq!(
5369 display_entries(
5370 &project,
5371 &snapshot(outline_panel, cx),
5372 &outline_panel.cached_entries,
5373 outline_panel.selected_entry(),
5374 cx,
5375 ),
5376 select_first_in_all_matches("fn_lifetime_fn.rs")
5377 );
5378 });
5379 outline_panel.update_in(cx, |outline_panel, window, cx| {
5380 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5381 });
5382 cx.executor()
5383 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5384 cx.run_until_parked();
5385 outline_panel.update(cx, |outline_panel, cx| {
5386 assert_eq!(
5387 display_entries(
5388 &project,
5389 &snapshot(outline_panel, cx),
5390 &outline_panel.cached_entries,
5391 outline_panel.selected_entry(),
5392 cx,
5393 ),
5394 format!(
5395 r#"rust-analyzer/
5396 crates/
5397 ide/src/
5398 inlay_hints/
5399 fn_lifetime_fn.rs{SELECTED_MARKER}
5400 inlay_hints.rs
5401 search: pub «param_names_for_lifetime_elision_hints»: bool,
5402 search: «param_names_for_lifetime_elision_hints»: self
5403 static_index.rs
5404 search: «param_names_for_lifetime_elision_hints»: false,
5405 rust-analyzer/src/
5406 cli/
5407 analysis_stats.rs
5408 search: «param_names_for_lifetime_elision_hints»: true,
5409 config.rs
5410 search: «param_names_for_lifetime_elision_hints»: self"#,
5411 )
5412 );
5413 });
5414
5415 outline_panel.update_in(cx, |outline_panel, window, cx| {
5416 outline_panel.expand_all_entries(&ExpandAllEntries, window, cx);
5417 });
5418 cx.executor()
5419 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5420 cx.run_until_parked();
5421 outline_panel.update_in(cx, |outline_panel, window, cx| {
5422 outline_panel.select_parent(&SelectParent, window, cx);
5423 assert_eq!(
5424 display_entries(
5425 &project,
5426 &snapshot(outline_panel, cx),
5427 &outline_panel.cached_entries,
5428 outline_panel.selected_entry(),
5429 cx,
5430 ),
5431 select_first_in_all_matches("inlay_hints/")
5432 );
5433 });
5434
5435 outline_panel.update_in(cx, |outline_panel, window, cx| {
5436 outline_panel.select_parent(&SelectParent, window, cx);
5437 assert_eq!(
5438 display_entries(
5439 &project,
5440 &snapshot(outline_panel, cx),
5441 &outline_panel.cached_entries,
5442 outline_panel.selected_entry(),
5443 cx,
5444 ),
5445 select_first_in_all_matches("ide/src/")
5446 );
5447 });
5448
5449 outline_panel.update_in(cx, |outline_panel, window, cx| {
5450 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5451 });
5452 cx.executor()
5453 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5454 cx.run_until_parked();
5455 outline_panel.update(cx, |outline_panel, cx| {
5456 assert_eq!(
5457 display_entries(
5458 &project,
5459 &snapshot(outline_panel, cx),
5460 &outline_panel.cached_entries,
5461 outline_panel.selected_entry(),
5462 cx,
5463 ),
5464 format!(
5465 r#"rust-analyzer/
5466 crates/
5467 ide/src/{SELECTED_MARKER}
5468 rust-analyzer/src/
5469 cli/
5470 analysis_stats.rs
5471 search: «param_names_for_lifetime_elision_hints»: true,
5472 config.rs
5473 search: «param_names_for_lifetime_elision_hints»: self"#,
5474 )
5475 );
5476 });
5477 outline_panel.update_in(cx, |outline_panel, window, cx| {
5478 outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
5479 });
5480 cx.executor()
5481 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5482 cx.run_until_parked();
5483 outline_panel.update(cx, |outline_panel, cx| {
5484 assert_eq!(
5485 display_entries(
5486 &project,
5487 &snapshot(outline_panel, cx),
5488 &outline_panel.cached_entries,
5489 outline_panel.selected_entry(),
5490 cx,
5491 ),
5492 select_first_in_all_matches("ide/src/")
5493 );
5494 });
5495 }
5496
5497 #[gpui::test(iterations = 10)]
5498 async fn test_item_filtering(cx: &mut TestAppContext) {
5499 init_test(cx);
5500
5501 let fs = FakeFs::new(cx.background_executor.clone());
5502 let root = path!("/rust-analyzer");
5503 populate_with_test_ra_project(&fs, root).await;
5504 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5505 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
5506 let (window, workspace) = add_outline_panel(&project, cx).await;
5507 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5508 let outline_panel = outline_panel(&workspace, cx);
5509 outline_panel.update_in(cx, |outline_panel, window, cx| {
5510 outline_panel.set_active(true, window, cx)
5511 });
5512
5513 workspace.update_in(cx, |workspace, window, cx| {
5514 ProjectSearchView::deploy_search(
5515 workspace,
5516 &workspace::DeploySearch::default(),
5517 window,
5518 cx,
5519 )
5520 });
5521 let search_view = workspace.update_in(cx, |workspace, _window, cx| {
5522 workspace
5523 .active_pane()
5524 .read(cx)
5525 .items()
5526 .find_map(|item| item.downcast::<ProjectSearchView>())
5527 .expect("Project search view expected to appear after new search event trigger")
5528 });
5529
5530 let query = "param_names_for_lifetime_elision_hints";
5531 perform_project_search(&search_view, query, cx);
5532 search_view.update(cx, |search_view, cx| {
5533 search_view
5534 .results_editor()
5535 .update(cx, |results_editor, cx| {
5536 assert_eq!(
5537 results_editor.display_text(cx).match_indices(query).count(),
5538 9
5539 );
5540 });
5541 });
5542 let all_matches = r#"rust-analyzer/
5543 crates/
5544 ide/src/
5545 inlay_hints/
5546 fn_lifetime_fn.rs
5547 search: match config.«param_names_for_lifetime_elision_hints» {
5548 search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
5549 search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
5550 search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
5551 inlay_hints.rs
5552 search: pub «param_names_for_lifetime_elision_hints»: bool,
5553 search: «param_names_for_lifetime_elision_hints»: self
5554 static_index.rs
5555 search: «param_names_for_lifetime_elision_hints»: false,
5556 rust-analyzer/src/
5557 cli/
5558 analysis_stats.rs
5559 search: «param_names_for_lifetime_elision_hints»: true,
5560 config.rs
5561 search: «param_names_for_lifetime_elision_hints»: self"#
5562 .to_string();
5563
5564 cx.executor()
5565 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5566 cx.run_until_parked();
5567 outline_panel.update(cx, |outline_panel, cx| {
5568 assert_eq!(
5569 display_entries(
5570 &project,
5571 &snapshot(outline_panel, cx),
5572 &outline_panel.cached_entries,
5573 None,
5574 cx,
5575 ),
5576 all_matches,
5577 );
5578 });
5579
5580 let filter_text = "a";
5581 outline_panel.update_in(cx, |outline_panel, window, cx| {
5582 outline_panel.filter_editor.update(cx, |filter_editor, cx| {
5583 filter_editor.set_text(filter_text, window, cx);
5584 });
5585 });
5586 cx.executor()
5587 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5588 cx.run_until_parked();
5589
5590 outline_panel.update(cx, |outline_panel, cx| {
5591 assert_eq!(
5592 display_entries(
5593 &project,
5594 &snapshot(outline_panel, cx),
5595 &outline_panel.cached_entries,
5596 None,
5597 cx,
5598 ),
5599 all_matches
5600 .lines()
5601 .skip(1) // `/rust-analyzer/` is a root entry with path `` and it will be filtered out
5602 .filter(|item| item.contains(filter_text))
5603 .collect::<Vec<_>>()
5604 .join("\n"),
5605 );
5606 });
5607
5608 outline_panel.update_in(cx, |outline_panel, window, cx| {
5609 outline_panel.filter_editor.update(cx, |filter_editor, cx| {
5610 filter_editor.set_text("", window, cx);
5611 });
5612 });
5613 cx.executor()
5614 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5615 cx.run_until_parked();
5616 outline_panel.update(cx, |outline_panel, cx| {
5617 assert_eq!(
5618 display_entries(
5619 &project,
5620 &snapshot(outline_panel, cx),
5621 &outline_panel.cached_entries,
5622 None,
5623 cx,
5624 ),
5625 all_matches,
5626 );
5627 });
5628 }
5629
5630 #[gpui::test(iterations = 10)]
5631 async fn test_item_opening(cx: &mut TestAppContext) {
5632 init_test(cx);
5633
5634 let fs = FakeFs::new(cx.background_executor.clone());
5635 let root = path!("/rust-analyzer");
5636 populate_with_test_ra_project(&fs, root).await;
5637 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5638 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
5639 let (window, workspace) = add_outline_panel(&project, cx).await;
5640 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5641 let outline_panel = outline_panel(&workspace, cx);
5642 outline_panel.update_in(cx, |outline_panel, window, cx| {
5643 outline_panel.set_active(true, window, cx)
5644 });
5645
5646 workspace.update_in(cx, |workspace, window, cx| {
5647 ProjectSearchView::deploy_search(
5648 workspace,
5649 &workspace::DeploySearch::default(),
5650 window,
5651 cx,
5652 )
5653 });
5654 let search_view = workspace.update_in(cx, |workspace, _window, cx| {
5655 workspace
5656 .active_pane()
5657 .read(cx)
5658 .items()
5659 .find_map(|item| item.downcast::<ProjectSearchView>())
5660 .expect("Project search view expected to appear after new search event trigger")
5661 });
5662
5663 let query = "param_names_for_lifetime_elision_hints";
5664 perform_project_search(&search_view, query, cx);
5665 search_view.update(cx, |search_view, cx| {
5666 search_view
5667 .results_editor()
5668 .update(cx, |results_editor, cx| {
5669 assert_eq!(
5670 results_editor.display_text(cx).match_indices(query).count(),
5671 9
5672 );
5673 });
5674 });
5675 let all_matches = r#"rust-analyzer/
5676 crates/
5677 ide/src/
5678 inlay_hints/
5679 fn_lifetime_fn.rs
5680 search: match config.«param_names_for_lifetime_elision_hints» {
5681 search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
5682 search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
5683 search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
5684 inlay_hints.rs
5685 search: pub «param_names_for_lifetime_elision_hints»: bool,
5686 search: «param_names_for_lifetime_elision_hints»: self
5687 static_index.rs
5688 search: «param_names_for_lifetime_elision_hints»: false,
5689 rust-analyzer/src/
5690 cli/
5691 analysis_stats.rs
5692 search: «param_names_for_lifetime_elision_hints»: true,
5693 config.rs
5694 search: «param_names_for_lifetime_elision_hints»: self"#
5695 .to_string();
5696 let select_first_in_all_matches = |line_to_select: &str| {
5697 assert!(
5698 all_matches.contains(line_to_select),
5699 "`{line_to_select}` was not found in all matches `{all_matches}`"
5700 );
5701 all_matches.replacen(
5702 line_to_select,
5703 &format!("{line_to_select}{SELECTED_MARKER}"),
5704 1,
5705 )
5706 };
5707 let clear_outline_metadata = |input: &str| {
5708 input
5709 .replace("search: ", "")
5710 .replace("«", "")
5711 .replace("»", "")
5712 };
5713
5714 cx.executor()
5715 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5716 cx.run_until_parked();
5717
5718 let active_editor = outline_panel.read_with(cx, |outline_panel, _| {
5719 outline_panel
5720 .active_editor()
5721 .expect("should have an active editor open")
5722 });
5723 let initial_outline_selection =
5724 "search: match config.«param_names_for_lifetime_elision_hints» {";
5725 outline_panel.update_in(cx, |outline_panel, window, cx| {
5726 assert_eq!(
5727 display_entries(
5728 &project,
5729 &snapshot(outline_panel, cx),
5730 &outline_panel.cached_entries,
5731 outline_panel.selected_entry(),
5732 cx,
5733 ),
5734 select_first_in_all_matches(initial_outline_selection)
5735 );
5736 assert_eq!(
5737 selected_row_text(&active_editor, cx),
5738 clear_outline_metadata(initial_outline_selection),
5739 "Should place the initial editor selection on the corresponding search result"
5740 );
5741
5742 outline_panel.select_next(&SelectNext, window, cx);
5743 outline_panel.select_next(&SelectNext, window, cx);
5744 });
5745
5746 let navigated_outline_selection =
5747 "search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {";
5748 outline_panel.update(cx, |outline_panel, cx| {
5749 assert_eq!(
5750 display_entries(
5751 &project,
5752 &snapshot(outline_panel, cx),
5753 &outline_panel.cached_entries,
5754 outline_panel.selected_entry(),
5755 cx,
5756 ),
5757 select_first_in_all_matches(navigated_outline_selection)
5758 );
5759 });
5760 cx.executor()
5761 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5762 outline_panel.update(cx, |_, cx| {
5763 assert_eq!(
5764 selected_row_text(&active_editor, cx),
5765 clear_outline_metadata(navigated_outline_selection),
5766 "Should still have the initial caret position after SelectNext calls"
5767 );
5768 });
5769
5770 outline_panel.update_in(cx, |outline_panel, window, cx| {
5771 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
5772 });
5773 outline_panel.update(cx, |_outline_panel, cx| {
5774 assert_eq!(
5775 selected_row_text(&active_editor, cx),
5776 clear_outline_metadata(navigated_outline_selection),
5777 "After opening, should move the caret to the opened outline entry's position"
5778 );
5779 });
5780
5781 outline_panel.update_in(cx, |outline_panel, window, cx| {
5782 outline_panel.select_next(&SelectNext, window, cx);
5783 });
5784 let next_navigated_outline_selection = "search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },";
5785 outline_panel.update(cx, |outline_panel, cx| {
5786 assert_eq!(
5787 display_entries(
5788 &project,
5789 &snapshot(outline_panel, cx),
5790 &outline_panel.cached_entries,
5791 outline_panel.selected_entry(),
5792 cx,
5793 ),
5794 select_first_in_all_matches(next_navigated_outline_selection)
5795 );
5796 });
5797 cx.executor()
5798 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5799 outline_panel.update(cx, |_outline_panel, cx| {
5800 assert_eq!(
5801 selected_row_text(&active_editor, cx),
5802 clear_outline_metadata(next_navigated_outline_selection),
5803 "Should again preserve the selection after another SelectNext call"
5804 );
5805 });
5806
5807 outline_panel.update_in(cx, |outline_panel, window, cx| {
5808 outline_panel.open_excerpts(&editor::actions::OpenExcerpts, window, cx);
5809 });
5810 cx.executor()
5811 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5812 cx.run_until_parked();
5813 let new_active_editor = outline_panel.read_with(cx, |outline_panel, _| {
5814 outline_panel
5815 .active_editor()
5816 .expect("should have an active editor open")
5817 });
5818 outline_panel.update(cx, |outline_panel, cx| {
5819 assert_ne!(
5820 active_editor, new_active_editor,
5821 "After opening an excerpt, new editor should be open"
5822 );
5823 assert_eq!(
5824 display_entries(
5825 &project,
5826 &snapshot(outline_panel, cx),
5827 &outline_panel.cached_entries,
5828 outline_panel.selected_entry(),
5829 cx,
5830 ),
5831 "outline: pub(super) fn hints
5832outline: fn hints_lifetimes_named <==== selected"
5833 );
5834 assert_eq!(
5835 selected_row_text(&new_active_editor, cx),
5836 clear_outline_metadata(next_navigated_outline_selection),
5837 "When opening the excerpt, should navigate to the place corresponding the outline entry"
5838 );
5839 });
5840 }
5841
5842 #[gpui::test]
5843 async fn test_multiple_worktrees(cx: &mut TestAppContext) {
5844 init_test(cx);
5845
5846 let fs = FakeFs::new(cx.background_executor.clone());
5847 fs.insert_tree(
5848 path!("/root"),
5849 json!({
5850 "one": {
5851 "a.txt": "aaa aaa"
5852 },
5853 "two": {
5854 "b.txt": "a aaa"
5855 }
5856
5857 }),
5858 )
5859 .await;
5860 let project = Project::test(fs.clone(), [Path::new(path!("/root/one"))], cx).await;
5861 let (window, workspace) = add_outline_panel(&project, cx).await;
5862 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5863 let outline_panel = outline_panel(&workspace, cx);
5864 outline_panel.update_in(cx, |outline_panel, window, cx| {
5865 outline_panel.set_active(true, window, cx)
5866 });
5867
5868 let items = workspace
5869 .update_in(cx, |workspace, window, cx| {
5870 workspace.open_paths(
5871 vec![PathBuf::from(path!("/root/two"))],
5872 OpenOptions {
5873 visible: Some(OpenVisible::OnlyDirectories),
5874 ..Default::default()
5875 },
5876 None,
5877 window,
5878 cx,
5879 )
5880 })
5881 .await;
5882 assert_eq!(items.len(), 1, "Were opening another worktree directory");
5883 assert!(
5884 items[0].is_none(),
5885 "Directory should be opened successfully"
5886 );
5887
5888 workspace.update_in(cx, |workspace, window, cx| {
5889 ProjectSearchView::deploy_search(
5890 workspace,
5891 &workspace::DeploySearch::default(),
5892 window,
5893 cx,
5894 )
5895 });
5896 let search_view = workspace.update_in(cx, |workspace, _window, cx| {
5897 workspace
5898 .active_pane()
5899 .read(cx)
5900 .items()
5901 .find_map(|item| item.downcast::<ProjectSearchView>())
5902 .expect("Project search view expected to appear after new search event trigger")
5903 });
5904
5905 let query = "aaa";
5906 perform_project_search(&search_view, query, cx);
5907 search_view.update(cx, |search_view, cx| {
5908 search_view
5909 .results_editor()
5910 .update(cx, |results_editor, cx| {
5911 assert_eq!(
5912 results_editor.display_text(cx).match_indices(query).count(),
5913 3
5914 );
5915 });
5916 });
5917
5918 cx.executor()
5919 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5920 cx.run_until_parked();
5921 outline_panel.update(cx, |outline_panel, cx| {
5922 assert_eq!(
5923 display_entries(
5924 &project,
5925 &snapshot(outline_panel, cx),
5926 &outline_panel.cached_entries,
5927 outline_panel.selected_entry(),
5928 cx,
5929 ),
5930 format!(
5931 r#"one/
5932 a.txt
5933 search: «aaa» aaa <==== selected
5934 search: aaa «aaa»
5935two/
5936 b.txt
5937 search: a «aaa»"#,
5938 ),
5939 );
5940 });
5941
5942 outline_panel.update_in(cx, |outline_panel, window, cx| {
5943 outline_panel.select_previous(&SelectPrevious, window, cx);
5944 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5945 });
5946 cx.executor()
5947 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5948 cx.run_until_parked();
5949 outline_panel.update(cx, |outline_panel, cx| {
5950 assert_eq!(
5951 display_entries(
5952 &project,
5953 &snapshot(outline_panel, cx),
5954 &outline_panel.cached_entries,
5955 outline_panel.selected_entry(),
5956 cx,
5957 ),
5958 format!(
5959 r#"one/
5960 a.txt <==== selected
5961two/
5962 b.txt
5963 search: a «aaa»"#,
5964 ),
5965 );
5966 });
5967
5968 outline_panel.update_in(cx, |outline_panel, window, cx| {
5969 outline_panel.select_next(&SelectNext, window, cx);
5970 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5971 });
5972 cx.executor()
5973 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5974 cx.run_until_parked();
5975 outline_panel.update(cx, |outline_panel, cx| {
5976 assert_eq!(
5977 display_entries(
5978 &project,
5979 &snapshot(outline_panel, cx),
5980 &outline_panel.cached_entries,
5981 outline_panel.selected_entry(),
5982 cx,
5983 ),
5984 format!(
5985 r#"one/
5986 a.txt
5987two/ <==== selected"#,
5988 ),
5989 );
5990 });
5991
5992 outline_panel.update_in(cx, |outline_panel, window, cx| {
5993 outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
5994 });
5995 cx.executor()
5996 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5997 cx.run_until_parked();
5998 outline_panel.update(cx, |outline_panel, cx| {
5999 assert_eq!(
6000 display_entries(
6001 &project,
6002 &snapshot(outline_panel, cx),
6003 &outline_panel.cached_entries,
6004 outline_panel.selected_entry(),
6005 cx,
6006 ),
6007 format!(
6008 r#"one/
6009 a.txt
6010two/ <==== selected
6011 b.txt
6012 search: a «aaa»"#,
6013 )
6014 );
6015 });
6016 }
6017
6018 #[gpui::test]
6019 async fn test_navigating_in_singleton(cx: &mut TestAppContext) {
6020 init_test(cx);
6021
6022 let root = path!("/root");
6023 let fs = FakeFs::new(cx.background_executor.clone());
6024 fs.insert_tree(
6025 root,
6026 json!({
6027 "src": {
6028 "lib.rs": indoc!("
6029#[derive(Clone, Debug, PartialEq, Eq, Hash)]
6030struct OutlineEntryExcerpt {
6031 id: ExcerptId,
6032 buffer_id: BufferId,
6033 range: ExcerptRange<language::Anchor>,
6034}"),
6035 }
6036 }),
6037 )
6038 .await;
6039 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
6040 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
6041 let (window, workspace) = add_outline_panel(&project, cx).await;
6042 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6043 let outline_panel = outline_panel(&workspace, cx);
6044 cx.update(|window, cx| {
6045 outline_panel.update(cx, |outline_panel, cx| {
6046 outline_panel.set_active(true, window, cx)
6047 });
6048 });
6049
6050 let _editor = workspace
6051 .update_in(cx, |workspace, window, cx| {
6052 workspace.open_abs_path(
6053 PathBuf::from(path!("/root/src/lib.rs")),
6054 OpenOptions {
6055 visible: Some(OpenVisible::All),
6056 ..Default::default()
6057 },
6058 window,
6059 cx,
6060 )
6061 })
6062 .await
6063 .expect("Failed to open Rust source file")
6064 .downcast::<Editor>()
6065 .expect("Should open an editor for Rust source file");
6066
6067 cx.executor()
6068 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6069 cx.run_until_parked();
6070 outline_panel.update(cx, |outline_panel, cx| {
6071 assert_eq!(
6072 display_entries(
6073 &project,
6074 &snapshot(outline_panel, cx),
6075 &outline_panel.cached_entries,
6076 outline_panel.selected_entry(),
6077 cx,
6078 ),
6079 indoc!(
6080 "
6081outline: struct OutlineEntryExcerpt
6082 outline: id
6083 outline: buffer_id
6084 outline: range"
6085 )
6086 );
6087 });
6088
6089 cx.update(|window, cx| {
6090 outline_panel.update(cx, |outline_panel, cx| {
6091 outline_panel.select_next(&SelectNext, window, cx);
6092 });
6093 });
6094 cx.executor()
6095 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6096 cx.run_until_parked();
6097 outline_panel.update(cx, |outline_panel, cx| {
6098 assert_eq!(
6099 display_entries(
6100 &project,
6101 &snapshot(outline_panel, cx),
6102 &outline_panel.cached_entries,
6103 outline_panel.selected_entry(),
6104 cx,
6105 ),
6106 indoc!(
6107 "
6108outline: struct OutlineEntryExcerpt <==== selected
6109 outline: id
6110 outline: buffer_id
6111 outline: range"
6112 )
6113 );
6114 });
6115
6116 cx.update(|window, cx| {
6117 outline_panel.update(cx, |outline_panel, cx| {
6118 outline_panel.select_next(&SelectNext, window, cx);
6119 });
6120 });
6121 cx.executor()
6122 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6123 cx.run_until_parked();
6124 outline_panel.update(cx, |outline_panel, cx| {
6125 assert_eq!(
6126 display_entries(
6127 &project,
6128 &snapshot(outline_panel, cx),
6129 &outline_panel.cached_entries,
6130 outline_panel.selected_entry(),
6131 cx,
6132 ),
6133 indoc!(
6134 "
6135outline: struct OutlineEntryExcerpt
6136 outline: id <==== selected
6137 outline: buffer_id
6138 outline: range"
6139 )
6140 );
6141 });
6142
6143 cx.update(|window, cx| {
6144 outline_panel.update(cx, |outline_panel, cx| {
6145 outline_panel.select_next(&SelectNext, window, cx);
6146 });
6147 });
6148 cx.executor()
6149 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6150 cx.run_until_parked();
6151 outline_panel.update(cx, |outline_panel, cx| {
6152 assert_eq!(
6153 display_entries(
6154 &project,
6155 &snapshot(outline_panel, cx),
6156 &outline_panel.cached_entries,
6157 outline_panel.selected_entry(),
6158 cx,
6159 ),
6160 indoc!(
6161 "
6162outline: struct OutlineEntryExcerpt
6163 outline: id
6164 outline: buffer_id <==== selected
6165 outline: range"
6166 )
6167 );
6168 });
6169
6170 cx.update(|window, cx| {
6171 outline_panel.update(cx, |outline_panel, cx| {
6172 outline_panel.select_next(&SelectNext, window, cx);
6173 });
6174 });
6175 cx.executor()
6176 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6177 cx.run_until_parked();
6178 outline_panel.update(cx, |outline_panel, cx| {
6179 assert_eq!(
6180 display_entries(
6181 &project,
6182 &snapshot(outline_panel, cx),
6183 &outline_panel.cached_entries,
6184 outline_panel.selected_entry(),
6185 cx,
6186 ),
6187 indoc!(
6188 "
6189outline: struct OutlineEntryExcerpt
6190 outline: id
6191 outline: buffer_id
6192 outline: range <==== selected"
6193 )
6194 );
6195 });
6196
6197 cx.update(|window, cx| {
6198 outline_panel.update(cx, |outline_panel, cx| {
6199 outline_panel.select_next(&SelectNext, window, cx);
6200 });
6201 });
6202 cx.executor()
6203 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6204 cx.run_until_parked();
6205 outline_panel.update(cx, |outline_panel, cx| {
6206 assert_eq!(
6207 display_entries(
6208 &project,
6209 &snapshot(outline_panel, cx),
6210 &outline_panel.cached_entries,
6211 outline_panel.selected_entry(),
6212 cx,
6213 ),
6214 indoc!(
6215 "
6216outline: struct OutlineEntryExcerpt <==== selected
6217 outline: id
6218 outline: buffer_id
6219 outline: range"
6220 )
6221 );
6222 });
6223
6224 cx.update(|window, cx| {
6225 outline_panel.update(cx, |outline_panel, cx| {
6226 outline_panel.select_previous(&SelectPrevious, window, cx);
6227 });
6228 });
6229 cx.executor()
6230 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6231 cx.run_until_parked();
6232 outline_panel.update(cx, |outline_panel, cx| {
6233 assert_eq!(
6234 display_entries(
6235 &project,
6236 &snapshot(outline_panel, cx),
6237 &outline_panel.cached_entries,
6238 outline_panel.selected_entry(),
6239 cx,
6240 ),
6241 indoc!(
6242 "
6243outline: struct OutlineEntryExcerpt
6244 outline: id
6245 outline: buffer_id
6246 outline: range <==== selected"
6247 )
6248 );
6249 });
6250
6251 cx.update(|window, cx| {
6252 outline_panel.update(cx, |outline_panel, cx| {
6253 outline_panel.select_previous(&SelectPrevious, window, cx);
6254 });
6255 });
6256 cx.executor()
6257 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6258 cx.run_until_parked();
6259 outline_panel.update(cx, |outline_panel, cx| {
6260 assert_eq!(
6261 display_entries(
6262 &project,
6263 &snapshot(outline_panel, cx),
6264 &outline_panel.cached_entries,
6265 outline_panel.selected_entry(),
6266 cx,
6267 ),
6268 indoc!(
6269 "
6270outline: struct OutlineEntryExcerpt
6271 outline: id
6272 outline: buffer_id <==== selected
6273 outline: range"
6274 )
6275 );
6276 });
6277
6278 cx.update(|window, cx| {
6279 outline_panel.update(cx, |outline_panel, cx| {
6280 outline_panel.select_previous(&SelectPrevious, window, cx);
6281 });
6282 });
6283 cx.executor()
6284 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6285 cx.run_until_parked();
6286 outline_panel.update(cx, |outline_panel, cx| {
6287 assert_eq!(
6288 display_entries(
6289 &project,
6290 &snapshot(outline_panel, cx),
6291 &outline_panel.cached_entries,
6292 outline_panel.selected_entry(),
6293 cx,
6294 ),
6295 indoc!(
6296 "
6297outline: struct OutlineEntryExcerpt
6298 outline: id <==== selected
6299 outline: buffer_id
6300 outline: range"
6301 )
6302 );
6303 });
6304
6305 cx.update(|window, cx| {
6306 outline_panel.update(cx, |outline_panel, cx| {
6307 outline_panel.select_previous(&SelectPrevious, window, cx);
6308 });
6309 });
6310 cx.executor()
6311 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6312 cx.run_until_parked();
6313 outline_panel.update(cx, |outline_panel, cx| {
6314 assert_eq!(
6315 display_entries(
6316 &project,
6317 &snapshot(outline_panel, cx),
6318 &outline_panel.cached_entries,
6319 outline_panel.selected_entry(),
6320 cx,
6321 ),
6322 indoc!(
6323 "
6324outline: struct OutlineEntryExcerpt <==== selected
6325 outline: id
6326 outline: buffer_id
6327 outline: range"
6328 )
6329 );
6330 });
6331
6332 cx.update(|window, cx| {
6333 outline_panel.update(cx, |outline_panel, cx| {
6334 outline_panel.select_previous(&SelectPrevious, window, cx);
6335 });
6336 });
6337 cx.executor()
6338 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6339 cx.run_until_parked();
6340 outline_panel.update(cx, |outline_panel, cx| {
6341 assert_eq!(
6342 display_entries(
6343 &project,
6344 &snapshot(outline_panel, cx),
6345 &outline_panel.cached_entries,
6346 outline_panel.selected_entry(),
6347 cx,
6348 ),
6349 indoc!(
6350 "
6351outline: struct OutlineEntryExcerpt
6352 outline: id
6353 outline: buffer_id
6354 outline: range <==== selected"
6355 )
6356 );
6357 });
6358 }
6359
6360 #[gpui::test(iterations = 10)]
6361 async fn test_frontend_repo_structure(cx: &mut TestAppContext) {
6362 init_test(cx);
6363
6364 let root = path!("/frontend-project");
6365 let fs = FakeFs::new(cx.background_executor.clone());
6366 fs.insert_tree(
6367 root,
6368 json!({
6369 "public": {
6370 "lottie": {
6371 "syntax-tree.json": r#"{ "something": "static" }"#
6372 }
6373 },
6374 "src": {
6375 "app": {
6376 "(site)": {
6377 "(about)": {
6378 "jobs": {
6379 "[slug]": {
6380 "page.tsx": r#"static"#
6381 }
6382 }
6383 },
6384 "(blog)": {
6385 "post": {
6386 "[slug]": {
6387 "page.tsx": r#"static"#
6388 }
6389 }
6390 },
6391 }
6392 },
6393 "components": {
6394 "ErrorBoundary.tsx": r#"static"#,
6395 }
6396 }
6397
6398 }),
6399 )
6400 .await;
6401 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
6402 let (window, workspace) = add_outline_panel(&project, cx).await;
6403 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6404 let outline_panel = outline_panel(&workspace, cx);
6405 outline_panel.update_in(cx, |outline_panel, window, cx| {
6406 outline_panel.set_active(true, window, cx)
6407 });
6408
6409 workspace.update_in(cx, |workspace, window, cx| {
6410 ProjectSearchView::deploy_search(
6411 workspace,
6412 &workspace::DeploySearch::default(),
6413 window,
6414 cx,
6415 )
6416 });
6417 let search_view = workspace.update_in(cx, |workspace, _window, cx| {
6418 workspace
6419 .active_pane()
6420 .read(cx)
6421 .items()
6422 .find_map(|item| item.downcast::<ProjectSearchView>())
6423 .expect("Project search view expected to appear after new search event trigger")
6424 });
6425
6426 let query = "static";
6427 perform_project_search(&search_view, query, cx);
6428 search_view.update(cx, |search_view, cx| {
6429 search_view
6430 .results_editor()
6431 .update(cx, |results_editor, cx| {
6432 assert_eq!(
6433 results_editor.display_text(cx).match_indices(query).count(),
6434 4
6435 );
6436 });
6437 });
6438
6439 cx.executor()
6440 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6441 cx.run_until_parked();
6442 outline_panel.update(cx, |outline_panel, cx| {
6443 assert_eq!(
6444 display_entries(
6445 &project,
6446 &snapshot(outline_panel, cx),
6447 &outline_panel.cached_entries,
6448 outline_panel.selected_entry(),
6449 cx,
6450 ),
6451 format!(
6452 r#"frontend-project/
6453 public/lottie/
6454 syntax-tree.json
6455 search: {{ "something": "«static»" }} <==== selected
6456 src/
6457 app/(site)/
6458 (about)/jobs/[slug]/
6459 page.tsx
6460 search: «static»
6461 (blog)/post/[slug]/
6462 page.tsx
6463 search: «static»
6464 components/
6465 ErrorBoundary.tsx
6466 search: «static»"#
6467 )
6468 );
6469 });
6470
6471 outline_panel.update_in(cx, |outline_panel, window, cx| {
6472 // Move to 5th element in the list, 3 items down.
6473 for _ in 0..2 {
6474 outline_panel.select_next(&SelectNext, window, cx);
6475 }
6476 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
6477 });
6478 cx.executor()
6479 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6480 cx.run_until_parked();
6481 outline_panel.update(cx, |outline_panel, cx| {
6482 assert_eq!(
6483 display_entries(
6484 &project,
6485 &snapshot(outline_panel, cx),
6486 &outline_panel.cached_entries,
6487 outline_panel.selected_entry(),
6488 cx,
6489 ),
6490 format!(
6491 r#"frontend-project/
6492 public/lottie/
6493 syntax-tree.json
6494 search: {{ "something": "«static»" }}
6495 src/
6496 app/(site)/ <==== selected
6497 components/
6498 ErrorBoundary.tsx
6499 search: «static»"#
6500 )
6501 );
6502 });
6503
6504 outline_panel.update_in(cx, |outline_panel, window, cx| {
6505 // Move to the next visible non-FS entry
6506 for _ in 0..3 {
6507 outline_panel.select_next(&SelectNext, window, cx);
6508 }
6509 });
6510 cx.run_until_parked();
6511 outline_panel.update(cx, |outline_panel, cx| {
6512 assert_eq!(
6513 display_entries(
6514 &project,
6515 &snapshot(outline_panel, cx),
6516 &outline_panel.cached_entries,
6517 outline_panel.selected_entry(),
6518 cx,
6519 ),
6520 format!(
6521 r#"frontend-project/
6522 public/lottie/
6523 syntax-tree.json
6524 search: {{ "something": "«static»" }}
6525 src/
6526 app/(site)/
6527 components/
6528 ErrorBoundary.tsx
6529 search: «static» <==== selected"#
6530 )
6531 );
6532 });
6533
6534 outline_panel.update_in(cx, |outline_panel, window, cx| {
6535 outline_panel
6536 .active_editor()
6537 .expect("Should have an active editor")
6538 .update(cx, |editor, cx| {
6539 editor.toggle_fold(&editor::actions::ToggleFold, window, cx)
6540 });
6541 });
6542 cx.executor()
6543 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6544 cx.run_until_parked();
6545 outline_panel.update(cx, |outline_panel, cx| {
6546 assert_eq!(
6547 display_entries(
6548 &project,
6549 &snapshot(outline_panel, cx),
6550 &outline_panel.cached_entries,
6551 outline_panel.selected_entry(),
6552 cx,
6553 ),
6554 format!(
6555 r#"frontend-project/
6556 public/lottie/
6557 syntax-tree.json
6558 search: {{ "something": "«static»" }}
6559 src/
6560 app/(site)/
6561 components/
6562 ErrorBoundary.tsx <==== selected"#
6563 )
6564 );
6565 });
6566
6567 outline_panel.update_in(cx, |outline_panel, window, cx| {
6568 outline_panel
6569 .active_editor()
6570 .expect("Should have an active editor")
6571 .update(cx, |editor, cx| {
6572 editor.toggle_fold(&editor::actions::ToggleFold, window, cx)
6573 });
6574 });
6575 cx.executor()
6576 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6577 cx.run_until_parked();
6578 outline_panel.update(cx, |outline_panel, cx| {
6579 assert_eq!(
6580 display_entries(
6581 &project,
6582 &snapshot(outline_panel, cx),
6583 &outline_panel.cached_entries,
6584 outline_panel.selected_entry(),
6585 cx,
6586 ),
6587 format!(
6588 r#"frontend-project/
6589 public/lottie/
6590 syntax-tree.json
6591 search: {{ "something": "«static»" }}
6592 src/
6593 app/(site)/
6594 components/
6595 ErrorBoundary.tsx <==== selected
6596 search: «static»"#
6597 )
6598 );
6599 });
6600
6601 outline_panel.update_in(cx, |outline_panel, window, cx| {
6602 outline_panel.collapse_all_entries(&CollapseAllEntries, window, cx);
6603 });
6604 cx.executor()
6605 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6606 cx.run_until_parked();
6607 outline_panel.update(cx, |outline_panel, cx| {
6608 assert_eq!(
6609 display_entries(
6610 &project,
6611 &snapshot(outline_panel, cx),
6612 &outline_panel.cached_entries,
6613 outline_panel.selected_entry(),
6614 cx,
6615 ),
6616 format!(r#"frontend-project/"#)
6617 );
6618 });
6619
6620 outline_panel.update_in(cx, |outline_panel, window, cx| {
6621 outline_panel.expand_all_entries(&ExpandAllEntries, window, cx);
6622 });
6623 cx.executor()
6624 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6625 cx.run_until_parked();
6626 outline_panel.update(cx, |outline_panel, cx| {
6627 assert_eq!(
6628 display_entries(
6629 &project,
6630 &snapshot(outline_panel, cx),
6631 &outline_panel.cached_entries,
6632 outline_panel.selected_entry(),
6633 cx,
6634 ),
6635 format!(
6636 r#"frontend-project/
6637 public/lottie/
6638 syntax-tree.json
6639 search: {{ "something": "«static»" }}
6640 src/
6641 app/(site)/
6642 (about)/jobs/[slug]/
6643 page.tsx
6644 search: «static»
6645 (blog)/post/[slug]/
6646 page.tsx
6647 search: «static»
6648 components/
6649 ErrorBoundary.tsx <==== selected
6650 search: «static»"#
6651 )
6652 );
6653 });
6654 }
6655
6656 async fn add_outline_panel(
6657 project: &Entity<Project>,
6658 cx: &mut TestAppContext,
6659 ) -> (WindowHandle<MultiWorkspace>, Entity<Workspace>) {
6660 let window =
6661 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6662 let workspace = window
6663 .read_with(cx, |mw, _| mw.workspace().clone())
6664 .unwrap();
6665
6666 let workspace_weak = workspace.downgrade();
6667 let outline_panel = window
6668 .update(cx, |_, window, cx| {
6669 cx.spawn_in(window, async move |_this, cx| {
6670 OutlinePanel::load(workspace_weak, cx.clone()).await
6671 })
6672 })
6673 .unwrap()
6674 .await
6675 .expect("Failed to load outline panel");
6676
6677 window
6678 .update(cx, |multi_workspace, window, cx| {
6679 multi_workspace.workspace().update(cx, |workspace, cx| {
6680 workspace.add_panel(outline_panel, window, cx);
6681 });
6682 })
6683 .unwrap();
6684 (window, workspace)
6685 }
6686
6687 fn outline_panel(
6688 workspace: &Entity<Workspace>,
6689 cx: &mut VisualTestContext,
6690 ) -> Entity<OutlinePanel> {
6691 workspace.update_in(cx, |workspace, _window, cx| {
6692 workspace
6693 .panel::<OutlinePanel>(cx)
6694 .expect("no outline panel")
6695 })
6696 }
6697
6698 fn display_entries(
6699 project: &Entity<Project>,
6700 multi_buffer_snapshot: &MultiBufferSnapshot,
6701 cached_entries: &[CachedEntry],
6702 selected_entry: Option<&PanelEntry>,
6703 cx: &mut App,
6704 ) -> String {
6705 let project = project.read(cx);
6706 let mut display_string = String::new();
6707 for entry in cached_entries {
6708 if !display_string.is_empty() {
6709 display_string += "\n";
6710 }
6711 for _ in 0..entry.depth {
6712 display_string += " ";
6713 }
6714 display_string += &match &entry.entry {
6715 PanelEntry::Fs(entry) => match entry {
6716 FsEntry::ExternalFile(_) => {
6717 panic!("Did not cover external files with tests")
6718 }
6719 FsEntry::Directory(directory) => {
6720 let path = if let Some(worktree) = project
6721 .worktree_for_id(directory.worktree_id, cx)
6722 .filter(|worktree| {
6723 worktree.read(cx).root_entry() == Some(&directory.entry.entry)
6724 }) {
6725 worktree
6726 .read(cx)
6727 .root_name()
6728 .join(&directory.entry.path)
6729 .as_unix_str()
6730 .to_string()
6731 } else {
6732 directory
6733 .entry
6734 .path
6735 .file_name()
6736 .unwrap_or_default()
6737 .to_string()
6738 };
6739 format!("{path}/")
6740 }
6741 FsEntry::File(file) => file
6742 .entry
6743 .path
6744 .file_name()
6745 .map(|name| name.to_string())
6746 .unwrap_or_default(),
6747 },
6748 PanelEntry::FoldedDirs(folded_dirs) => folded_dirs
6749 .entries
6750 .iter()
6751 .filter_map(|dir| dir.path.file_name())
6752 .map(|name| name.to_string() + "/")
6753 .collect(),
6754 PanelEntry::Outline(outline_entry) => match outline_entry {
6755 OutlineEntry::Excerpt(_) => continue,
6756 OutlineEntry::Outline(outline_entry) => {
6757 format!("outline: {}", outline_entry.text)
6758 }
6759 },
6760 PanelEntry::Search(search_entry) => {
6761 let search_data = search_entry.render_data.get_or_init(|| {
6762 SearchData::new(&search_entry.match_range, multi_buffer_snapshot)
6763 });
6764 let mut search_result = String::new();
6765 let mut last_end = 0;
6766 for range in &search_data.search_match_indices {
6767 search_result.push_str(&search_data.context_text[last_end..range.start]);
6768 search_result.push('«');
6769 search_result.push_str(&search_data.context_text[range.start..range.end]);
6770 search_result.push('»');
6771 last_end = range.end;
6772 }
6773 search_result.push_str(&search_data.context_text[last_end..]);
6774
6775 format!("search: {search_result}")
6776 }
6777 };
6778
6779 if Some(&entry.entry) == selected_entry {
6780 display_string += SELECTED_MARKER;
6781 }
6782 }
6783 display_string
6784 }
6785
6786 fn init_test(cx: &mut TestAppContext) {
6787 cx.update(|cx| {
6788 let settings = SettingsStore::test(cx);
6789 cx.set_global(settings);
6790
6791 theme_settings::init(theme::LoadThemes::JustBase, cx);
6792
6793 editor::init(cx);
6794 project_search::init(cx);
6795 buffer_search::init(cx);
6796 super::init(cx);
6797 });
6798 }
6799
6800 // Based on https://github.com/rust-lang/rust-analyzer/
6801 async fn populate_with_test_ra_project(fs: &FakeFs, root: &str) {
6802 fs.insert_tree(
6803 root,
6804 json!({
6805 "crates": {
6806 "ide": {
6807 "src": {
6808 "inlay_hints": {
6809 "fn_lifetime_fn.rs": r##"
6810 pub(super) fn hints(
6811 acc: &mut Vec<InlayHint>,
6812 config: &InlayHintsConfig,
6813 func: ast::Fn,
6814 ) -> Option<()> {
6815 // ... snip
6816
6817 let mut used_names: FxHashMap<SmolStr, usize> =
6818 match config.param_names_for_lifetime_elision_hints {
6819 true => generic_param_list
6820 .iter()
6821 .flat_map(|gpl| gpl.lifetime_params())
6822 .filter_map(|param| param.lifetime())
6823 .filter_map(|lt| Some((SmolStr::from(lt.text().as_str().get(1..)?), 0)))
6824 .collect(),
6825 false => Default::default(),
6826 };
6827 {
6828 let mut potential_lt_refs = potential_lt_refs.iter().filter(|&&(.., is_elided)| is_elided);
6829 if self_param.is_some() && potential_lt_refs.next().is_some() {
6830 allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
6831 // self can't be used as a lifetime, so no need to check for collisions
6832 "'self".into()
6833 } else {
6834 gen_idx_name()
6835 });
6836 }
6837 potential_lt_refs.for_each(|(name, ..)| {
6838 let name = match name {
6839 Some(it) if config.param_names_for_lifetime_elision_hints => {
6840 if let Some(c) = used_names.get_mut(it.text().as_str()) {
6841 *c += 1;
6842 SmolStr::from(format!("'{text}{c}", text = it.text().as_str()))
6843 } else {
6844 used_names.insert(it.text().as_str().into(), 0);
6845 SmolStr::from_iter(["\'", it.text().as_str()])
6846 }
6847 }
6848 _ => gen_idx_name(),
6849 };
6850 allocated_lifetimes.push(name);
6851 });
6852 }
6853
6854 // ... snip
6855 }
6856
6857 // ... snip
6858
6859 #[test]
6860 fn hints_lifetimes_named() {
6861 check_with_config(
6862 InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
6863 r#"
6864 fn nested_in<'named>(named: & &X< &()>) {}
6865 // ^'named1, 'named2, 'named3, $
6866 //^'named1 ^'named2 ^'named3
6867 "#,
6868 );
6869 }
6870
6871 // ... snip
6872 "##,
6873 },
6874 "inlay_hints.rs": r#"
6875 #[derive(Clone, Debug, PartialEq, Eq)]
6876 pub struct InlayHintsConfig {
6877 // ... snip
6878 pub param_names_for_lifetime_elision_hints: bool,
6879 pub max_length: Option<usize>,
6880 // ... snip
6881 }
6882
6883 impl Config {
6884 pub fn inlay_hints(&self) -> InlayHintsConfig {
6885 InlayHintsConfig {
6886 // ... snip
6887 param_names_for_lifetime_elision_hints: self
6888 .inlayHints_lifetimeElisionHints_useParameterNames()
6889 .to_owned(),
6890 max_length: self.inlayHints_maxLength().to_owned(),
6891 // ... snip
6892 }
6893 }
6894 }
6895 "#,
6896 "static_index.rs": r#"
6897// ... snip
6898 fn add_file(&mut self, file_id: FileId) {
6899 let current_crate = crates_for(self.db, file_id).pop().map(Into::into);
6900 let folds = self.analysis.folding_ranges(file_id).unwrap();
6901 let inlay_hints = self
6902 .analysis
6903 .inlay_hints(
6904 &InlayHintsConfig {
6905 // ... snip
6906 closure_style: hir::ClosureStyle::ImplFn,
6907 param_names_for_lifetime_elision_hints: false,
6908 binding_mode_hints: false,
6909 max_length: Some(25),
6910 closure_capture_hints: false,
6911 // ... snip
6912 },
6913 file_id,
6914 None,
6915 )
6916 .unwrap();
6917 // ... snip
6918 }
6919// ... snip
6920 "#
6921 }
6922 },
6923 "rust-analyzer": {
6924 "src": {
6925 "cli": {
6926 "analysis_stats.rs": r#"
6927 // ... snip
6928 for &file_id in &file_ids {
6929 _ = analysis.inlay_hints(
6930 &InlayHintsConfig {
6931 // ... snip
6932 implicit_drop_hints: true,
6933 lifetime_elision_hints: ide::LifetimeElisionHints::Always,
6934 param_names_for_lifetime_elision_hints: true,
6935 hide_named_constructor_hints: false,
6936 hide_closure_initialization_hints: false,
6937 closure_style: hir::ClosureStyle::ImplFn,
6938 max_length: Some(25),
6939 closing_brace_hints_min_lines: Some(20),
6940 fields_to_resolve: InlayFieldsToResolve::empty(),
6941 range_exclusive_hints: true,
6942 },
6943 file_id.into(),
6944 None,
6945 );
6946 }
6947 // ... snip
6948 "#,
6949 },
6950 "config.rs": r#"
6951 config_data! {
6952 /// Configs that only make sense when they are set by a client. As such they can only be defined
6953 /// by setting them using client's settings (e.g `settings.json` on VS Code).
6954 client: struct ClientDefaultConfigData <- ClientConfigInput -> {
6955 // ... snip
6956 /// Maximum length for inlay hints. Set to null to have an unlimited length.
6957 inlayHints_maxLength: Option<usize> = Some(25),
6958 // ... snip
6959 /// Whether to prefer using parameter names as the name for elided lifetime hints if possible.
6960 inlayHints_lifetimeElisionHints_useParameterNames: bool = false,
6961 // ... snip
6962 }
6963 }
6964
6965 impl Config {
6966 // ... snip
6967 pub fn inlay_hints(&self) -> InlayHintsConfig {
6968 InlayHintsConfig {
6969 // ... snip
6970 param_names_for_lifetime_elision_hints: self
6971 .inlayHints_lifetimeElisionHints_useParameterNames()
6972 .to_owned(),
6973 max_length: self.inlayHints_maxLength().to_owned(),
6974 // ... snip
6975 }
6976 }
6977 // ... snip
6978 }
6979 "#
6980 }
6981 }
6982 }
6983 }),
6984 )
6985 .await;
6986 }
6987
6988 fn snapshot(outline_panel: &OutlinePanel, cx: &App) -> MultiBufferSnapshot {
6989 outline_panel
6990 .active_editor()
6991 .unwrap()
6992 .read(cx)
6993 .buffer()
6994 .read(cx)
6995 .snapshot(cx)
6996 }
6997
6998 fn selected_row_text(editor: &Entity<Editor>, cx: &mut App) -> String {
6999 editor.update(cx, |editor, cx| {
7000 let selections = editor.selections.all::<language::Point>(&editor.display_snapshot(cx));
7001 assert_eq!(selections.len(), 1, "Active editor should have exactly one selection after any outline panel interactions");
7002 let selection = selections.first().unwrap();
7003 let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
7004 let line_start = language::Point::new(selection.start.row, 0);
7005 let line_end = multi_buffer_snapshot.clip_point(language::Point::new(selection.end.row, u32::MAX), language::Bias::Right);
7006 multi_buffer_snapshot.text_for_range(line_start..line_end).collect::<String>().trim().to_owned()
7007 })
7008 }
7009
7010 #[gpui::test]
7011 async fn test_outline_keyboard_expand_collapse(cx: &mut TestAppContext) {
7012 init_test(cx);
7013
7014 let fs = FakeFs::new(cx.background_executor.clone());
7015 fs.insert_tree(
7016 "/test",
7017 json!({
7018 "src": {
7019 "lib.rs": indoc!("
7020 mod outer {
7021 pub struct OuterStruct {
7022 field: String,
7023 }
7024 impl OuterStruct {
7025 pub fn new() -> Self {
7026 Self { field: String::new() }
7027 }
7028 pub fn method(&self) {
7029 println!(\"{}\", self.field);
7030 }
7031 }
7032 mod inner {
7033 pub fn inner_function() {
7034 let x = 42;
7035 println!(\"{}\", x);
7036 }
7037 pub struct InnerStruct {
7038 value: i32,
7039 }
7040 }
7041 }
7042 fn main() {
7043 let s = outer::OuterStruct::new();
7044 s.method();
7045 }
7046 "),
7047 }
7048 }),
7049 )
7050 .await;
7051
7052 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7053 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
7054 let (window, workspace) = add_outline_panel(&project, cx).await;
7055 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7056 let outline_panel = outline_panel(&workspace, cx);
7057
7058 outline_panel.update_in(cx, |outline_panel, window, cx| {
7059 outline_panel.set_active(true, window, cx)
7060 });
7061
7062 workspace
7063 .update_in(cx, |workspace, window, cx| {
7064 workspace.open_abs_path(
7065 PathBuf::from("/test/src/lib.rs"),
7066 OpenOptions {
7067 visible: Some(OpenVisible::All),
7068 ..Default::default()
7069 },
7070 window,
7071 cx,
7072 )
7073 })
7074 .await
7075 .unwrap();
7076
7077 cx.executor()
7078 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7079 cx.run_until_parked();
7080
7081 // Force another update cycle to ensure outlines are fetched
7082 outline_panel.update_in(cx, |panel, window, cx| {
7083 panel.update_non_fs_items(window, cx);
7084 panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
7085 });
7086 cx.executor()
7087 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7088 cx.run_until_parked();
7089
7090 outline_panel.update(cx, |outline_panel, cx| {
7091 assert_eq!(
7092 display_entries(
7093 &project,
7094 &snapshot(outline_panel, cx),
7095 &outline_panel.cached_entries,
7096 outline_panel.selected_entry(),
7097 cx,
7098 ),
7099 indoc!(
7100 "
7101outline: mod outer <==== selected
7102 outline: pub struct OuterStruct
7103 outline: field
7104 outline: impl OuterStruct
7105 outline: pub fn new
7106 outline: pub fn method
7107 outline: mod inner
7108 outline: pub fn inner_function
7109 outline: pub struct InnerStruct
7110 outline: value
7111outline: fn main"
7112 )
7113 );
7114 });
7115
7116 let parent_outline = outline_panel
7117 .read_with(cx, |panel, _cx| {
7118 panel
7119 .cached_entries
7120 .iter()
7121 .find_map(|entry| match &entry.entry {
7122 PanelEntry::Outline(OutlineEntry::Outline(outline))
7123 if panel
7124 .outline_children_cache
7125 .get(&outline.range.start.buffer_id)
7126 .and_then(|children_map| {
7127 let key = (outline.range.clone(), outline.depth);
7128 children_map.get(&key)
7129 })
7130 .copied()
7131 .unwrap_or(false) =>
7132 {
7133 Some(entry.entry.clone())
7134 }
7135 _ => None,
7136 })
7137 })
7138 .expect("Should find an outline with children");
7139
7140 outline_panel.update_in(cx, |panel, window, cx| {
7141 panel.select_entry(parent_outline.clone(), true, window, cx);
7142 panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
7143 });
7144 cx.executor()
7145 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7146 cx.run_until_parked();
7147
7148 outline_panel.update(cx, |outline_panel, cx| {
7149 assert_eq!(
7150 display_entries(
7151 &project,
7152 &snapshot(outline_panel, cx),
7153 &outline_panel.cached_entries,
7154 outline_panel.selected_entry(),
7155 cx,
7156 ),
7157 indoc!(
7158 "
7159outline: mod outer <==== selected
7160outline: fn main"
7161 )
7162 );
7163 });
7164
7165 outline_panel.update_in(cx, |panel, window, cx| {
7166 panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
7167 });
7168 cx.executor()
7169 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7170 cx.run_until_parked();
7171
7172 outline_panel.update(cx, |outline_panel, cx| {
7173 assert_eq!(
7174 display_entries(
7175 &project,
7176 &snapshot(outline_panel, cx),
7177 &outline_panel.cached_entries,
7178 outline_panel.selected_entry(),
7179 cx,
7180 ),
7181 indoc!(
7182 "
7183outline: mod outer <==== selected
7184 outline: pub struct OuterStruct
7185 outline: field
7186 outline: impl OuterStruct
7187 outline: pub fn new
7188 outline: pub fn method
7189 outline: mod inner
7190 outline: pub fn inner_function
7191 outline: pub struct InnerStruct
7192 outline: value
7193outline: fn main"
7194 )
7195 );
7196 });
7197
7198 outline_panel.update_in(cx, |panel, window, cx| {
7199 panel.collapsed_entries.clear();
7200 panel.update_cached_entries(None, window, cx);
7201 });
7202 cx.executor()
7203 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7204 cx.run_until_parked();
7205
7206 outline_panel.update_in(cx, |panel, window, cx| {
7207 let outlines_with_children: Vec<_> = panel
7208 .cached_entries
7209 .iter()
7210 .filter_map(|entry| match &entry.entry {
7211 PanelEntry::Outline(OutlineEntry::Outline(outline))
7212 if panel
7213 .outline_children_cache
7214 .get(&outline.range.start.buffer_id)
7215 .and_then(|children_map| {
7216 let key = (outline.range.clone(), outline.depth);
7217 children_map.get(&key)
7218 })
7219 .copied()
7220 .unwrap_or(false) =>
7221 {
7222 Some(entry.entry.clone())
7223 }
7224 _ => None,
7225 })
7226 .collect();
7227
7228 for outline in outlines_with_children {
7229 panel.select_entry(outline, false, window, cx);
7230 panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
7231 }
7232 });
7233 cx.executor()
7234 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7235 cx.run_until_parked();
7236
7237 outline_panel.update(cx, |outline_panel, cx| {
7238 assert_eq!(
7239 display_entries(
7240 &project,
7241 &snapshot(outline_panel, cx),
7242 &outline_panel.cached_entries,
7243 outline_panel.selected_entry(),
7244 cx,
7245 ),
7246 indoc!(
7247 "
7248outline: mod outer
7249outline: fn main"
7250 )
7251 );
7252 });
7253
7254 let collapsed_entries_count =
7255 outline_panel.read_with(cx, |panel, _| panel.collapsed_entries.len());
7256 assert!(
7257 collapsed_entries_count > 0,
7258 "Should have collapsed entries tracked"
7259 );
7260 }
7261
7262 #[gpui::test]
7263 async fn test_outline_click_toggle_behavior(cx: &mut TestAppContext) {
7264 init_test(cx);
7265
7266 let fs = FakeFs::new(cx.background_executor.clone());
7267 fs.insert_tree(
7268 "/test",
7269 json!({
7270 "src": {
7271 "main.rs": indoc!("
7272 struct Config {
7273 name: String,
7274 value: i32,
7275 }
7276 impl Config {
7277 fn new(name: String) -> Self {
7278 Self { name, value: 0 }
7279 }
7280 fn get_value(&self) -> i32 {
7281 self.value
7282 }
7283 }
7284 enum Status {
7285 Active,
7286 Inactive,
7287 }
7288 fn process_config(config: Config) -> Status {
7289 if config.get_value() > 0 {
7290 Status::Active
7291 } else {
7292 Status::Inactive
7293 }
7294 }
7295 fn main() {
7296 let config = Config::new(\"test\".to_string());
7297 let status = process_config(config);
7298 }
7299 "),
7300 }
7301 }),
7302 )
7303 .await;
7304
7305 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7306 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
7307
7308 let (window, workspace) = add_outline_panel(&project, cx).await;
7309 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7310 let outline_panel = outline_panel(&workspace, cx);
7311
7312 outline_panel.update_in(cx, |outline_panel, window, cx| {
7313 outline_panel.set_active(true, window, cx)
7314 });
7315
7316 let _editor = workspace
7317 .update_in(cx, |workspace, window, cx| {
7318 workspace.open_abs_path(
7319 PathBuf::from("/test/src/main.rs"),
7320 OpenOptions {
7321 visible: Some(OpenVisible::All),
7322 ..Default::default()
7323 },
7324 window,
7325 cx,
7326 )
7327 })
7328 .await
7329 .unwrap();
7330
7331 cx.executor()
7332 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7333 cx.run_until_parked();
7334
7335 outline_panel.update(cx, |outline_panel, _cx| {
7336 outline_panel.selected_entry = SelectedEntry::None;
7337 });
7338
7339 // Check initial state - all entries should be expanded by default
7340 outline_panel.update(cx, |outline_panel, cx| {
7341 assert_eq!(
7342 display_entries(
7343 &project,
7344 &snapshot(outline_panel, cx),
7345 &outline_panel.cached_entries,
7346 outline_panel.selected_entry(),
7347 cx,
7348 ),
7349 indoc!(
7350 "
7351outline: struct Config
7352 outline: name
7353 outline: value
7354outline: impl Config
7355 outline: fn new
7356 outline: fn get_value
7357outline: enum Status
7358 outline: Active
7359 outline: Inactive
7360outline: fn process_config
7361outline: fn main"
7362 )
7363 );
7364 });
7365
7366 outline_panel.update(cx, |outline_panel, _cx| {
7367 outline_panel.selected_entry = SelectedEntry::None;
7368 });
7369
7370 cx.update(|window, cx| {
7371 outline_panel.update(cx, |outline_panel, cx| {
7372 outline_panel.select_first(&SelectFirst, window, cx);
7373 });
7374 });
7375
7376 cx.executor()
7377 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7378 cx.run_until_parked();
7379
7380 outline_panel.update(cx, |outline_panel, cx| {
7381 assert_eq!(
7382 display_entries(
7383 &project,
7384 &snapshot(outline_panel, cx),
7385 &outline_panel.cached_entries,
7386 outline_panel.selected_entry(),
7387 cx,
7388 ),
7389 indoc!(
7390 "
7391outline: struct Config <==== selected
7392 outline: name
7393 outline: value
7394outline: impl Config
7395 outline: fn new
7396 outline: fn get_value
7397outline: enum Status
7398 outline: Active
7399 outline: Inactive
7400outline: fn process_config
7401outline: fn main"
7402 )
7403 );
7404 });
7405
7406 cx.update(|window, cx| {
7407 outline_panel.update(cx, |outline_panel, cx| {
7408 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
7409 });
7410 });
7411
7412 cx.executor()
7413 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7414 cx.run_until_parked();
7415
7416 outline_panel.update(cx, |outline_panel, cx| {
7417 assert_eq!(
7418 display_entries(
7419 &project,
7420 &snapshot(outline_panel, cx),
7421 &outline_panel.cached_entries,
7422 outline_panel.selected_entry(),
7423 cx,
7424 ),
7425 indoc!(
7426 "
7427outline: struct Config <==== selected
7428outline: impl Config
7429 outline: fn new
7430 outline: fn get_value
7431outline: enum Status
7432 outline: Active
7433 outline: Inactive
7434outline: fn process_config
7435outline: fn main"
7436 )
7437 );
7438 });
7439
7440 cx.update(|window, cx| {
7441 outline_panel.update(cx, |outline_panel, cx| {
7442 outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
7443 });
7444 });
7445
7446 cx.executor()
7447 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7448 cx.run_until_parked();
7449
7450 outline_panel.update(cx, |outline_panel, cx| {
7451 assert_eq!(
7452 display_entries(
7453 &project,
7454 &snapshot(outline_panel, cx),
7455 &outline_panel.cached_entries,
7456 outline_panel.selected_entry(),
7457 cx,
7458 ),
7459 indoc!(
7460 "
7461outline: struct Config <==== selected
7462 outline: name
7463 outline: value
7464outline: impl Config
7465 outline: fn new
7466 outline: fn get_value
7467outline: enum Status
7468 outline: Active
7469 outline: Inactive
7470outline: fn process_config
7471outline: fn main"
7472 )
7473 );
7474 });
7475 }
7476
7477 #[gpui::test]
7478 async fn test_outline_expand_collapse_all(cx: &mut TestAppContext) {
7479 init_test(cx);
7480
7481 let fs = FakeFs::new(cx.background_executor.clone());
7482 fs.insert_tree(
7483 "/test",
7484 json!({
7485 "src": {
7486 "lib.rs": indoc!("
7487 mod outer {
7488 pub struct OuterStruct {
7489 field: String,
7490 }
7491 impl OuterStruct {
7492 pub fn new() -> Self {
7493 Self { field: String::new() }
7494 }
7495 pub fn method(&self) {
7496 println!(\"{}\", self.field);
7497 }
7498 }
7499 mod inner {
7500 pub fn inner_function() {
7501 let x = 42;
7502 println!(\"{}\", x);
7503 }
7504 pub struct InnerStruct {
7505 value: i32,
7506 }
7507 }
7508 }
7509 fn main() {
7510 let s = outer::OuterStruct::new();
7511 s.method();
7512 }
7513 "),
7514 }
7515 }),
7516 )
7517 .await;
7518
7519 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7520 project.read_with(cx, |project, _| project.languages().add(rust_lang()));
7521 let (window, workspace) = add_outline_panel(&project, cx).await;
7522 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7523 let outline_panel = outline_panel(&workspace, cx);
7524
7525 outline_panel.update_in(cx, |outline_panel, window, cx| {
7526 outline_panel.set_active(true, window, cx)
7527 });
7528
7529 workspace
7530 .update_in(cx, |workspace, window, cx| {
7531 workspace.open_abs_path(
7532 PathBuf::from("/test/src/lib.rs"),
7533 OpenOptions {
7534 visible: Some(OpenVisible::All),
7535 ..Default::default()
7536 },
7537 window,
7538 cx,
7539 )
7540 })
7541 .await
7542 .unwrap();
7543
7544 cx.executor()
7545 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7546 cx.run_until_parked();
7547
7548 // Force another update cycle to ensure outlines are fetched
7549 outline_panel.update_in(cx, |panel, window, cx| {
7550 panel.update_non_fs_items(window, cx);
7551 panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
7552 });
7553 cx.executor()
7554 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7555 cx.run_until_parked();
7556
7557 outline_panel.update(cx, |outline_panel, cx| {
7558 assert_eq!(
7559 display_entries(
7560 &project,
7561 &snapshot(outline_panel, cx),
7562 &outline_panel.cached_entries,
7563 outline_panel.selected_entry(),
7564 cx,
7565 ),
7566 indoc!(
7567 "
7568outline: mod outer <==== selected
7569 outline: pub struct OuterStruct
7570 outline: field
7571 outline: impl OuterStruct
7572 outline: pub fn new
7573 outline: pub fn method
7574 outline: mod inner
7575 outline: pub fn inner_function
7576 outline: pub struct InnerStruct
7577 outline: value
7578outline: fn main"
7579 )
7580 );
7581 });
7582
7583 let _parent_outline = outline_panel
7584 .read_with(cx, |panel, _cx| {
7585 panel
7586 .cached_entries
7587 .iter()
7588 .find_map(|entry| match &entry.entry {
7589 PanelEntry::Outline(OutlineEntry::Outline(outline))
7590 if panel
7591 .outline_children_cache
7592 .get(&outline.range.start.buffer_id)
7593 .and_then(|children_map| {
7594 let key = (outline.range.clone(), outline.depth);
7595 children_map.get(&key)
7596 })
7597 .copied()
7598 .unwrap_or(false) =>
7599 {
7600 Some(entry.entry.clone())
7601 }
7602 _ => None,
7603 })
7604 })
7605 .expect("Should find an outline with children");
7606
7607 // Collapse all entries
7608 outline_panel.update_in(cx, |panel, window, cx| {
7609 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
7610 });
7611 cx.executor()
7612 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7613 cx.run_until_parked();
7614
7615 let expected_collapsed_output = indoc!(
7616 "
7617 outline: mod outer <==== selected
7618 outline: fn main"
7619 );
7620
7621 outline_panel.update(cx, |panel, cx| {
7622 assert_eq! {
7623 display_entries(
7624 &project,
7625 &snapshot(panel, cx),
7626 &panel.cached_entries,
7627 panel.selected_entry(),
7628 cx,
7629 ),
7630 expected_collapsed_output
7631 };
7632 });
7633
7634 // Expand all entries
7635 outline_panel.update_in(cx, |panel, window, cx| {
7636 panel.expand_all_entries(&ExpandAllEntries, window, cx);
7637 });
7638 cx.executor()
7639 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7640 cx.run_until_parked();
7641
7642 let expected_expanded_output = indoc!(
7643 "
7644 outline: mod outer <==== selected
7645 outline: pub struct OuterStruct
7646 outline: field
7647 outline: impl OuterStruct
7648 outline: pub fn new
7649 outline: pub fn method
7650 outline: mod inner
7651 outline: pub fn inner_function
7652 outline: pub struct InnerStruct
7653 outline: value
7654 outline: fn main"
7655 );
7656
7657 outline_panel.update(cx, |panel, cx| {
7658 assert_eq! {
7659 display_entries(
7660 &project,
7661 &snapshot(panel, cx),
7662 &panel.cached_entries,
7663 panel.selected_entry(),
7664 cx,
7665 ),
7666 expected_expanded_output
7667 };
7668 });
7669 }
7670
7671 #[gpui::test]
7672 async fn test_buffer_search(cx: &mut TestAppContext) {
7673 init_test(cx);
7674
7675 let fs = FakeFs::new(cx.background_executor.clone());
7676 fs.insert_tree(
7677 "/test",
7678 json!({
7679 "foo.txt": r#"<_constitution>
7680
7681</_constitution>
7682
7683
7684
7685## 📊 Output
7686
7687| Field | Meaning |
7688"#
7689 }),
7690 )
7691 .await;
7692
7693 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7694 let (window, workspace) = add_outline_panel(&project, cx).await;
7695 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7696
7697 let editor = workspace
7698 .update_in(cx, |workspace, window, cx| {
7699 workspace.open_abs_path(
7700 PathBuf::from("/test/foo.txt"),
7701 OpenOptions {
7702 visible: Some(OpenVisible::All),
7703 ..OpenOptions::default()
7704 },
7705 window,
7706 cx,
7707 )
7708 })
7709 .await
7710 .unwrap()
7711 .downcast::<Editor>()
7712 .unwrap();
7713
7714 let search_bar = workspace.update_in(cx, |_, window, cx| {
7715 cx.new(|cx| {
7716 let mut search_bar = BufferSearchBar::new(None, window, cx);
7717 search_bar.set_active_pane_item(Some(&editor), window, cx);
7718 search_bar.show(window, cx);
7719 search_bar
7720 })
7721 });
7722
7723 let outline_panel = outline_panel(&workspace, cx);
7724
7725 outline_panel.update_in(cx, |outline_panel, window, cx| {
7726 outline_panel.set_active(true, window, cx)
7727 });
7728
7729 search_bar
7730 .update_in(cx, |search_bar, window, cx| {
7731 search_bar.search(" ", None, true, window, cx)
7732 })
7733 .await
7734 .unwrap();
7735
7736 cx.executor()
7737 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7738 cx.run_until_parked();
7739
7740 outline_panel.update(cx, |outline_panel, cx| {
7741 assert_eq!(
7742 display_entries(
7743 &project,
7744 &snapshot(outline_panel, cx),
7745 &outline_panel.cached_entries,
7746 outline_panel.selected_entry(),
7747 cx,
7748 ),
7749 "search: | Field« » | Meaning | <==== selected
7750search: | Field « » | Meaning |
7751search: | Field « » | Meaning |
7752search: | Field « » | Meaning |
7753search: | Field « »| Meaning |
7754search: | Field | Meaning« » |
7755search: | Field | Meaning « » |
7756search: | Field | Meaning « » |
7757search: | Field | Meaning « » |
7758search: | Field | Meaning « » |
7759search: | Field | Meaning « » |
7760search: | Field | Meaning « » |
7761search: | Field | Meaning « »|"
7762 );
7763 });
7764 }
7765
7766 #[gpui::test]
7767 async fn test_outline_panel_lsp_document_symbols(cx: &mut TestAppContext) {
7768 init_test(cx);
7769
7770 let root = path!("/root");
7771 let fs = FakeFs::new(cx.background_executor.clone());
7772 fs.insert_tree(
7773 root,
7774 json!({
7775 "src": {
7776 "lib.rs": "struct Foo {\n bar: u32,\n baz: String,\n}\n",
7777 }
7778 }),
7779 )
7780 .await;
7781
7782 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
7783 let language_registry = project.read_with(cx, |project, _| {
7784 project.languages().add(rust_lang());
7785 project.languages().clone()
7786 });
7787
7788 let mut fake_language_servers = language_registry.register_fake_lsp(
7789 "Rust",
7790 FakeLspAdapter {
7791 capabilities: lsp::ServerCapabilities {
7792 document_symbol_provider: Some(lsp::OneOf::Left(true)),
7793 ..lsp::ServerCapabilities::default()
7794 },
7795 initializer: Some(Box::new(|fake_language_server| {
7796 fake_language_server
7797 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
7798 move |_, _| async move {
7799 #[allow(deprecated)]
7800 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
7801 lsp::DocumentSymbol {
7802 name: "Foo".to_string(),
7803 detail: None,
7804 kind: lsp::SymbolKind::STRUCT,
7805 tags: None,
7806 deprecated: None,
7807 range: lsp::Range::new(
7808 lsp::Position::new(0, 0),
7809 lsp::Position::new(3, 1),
7810 ),
7811 selection_range: lsp::Range::new(
7812 lsp::Position::new(0, 7),
7813 lsp::Position::new(0, 10),
7814 ),
7815 children: Some(vec![
7816 lsp::DocumentSymbol {
7817 name: "bar".to_string(),
7818 detail: None,
7819 kind: lsp::SymbolKind::FIELD,
7820 tags: None,
7821 deprecated: None,
7822 range: lsp::Range::new(
7823 lsp::Position::new(1, 4),
7824 lsp::Position::new(1, 13),
7825 ),
7826 selection_range: lsp::Range::new(
7827 lsp::Position::new(1, 4),
7828 lsp::Position::new(1, 7),
7829 ),
7830 children: None,
7831 },
7832 lsp::DocumentSymbol {
7833 name: "lsp_only_field".to_string(),
7834 detail: None,
7835 kind: lsp::SymbolKind::FIELD,
7836 tags: None,
7837 deprecated: None,
7838 range: lsp::Range::new(
7839 lsp::Position::new(2, 4),
7840 lsp::Position::new(2, 15),
7841 ),
7842 selection_range: lsp::Range::new(
7843 lsp::Position::new(2, 4),
7844 lsp::Position::new(2, 7),
7845 ),
7846 children: None,
7847 },
7848 ]),
7849 },
7850 ])))
7851 },
7852 );
7853 })),
7854 ..FakeLspAdapter::default()
7855 },
7856 );
7857
7858 let (window, workspace) = add_outline_panel(&project, cx).await;
7859 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7860 let outline_panel = outline_panel(&workspace, cx);
7861 cx.update(|window, cx| {
7862 outline_panel.update(cx, |outline_panel, cx| {
7863 outline_panel.set_active(true, window, cx)
7864 });
7865 });
7866
7867 let _editor = workspace
7868 .update_in(cx, |workspace, window, cx| {
7869 workspace.open_abs_path(
7870 PathBuf::from(path!("/root/src/lib.rs")),
7871 OpenOptions {
7872 visible: Some(OpenVisible::All),
7873 ..OpenOptions::default()
7874 },
7875 window,
7876 cx,
7877 )
7878 })
7879 .await
7880 .expect("Failed to open Rust source file")
7881 .downcast::<Editor>()
7882 .expect("Should open an editor for Rust source file");
7883 let _fake_language_server = fake_language_servers.next().await.unwrap();
7884 cx.executor()
7885 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7886 cx.run_until_parked();
7887
7888 // Step 1: tree-sitter outlines by default
7889 outline_panel.update(cx, |outline_panel, cx| {
7890 assert_eq!(
7891 display_entries(
7892 &project,
7893 &snapshot(outline_panel, cx),
7894 &outline_panel.cached_entries,
7895 outline_panel.selected_entry(),
7896 cx,
7897 ),
7898 indoc!(
7899 "
7900outline: struct Foo <==== selected
7901 outline: bar
7902 outline: baz"
7903 ),
7904 "Step 1: tree-sitter outlines should be displayed by default"
7905 );
7906 });
7907
7908 // Step 2: Switch to LSP document symbols
7909 cx.update(|_, cx| {
7910 settings::SettingsStore::update_global(
7911 cx,
7912 |store: &mut settings::SettingsStore, cx| {
7913 store.update_user_settings(cx, |settings| {
7914 settings.project.all_languages.defaults.document_symbols =
7915 Some(settings::DocumentSymbols::On);
7916 });
7917 },
7918 );
7919 });
7920 cx.executor()
7921 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7922 cx.run_until_parked();
7923
7924 outline_panel.update(cx, |outline_panel, cx| {
7925 assert_eq!(
7926 display_entries(
7927 &project,
7928 &snapshot(outline_panel, cx),
7929 &outline_panel.cached_entries,
7930 outline_panel.selected_entry(),
7931 cx,
7932 ),
7933 indoc!(
7934 "
7935outline: struct Foo <==== selected
7936 outline: bar
7937 outline: lsp_only_field"
7938 ),
7939 "Step 2: After switching to LSP, should see LSP-provided symbols"
7940 );
7941 });
7942
7943 // Step 3: Switch back to tree-sitter
7944 cx.update(|_, cx| {
7945 settings::SettingsStore::update_global(
7946 cx,
7947 |store: &mut settings::SettingsStore, cx| {
7948 store.update_user_settings(cx, |settings| {
7949 settings.project.all_languages.defaults.document_symbols =
7950 Some(settings::DocumentSymbols::Off);
7951 });
7952 },
7953 );
7954 });
7955 cx.executor()
7956 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7957 cx.run_until_parked();
7958
7959 outline_panel.update(cx, |outline_panel, cx| {
7960 assert_eq!(
7961 display_entries(
7962 &project,
7963 &snapshot(outline_panel, cx),
7964 &outline_panel.cached_entries,
7965 outline_panel.selected_entry(),
7966 cx,
7967 ),
7968 indoc!(
7969 "
7970outline: struct Foo <==== selected
7971 outline: bar
7972 outline: baz"
7973 ),
7974 "Step 3: tree-sitter outlines should be restored"
7975 );
7976 });
7977 }
7978
7979 #[gpui::test]
7980 async fn test_markdown_outline_selection_at_heading_boundaries(cx: &mut TestAppContext) {
7981 init_test(cx);
7982
7983 let fs = FakeFs::new(cx.background_executor.clone());
7984 fs.insert_tree(
7985 "/test",
7986 json!({
7987 "doc.md": indoc!("
7988 # Section A
7989
7990 ## Sub Section A
7991
7992 ## Sub Section B
7993
7994 # Section B
7995
7996 ")
7997 }),
7998 )
7999 .await;
8000
8001 let project = Project::test(fs.clone(), [Path::new("/test")], cx).await;
8002 project.read_with(cx, |project, _| project.languages().add(markdown_lang()));
8003 let (window, workspace) = add_outline_panel(&project, cx).await;
8004 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8005 let outline_panel = outline_panel(&workspace, cx);
8006 outline_panel.update_in(cx, |outline_panel, window, cx| {
8007 outline_panel.set_active(true, window, cx)
8008 });
8009
8010 let editor = workspace
8011 .update_in(cx, |workspace, window, cx| {
8012 workspace.open_abs_path(
8013 PathBuf::from("/test/doc.md"),
8014 OpenOptions {
8015 visible: Some(OpenVisible::All),
8016 ..Default::default()
8017 },
8018 window,
8019 cx,
8020 )
8021 })
8022 .await
8023 .unwrap()
8024 .downcast::<Editor>()
8025 .unwrap();
8026
8027 cx.run_until_parked();
8028
8029 outline_panel.update_in(cx, |panel, window, cx| {
8030 panel.update_non_fs_items(window, cx);
8031 panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
8032 });
8033
8034 // Helper function to move the cursor to the first column of a given row
8035 // and return the selected outline entry's text.
8036 let move_cursor_and_get_selection =
8037 |row: u32, cx: &mut VisualTestContext| -> Option<String> {
8038 cx.update(|window, cx| {
8039 editor.update(cx, |editor, cx| {
8040 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
8041 s.select_ranges(Some(
8042 language::Point::new(row, 0)..language::Point::new(row, 0),
8043 ))
8044 });
8045 });
8046 });
8047
8048 cx.run_until_parked();
8049
8050 outline_panel.read_with(cx, |panel, _cx| {
8051 panel.selected_entry().and_then(|entry| match entry {
8052 PanelEntry::Outline(OutlineEntry::Outline(outline)) => {
8053 Some(outline.text.clone())
8054 }
8055 _ => None,
8056 })
8057 })
8058 };
8059
8060 assert_eq!(
8061 move_cursor_and_get_selection(0, cx).as_deref(),
8062 Some("# Section A"),
8063 "Cursor at row 0 should select '# Section A'"
8064 );
8065
8066 assert_eq!(
8067 move_cursor_and_get_selection(2, cx).as_deref(),
8068 Some("## Sub Section A"),
8069 "Cursor at row 2 should select '## Sub Section A'"
8070 );
8071
8072 assert_eq!(
8073 move_cursor_and_get_selection(4, cx).as_deref(),
8074 Some("## Sub Section B"),
8075 "Cursor at row 4 should select '## Sub Section B'"
8076 );
8077
8078 assert_eq!(
8079 move_cursor_and_get_selection(6, cx).as_deref(),
8080 Some("# Section B"),
8081 "Cursor at row 6 should select '# Section B'"
8082 );
8083 }
8084}