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