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