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::{MAIN_SEPARATOR_STR, Path, PathBuf},
33 sync::{
34 Arc, OnceLock,
35 atomic::{self, AtomicBool},
36 },
37 time::Duration,
38 u32,
39};
40
41use outline_panel_settings::{OutlinePanelDockPosition, 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};
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<Path>, 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 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 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 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 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 f32);
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 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().to_string())
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 if let Some(clipboard_text) = self
1909 .selected_entry()
1910 .and_then(|entry| match entry {
1911 PanelEntry::Fs(entry) => self.relative_path(entry, cx),
1912 PanelEntry::FoldedDirs(folded_dirs) => {
1913 folded_dirs.entries.last().map(|entry| entry.path.clone())
1914 }
1915 PanelEntry::Search(_) | PanelEntry::Outline(..) => None,
1916 })
1917 .map(|p| p.to_string_lossy().to_string())
1918 {
1919 cx.write_to_clipboard(ClipboardItem::new_string(clipboard_text));
1920 }
1921 }
1922
1923 fn reveal_in_finder(
1924 &mut self,
1925 _: &RevealInFileManager,
1926 _: &mut Window,
1927 cx: &mut Context<Self>,
1928 ) {
1929 if let Some(abs_path) = self
1930 .selected_entry()
1931 .and_then(|entry| self.abs_path(entry, cx))
1932 {
1933 cx.reveal_path(&abs_path);
1934 }
1935 }
1936
1937 fn open_in_terminal(
1938 &mut self,
1939 _: &OpenInTerminal,
1940 window: &mut Window,
1941 cx: &mut Context<Self>,
1942 ) {
1943 let selected_entry = self.selected_entry();
1944 let abs_path = selected_entry.and_then(|entry| self.abs_path(entry, cx));
1945 let working_directory = if let (
1946 Some(abs_path),
1947 Some(PanelEntry::Fs(FsEntry::File(..) | FsEntry::ExternalFile(..))),
1948 ) = (&abs_path, selected_entry)
1949 {
1950 abs_path.parent().map(|p| p.to_owned())
1951 } else {
1952 abs_path
1953 };
1954
1955 if let Some(working_directory) = working_directory {
1956 window.dispatch_action(
1957 workspace::OpenTerminal { working_directory }.boxed_clone(),
1958 cx,
1959 )
1960 }
1961 }
1962
1963 fn reveal_entry_for_selection(
1964 &mut self,
1965 editor: Entity<Editor>,
1966 window: &mut Window,
1967 cx: &mut Context<Self>,
1968 ) {
1969 if !self.active
1970 || !OutlinePanelSettings::get_global(cx).auto_reveal_entries
1971 || self.focus_handle.contains_focused(window, cx)
1972 {
1973 return;
1974 }
1975 let project = self.project.clone();
1976 self.reveal_selection_task = cx.spawn_in(window, async move |outline_panel, cx| {
1977 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1978 let entry_with_selection =
1979 outline_panel.update_in(cx, |outline_panel, window, cx| {
1980 outline_panel.location_for_editor_selection(&editor, window, cx)
1981 })?;
1982 let Some(entry_with_selection) = entry_with_selection else {
1983 outline_panel.update(cx, |outline_panel, cx| {
1984 outline_panel.selected_entry = SelectedEntry::None;
1985 cx.notify();
1986 })?;
1987 return Ok(());
1988 };
1989 let related_buffer_entry = match &entry_with_selection {
1990 PanelEntry::Fs(FsEntry::File(FsEntryFile {
1991 worktree_id,
1992 buffer_id,
1993 ..
1994 })) => project.update(cx, |project, cx| {
1995 let entry_id = project
1996 .buffer_for_id(*buffer_id, cx)
1997 .and_then(|buffer| buffer.read(cx).entry_id(cx));
1998 project
1999 .worktree_for_id(*worktree_id, cx)
2000 .zip(entry_id)
2001 .and_then(|(worktree, entry_id)| {
2002 let entry = worktree.read(cx).entry_for_id(entry_id)?.clone();
2003 Some((worktree, entry))
2004 })
2005 })?,
2006 PanelEntry::Outline(outline_entry) => {
2007 let (buffer_id, excerpt_id) = outline_entry.ids();
2008 outline_panel.update(cx, |outline_panel, cx| {
2009 outline_panel
2010 .collapsed_entries
2011 .remove(&CollapsedEntry::ExternalFile(buffer_id));
2012 outline_panel
2013 .collapsed_entries
2014 .remove(&CollapsedEntry::Excerpt(buffer_id, excerpt_id));
2015 let project = outline_panel.project.read(cx);
2016 let entry_id = project
2017 .buffer_for_id(buffer_id, cx)
2018 .and_then(|buffer| buffer.read(cx).entry_id(cx));
2019
2020 entry_id.and_then(|entry_id| {
2021 project
2022 .worktree_for_entry(entry_id, cx)
2023 .and_then(|worktree| {
2024 let worktree_id = worktree.read(cx).id();
2025 outline_panel
2026 .collapsed_entries
2027 .remove(&CollapsedEntry::File(worktree_id, buffer_id));
2028 let entry = worktree.read(cx).entry_for_id(entry_id)?.clone();
2029 Some((worktree, entry))
2030 })
2031 })
2032 })?
2033 }
2034 PanelEntry::Fs(FsEntry::ExternalFile(..)) => None,
2035 PanelEntry::Search(SearchEntry { match_range, .. }) => match_range
2036 .start
2037 .buffer_id
2038 .or(match_range.end.buffer_id)
2039 .map(|buffer_id| {
2040 outline_panel.update(cx, |outline_panel, cx| {
2041 outline_panel
2042 .collapsed_entries
2043 .remove(&CollapsedEntry::ExternalFile(buffer_id));
2044 let project = project.read(cx);
2045 let entry_id = project
2046 .buffer_for_id(buffer_id, cx)
2047 .and_then(|buffer| buffer.read(cx).entry_id(cx));
2048
2049 entry_id.and_then(|entry_id| {
2050 project
2051 .worktree_for_entry(entry_id, cx)
2052 .and_then(|worktree| {
2053 let worktree_id = worktree.read(cx).id();
2054 outline_panel
2055 .collapsed_entries
2056 .remove(&CollapsedEntry::File(worktree_id, buffer_id));
2057 let entry =
2058 worktree.read(cx).entry_for_id(entry_id)?.clone();
2059 Some((worktree, entry))
2060 })
2061 })
2062 })
2063 })
2064 .transpose()?
2065 .flatten(),
2066 _ => return anyhow::Ok(()),
2067 };
2068 if let Some((worktree, buffer_entry)) = related_buffer_entry {
2069 outline_panel.update(cx, |outline_panel, cx| {
2070 let worktree_id = worktree.read(cx).id();
2071 let mut dirs_to_expand = Vec::new();
2072 {
2073 let mut traversal = worktree.read(cx).traverse_from_path(
2074 true,
2075 true,
2076 true,
2077 buffer_entry.path.as_ref(),
2078 );
2079 let mut current_entry = buffer_entry;
2080 loop {
2081 if current_entry.is_dir()
2082 && outline_panel
2083 .collapsed_entries
2084 .remove(&CollapsedEntry::Dir(worktree_id, current_entry.id))
2085 {
2086 dirs_to_expand.push(current_entry.id);
2087 }
2088
2089 if traversal.back_to_parent()
2090 && let Some(parent_entry) = traversal.entry()
2091 {
2092 current_entry = parent_entry.clone();
2093 continue;
2094 }
2095 break;
2096 }
2097 }
2098 for dir_to_expand in dirs_to_expand {
2099 project
2100 .update(cx, |project, cx| {
2101 project.expand_entry(worktree_id, dir_to_expand, cx)
2102 })
2103 .unwrap_or_else(|| Task::ready(Ok(())))
2104 .detach_and_log_err(cx)
2105 }
2106 })?
2107 }
2108
2109 outline_panel.update_in(cx, |outline_panel, window, cx| {
2110 outline_panel.select_entry(entry_with_selection, false, window, cx);
2111 outline_panel.update_cached_entries(None, window, cx);
2112 })?;
2113
2114 anyhow::Ok(())
2115 });
2116 }
2117
2118 fn render_excerpt(
2119 &self,
2120 excerpt: &OutlineEntryExcerpt,
2121 depth: usize,
2122 window: &mut Window,
2123 cx: &mut Context<OutlinePanel>,
2124 ) -> Option<Stateful<Div>> {
2125 let item_id = ElementId::from(excerpt.id.to_proto() as usize);
2126 let is_active = match self.selected_entry() {
2127 Some(PanelEntry::Outline(OutlineEntry::Excerpt(selected_excerpt))) => {
2128 selected_excerpt.buffer_id == excerpt.buffer_id && selected_excerpt.id == excerpt.id
2129 }
2130 _ => false,
2131 };
2132 let has_outlines = self
2133 .excerpts
2134 .get(&excerpt.buffer_id)
2135 .and_then(|excerpts| match &excerpts.get(&excerpt.id)?.outlines {
2136 ExcerptOutlines::Outlines(outlines) => Some(outlines),
2137 ExcerptOutlines::Invalidated(outlines) => Some(outlines),
2138 ExcerptOutlines::NotFetched => None,
2139 })
2140 .is_some_and(|outlines| !outlines.is_empty());
2141 let is_expanded = !self
2142 .collapsed_entries
2143 .contains(&CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id));
2144 let color = entry_label_color(is_active);
2145 let icon = if has_outlines {
2146 FileIcons::get_chevron_icon(is_expanded, cx)
2147 .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element())
2148 } else {
2149 None
2150 }
2151 .unwrap_or_else(empty_icon);
2152
2153 let label = self.excerpt_label(excerpt.buffer_id, &excerpt.range, cx)?;
2154 let label_element = Label::new(label)
2155 .single_line()
2156 .color(color)
2157 .into_any_element();
2158
2159 Some(self.entry_element(
2160 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.clone())),
2161 item_id,
2162 depth,
2163 icon,
2164 is_active,
2165 label_element,
2166 window,
2167 cx,
2168 ))
2169 }
2170
2171 fn excerpt_label(
2172 &self,
2173 buffer_id: BufferId,
2174 range: &ExcerptRange<language::Anchor>,
2175 cx: &App,
2176 ) -> Option<String> {
2177 let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx)?;
2178 let excerpt_range = range.context.to_point(&buffer_snapshot);
2179 Some(format!(
2180 "Lines {}- {}",
2181 excerpt_range.start.row + 1,
2182 excerpt_range.end.row + 1,
2183 ))
2184 }
2185
2186 fn render_outline(
2187 &self,
2188 outline: &OutlineEntryOutline,
2189 depth: usize,
2190 string_match: Option<&StringMatch>,
2191 window: &mut Window,
2192 cx: &mut Context<Self>,
2193 ) -> Stateful<Div> {
2194 let item_id = ElementId::from(SharedString::from(format!(
2195 "{:?}|{:?}{:?}|{:?}",
2196 outline.buffer_id, outline.excerpt_id, outline.outline.range, &outline.outline.text,
2197 )));
2198
2199 let label_element = outline::render_item(
2200 &outline.outline,
2201 string_match
2202 .map(|string_match| string_match.ranges().collect::<Vec<_>>())
2203 .unwrap_or_default(),
2204 cx,
2205 )
2206 .into_any_element();
2207
2208 let is_active = match self.selected_entry() {
2209 Some(PanelEntry::Outline(OutlineEntry::Outline(selected))) => {
2210 outline == selected && outline.outline == selected.outline
2211 }
2212 _ => false,
2213 };
2214
2215 let has_children = self
2216 .outline_children_cache
2217 .get(&outline.buffer_id)
2218 .and_then(|children_map| {
2219 let key = (outline.outline.range.clone(), outline.outline.depth);
2220 children_map.get(&key)
2221 })
2222 .copied()
2223 .unwrap_or(false);
2224 let is_expanded = !self.collapsed_entries.contains(&CollapsedEntry::Outline(
2225 outline.buffer_id,
2226 outline.excerpt_id,
2227 outline.outline.range.clone(),
2228 ));
2229
2230 let icon = if has_children {
2231 FileIcons::get_chevron_icon(is_expanded, cx)
2232 .map(|icon_path| {
2233 Icon::from_path(icon_path)
2234 .color(entry_label_color(is_active))
2235 .into_any_element()
2236 })
2237 .unwrap_or_else(empty_icon)
2238 } else {
2239 empty_icon()
2240 };
2241
2242 self.entry_element(
2243 PanelEntry::Outline(OutlineEntry::Outline(outline.clone())),
2244 item_id,
2245 depth,
2246 icon,
2247 is_active,
2248 label_element,
2249 window,
2250 cx,
2251 )
2252 }
2253
2254 fn render_entry(
2255 &self,
2256 rendered_entry: &FsEntry,
2257 depth: usize,
2258 string_match: Option<&StringMatch>,
2259 window: &mut Window,
2260 cx: &mut Context<Self>,
2261 ) -> Stateful<Div> {
2262 let settings = OutlinePanelSettings::get_global(cx);
2263 let is_active = match self.selected_entry() {
2264 Some(PanelEntry::Fs(selected_entry)) => selected_entry == rendered_entry,
2265 _ => false,
2266 };
2267 let (item_id, label_element, icon) = match rendered_entry {
2268 FsEntry::File(FsEntryFile {
2269 worktree_id, entry, ..
2270 }) => {
2271 let name = self.entry_name(worktree_id, entry, cx);
2272 let color =
2273 entry_git_aware_label_color(entry.git_summary, entry.is_ignored, is_active);
2274 let icon = if settings.file_icons {
2275 FileIcons::get_icon(&entry.path, cx)
2276 .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element())
2277 } else {
2278 None
2279 };
2280 (
2281 ElementId::from(entry.id.to_proto() as usize),
2282 HighlightedLabel::new(
2283 name,
2284 string_match
2285 .map(|string_match| string_match.positions.clone())
2286 .unwrap_or_default(),
2287 )
2288 .color(color)
2289 .into_any_element(),
2290 icon.unwrap_or_else(empty_icon),
2291 )
2292 }
2293 FsEntry::Directory(directory) => {
2294 let name = self.entry_name(&directory.worktree_id, &directory.entry, cx);
2295
2296 let is_expanded = !self.collapsed_entries.contains(&CollapsedEntry::Dir(
2297 directory.worktree_id,
2298 directory.entry.id,
2299 ));
2300 let color = entry_git_aware_label_color(
2301 directory.entry.git_summary,
2302 directory.entry.is_ignored,
2303 is_active,
2304 );
2305 let icon = if settings.folder_icons {
2306 FileIcons::get_folder_icon(is_expanded, &directory.entry.path, cx)
2307 } else {
2308 FileIcons::get_chevron_icon(is_expanded, cx)
2309 }
2310 .map(Icon::from_path)
2311 .map(|icon| icon.color(color).into_any_element());
2312 (
2313 ElementId::from(directory.entry.id.to_proto() as usize),
2314 HighlightedLabel::new(
2315 name,
2316 string_match
2317 .map(|string_match| string_match.positions.clone())
2318 .unwrap_or_default(),
2319 )
2320 .color(color)
2321 .into_any_element(),
2322 icon.unwrap_or_else(empty_icon),
2323 )
2324 }
2325 FsEntry::ExternalFile(external_file) => {
2326 let color = entry_label_color(is_active);
2327 let (icon, name) = match self.buffer_snapshot_for_id(external_file.buffer_id, cx) {
2328 Some(buffer_snapshot) => match buffer_snapshot.file() {
2329 Some(file) => {
2330 let path = file.path();
2331 let icon = if settings.file_icons {
2332 FileIcons::get_icon(path.as_ref(), cx)
2333 } else {
2334 None
2335 }
2336 .map(Icon::from_path)
2337 .map(|icon| icon.color(color).into_any_element());
2338 (icon, file_name(path.as_ref()))
2339 }
2340 None => (None, "Untitled".to_string()),
2341 },
2342 None => (None, "Unknown buffer".to_string()),
2343 };
2344 (
2345 ElementId::from(external_file.buffer_id.to_proto() as usize),
2346 HighlightedLabel::new(
2347 name,
2348 string_match
2349 .map(|string_match| string_match.positions.clone())
2350 .unwrap_or_default(),
2351 )
2352 .color(color)
2353 .into_any_element(),
2354 icon.unwrap_or_else(empty_icon),
2355 )
2356 }
2357 };
2358
2359 self.entry_element(
2360 PanelEntry::Fs(rendered_entry.clone()),
2361 item_id,
2362 depth,
2363 icon,
2364 is_active,
2365 label_element,
2366 window,
2367 cx,
2368 )
2369 }
2370
2371 fn render_folded_dirs(
2372 &self,
2373 folded_dir: &FoldedDirsEntry,
2374 depth: usize,
2375 string_match: Option<&StringMatch>,
2376 window: &mut Window,
2377 cx: &mut Context<OutlinePanel>,
2378 ) -> Stateful<Div> {
2379 let settings = OutlinePanelSettings::get_global(cx);
2380 let is_active = match self.selected_entry() {
2381 Some(PanelEntry::FoldedDirs(selected_dirs)) => {
2382 selected_dirs.worktree_id == folded_dir.worktree_id
2383 && selected_dirs.entries == folded_dir.entries
2384 }
2385 _ => false,
2386 };
2387 let (item_id, label_element, icon) = {
2388 let name = self.dir_names_string(&folded_dir.entries, folded_dir.worktree_id, cx);
2389
2390 let is_expanded = folded_dir.entries.iter().all(|dir| {
2391 !self
2392 .collapsed_entries
2393 .contains(&CollapsedEntry::Dir(folded_dir.worktree_id, dir.id))
2394 });
2395 let is_ignored = folded_dir.entries.iter().any(|entry| entry.is_ignored);
2396 let git_status = folded_dir
2397 .entries
2398 .first()
2399 .map(|entry| entry.git_summary)
2400 .unwrap_or_default();
2401 let color = entry_git_aware_label_color(git_status, is_ignored, is_active);
2402 let icon = if settings.folder_icons {
2403 FileIcons::get_folder_icon(is_expanded, &Path::new(&name), cx)
2404 } else {
2405 FileIcons::get_chevron_icon(is_expanded, cx)
2406 }
2407 .map(Icon::from_path)
2408 .map(|icon| icon.color(color).into_any_element());
2409 (
2410 ElementId::from(
2411 folded_dir
2412 .entries
2413 .last()
2414 .map(|entry| entry.id.to_proto())
2415 .unwrap_or_else(|| folded_dir.worktree_id.to_proto())
2416 as usize,
2417 ),
2418 HighlightedLabel::new(
2419 name,
2420 string_match
2421 .map(|string_match| string_match.positions.clone())
2422 .unwrap_or_default(),
2423 )
2424 .color(color)
2425 .into_any_element(),
2426 icon.unwrap_or_else(empty_icon),
2427 )
2428 };
2429
2430 self.entry_element(
2431 PanelEntry::FoldedDirs(folded_dir.clone()),
2432 item_id,
2433 depth,
2434 icon,
2435 is_active,
2436 label_element,
2437 window,
2438 cx,
2439 )
2440 }
2441
2442 fn render_search_match(
2443 &mut self,
2444 multi_buffer_snapshot: Option<&MultiBufferSnapshot>,
2445 match_range: &Range<editor::Anchor>,
2446 render_data: &Arc<OnceLock<SearchData>>,
2447 kind: SearchKind,
2448 depth: usize,
2449 string_match: Option<&StringMatch>,
2450 window: &mut Window,
2451 cx: &mut Context<Self>,
2452 ) -> Option<Stateful<Div>> {
2453 let search_data = match render_data.get() {
2454 Some(search_data) => search_data,
2455 None => {
2456 if let ItemsDisplayMode::Search(search_state) = &mut self.mode
2457 && let Some(multi_buffer_snapshot) = multi_buffer_snapshot
2458 {
2459 search_state
2460 .highlight_search_match_tx
2461 .try_send(HighlightArguments {
2462 multi_buffer_snapshot: multi_buffer_snapshot.clone(),
2463 match_range: match_range.clone(),
2464 search_data: Arc::clone(render_data),
2465 })
2466 .ok();
2467 }
2468 return None;
2469 }
2470 };
2471 let search_matches = string_match
2472 .iter()
2473 .flat_map(|string_match| string_match.ranges())
2474 .collect::<Vec<_>>();
2475 let match_ranges = if search_matches.is_empty() {
2476 &search_data.search_match_indices
2477 } else {
2478 &search_matches
2479 };
2480 let label_element = outline::render_item(
2481 &OutlineItem {
2482 depth,
2483 annotation_range: None,
2484 range: search_data.context_range.clone(),
2485 text: search_data.context_text.clone(),
2486 highlight_ranges: search_data
2487 .highlights_data
2488 .get()
2489 .cloned()
2490 .unwrap_or_default(),
2491 name_ranges: search_data.search_match_indices.clone(),
2492 body_range: Some(search_data.context_range.clone()),
2493 },
2494 match_ranges.iter().cloned(),
2495 cx,
2496 );
2497 let truncated_contents_label = || Label::new(TRUNCATED_CONTEXT_MARK);
2498 let entire_label = h_flex()
2499 .justify_center()
2500 .p_0()
2501 .when(search_data.truncated_left, |parent| {
2502 parent.child(truncated_contents_label())
2503 })
2504 .child(label_element)
2505 .when(search_data.truncated_right, |parent| {
2506 parent.child(truncated_contents_label())
2507 })
2508 .into_any_element();
2509
2510 let is_active = match self.selected_entry() {
2511 Some(PanelEntry::Search(SearchEntry {
2512 match_range: selected_match_range,
2513 ..
2514 })) => match_range == selected_match_range,
2515 _ => false,
2516 };
2517 Some(self.entry_element(
2518 PanelEntry::Search(SearchEntry {
2519 kind,
2520 match_range: match_range.clone(),
2521 render_data: render_data.clone(),
2522 }),
2523 ElementId::from(SharedString::from(format!("search-{match_range:?}"))),
2524 depth,
2525 empty_icon(),
2526 is_active,
2527 entire_label,
2528 window,
2529 cx,
2530 ))
2531 }
2532
2533 fn entry_element(
2534 &self,
2535 rendered_entry: PanelEntry,
2536 item_id: ElementId,
2537 depth: usize,
2538 icon_element: AnyElement,
2539 is_active: bool,
2540 label_element: gpui::AnyElement,
2541 window: &mut Window,
2542 cx: &mut Context<OutlinePanel>,
2543 ) -> Stateful<Div> {
2544 let settings = OutlinePanelSettings::get_global(cx);
2545 div()
2546 .text_ui(cx)
2547 .id(item_id.clone())
2548 .on_click({
2549 let clicked_entry = rendered_entry.clone();
2550 cx.listener(move |outline_panel, event: &gpui::ClickEvent, window, cx| {
2551 if event.is_right_click() || event.first_focus() {
2552 return;
2553 }
2554
2555 let change_focus = event.click_count() > 1;
2556 outline_panel.toggle_expanded(&clicked_entry, window, cx);
2557
2558 outline_panel.scroll_editor_to_entry(
2559 &clicked_entry,
2560 true,
2561 change_focus,
2562 window,
2563 cx,
2564 );
2565 })
2566 })
2567 .cursor_pointer()
2568 .child(
2569 ListItem::new(item_id)
2570 .indent_level(depth)
2571 .indent_step_size(px(settings.indent_size))
2572 .toggle_state(is_active)
2573 .child(
2574 h_flex()
2575 .child(h_flex().w(px(16.)).justify_center().child(icon_element))
2576 .child(h_flex().h_6().child(label_element).ml_1()),
2577 )
2578 .on_secondary_mouse_down(cx.listener(
2579 move |outline_panel, event: &MouseDownEvent, window, cx| {
2580 // Stop propagation to prevent the catch-all context menu for the project
2581 // panel from being deployed.
2582 cx.stop_propagation();
2583 outline_panel.deploy_context_menu(
2584 event.position,
2585 rendered_entry.clone(),
2586 window,
2587 cx,
2588 )
2589 },
2590 )),
2591 )
2592 .border_1()
2593 .border_r_2()
2594 .rounded_none()
2595 .hover(|style| {
2596 if is_active {
2597 style
2598 } else {
2599 let hover_color = cx.theme().colors().ghost_element_hover;
2600 style.bg(hover_color).border_color(hover_color)
2601 }
2602 })
2603 .when(
2604 is_active && self.focus_handle.contains_focused(window, cx),
2605 |div| div.border_color(Color::Selected.color(cx)),
2606 )
2607 }
2608
2609 fn entry_name(&self, worktree_id: &WorktreeId, entry: &Entry, cx: &App) -> String {
2610 match self.project.read(cx).worktree_for_id(*worktree_id, cx) {
2611 Some(worktree) => {
2612 let worktree = worktree.read(cx);
2613 match worktree.snapshot().root_entry() {
2614 Some(root_entry) => {
2615 if root_entry.id == entry.id {
2616 file_name(worktree.abs_path().as_ref())
2617 } else {
2618 let path = worktree.absolutize(entry.path.as_ref()).ok();
2619 let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref());
2620 file_name(path)
2621 }
2622 }
2623 None => {
2624 let path = worktree.absolutize(entry.path.as_ref()).ok();
2625 let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref());
2626 file_name(path)
2627 }
2628 }
2629 }
2630 None => file_name(entry.path.as_ref()),
2631 }
2632 }
2633
2634 fn update_fs_entries(
2635 &mut self,
2636 active_editor: Entity<Editor>,
2637 debounce: Option<Duration>,
2638 window: &mut Window,
2639 cx: &mut Context<Self>,
2640 ) {
2641 if !self.active {
2642 return;
2643 }
2644
2645 let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
2646 let active_multi_buffer = active_editor.read(cx).buffer().clone();
2647 let new_entries = self.new_entries_for_fs_update.clone();
2648 let repo_snapshots = self.project.update(cx, |project, cx| {
2649 project.git_store().read(cx).repo_snapshots(cx)
2650 });
2651 self.updating_fs_entries = true;
2652 self.fs_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| {
2653 if let Some(debounce) = debounce {
2654 cx.background_executor().timer(debounce).await;
2655 }
2656
2657 let mut new_collapsed_entries = HashSet::default();
2658 let mut new_unfolded_dirs = HashMap::default();
2659 let mut root_entries = HashSet::default();
2660 let mut new_excerpts = HashMap::<BufferId, HashMap<ExcerptId, Excerpt>>::default();
2661 let Ok(buffer_excerpts) = outline_panel.update(cx, |outline_panel, cx| {
2662 let git_store = outline_panel.project.read(cx).git_store().clone();
2663 new_collapsed_entries = outline_panel.collapsed_entries.clone();
2664 new_unfolded_dirs = outline_panel.unfolded_dirs.clone();
2665 let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx);
2666
2667 multi_buffer_snapshot.excerpts().fold(
2668 HashMap::default(),
2669 |mut buffer_excerpts, (excerpt_id, buffer_snapshot, excerpt_range)| {
2670 let buffer_id = buffer_snapshot.remote_id();
2671 let file = File::from_dyn(buffer_snapshot.file());
2672 let entry_id = file.and_then(|file| file.project_entry_id(cx));
2673 let worktree = file.map(|file| file.worktree.read(cx).snapshot());
2674 let is_new = new_entries.contains(&excerpt_id)
2675 || !outline_panel.excerpts.contains_key(&buffer_id);
2676 let is_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx);
2677 let status = git_store
2678 .read(cx)
2679 .repository_and_path_for_buffer_id(buffer_id, cx)
2680 .and_then(|(repo, path)| {
2681 Some(repo.read(cx).status_for_path(&path)?.status)
2682 });
2683 buffer_excerpts
2684 .entry(buffer_id)
2685 .or_insert_with(|| {
2686 (is_new, is_folded, Vec::new(), entry_id, worktree, status)
2687 })
2688 .2
2689 .push(excerpt_id);
2690
2691 let outlines = match outline_panel
2692 .excerpts
2693 .get(&buffer_id)
2694 .and_then(|excerpts| excerpts.get(&excerpt_id))
2695 {
2696 Some(old_excerpt) => match &old_excerpt.outlines {
2697 ExcerptOutlines::Outlines(outlines) => {
2698 ExcerptOutlines::Outlines(outlines.clone())
2699 }
2700 ExcerptOutlines::Invalidated(_) => ExcerptOutlines::NotFetched,
2701 ExcerptOutlines::NotFetched => ExcerptOutlines::NotFetched,
2702 },
2703 None => ExcerptOutlines::NotFetched,
2704 };
2705 new_excerpts.entry(buffer_id).or_default().insert(
2706 excerpt_id,
2707 Excerpt {
2708 range: excerpt_range,
2709 outlines,
2710 },
2711 );
2712 buffer_excerpts
2713 },
2714 )
2715 }) else {
2716 return;
2717 };
2718
2719 let Some((
2720 new_collapsed_entries,
2721 new_unfolded_dirs,
2722 new_fs_entries,
2723 new_depth_map,
2724 new_children_count,
2725 )) = cx
2726 .background_spawn(async move {
2727 let mut processed_external_buffers = HashSet::default();
2728 let mut new_worktree_entries =
2729 BTreeMap::<WorktreeId, HashMap<ProjectEntryId, GitEntry>>::default();
2730 let mut worktree_excerpts = HashMap::<
2731 WorktreeId,
2732 HashMap<ProjectEntryId, (BufferId, Vec<ExcerptId>)>,
2733 >::default();
2734 let mut external_excerpts = HashMap::default();
2735
2736 for (buffer_id, (is_new, is_folded, excerpts, entry_id, worktree, status)) in
2737 buffer_excerpts
2738 {
2739 if is_folded {
2740 match &worktree {
2741 Some(worktree) => {
2742 new_collapsed_entries
2743 .insert(CollapsedEntry::File(worktree.id(), buffer_id));
2744 }
2745 None => {
2746 new_collapsed_entries
2747 .insert(CollapsedEntry::ExternalFile(buffer_id));
2748 }
2749 }
2750 } else if is_new {
2751 match &worktree {
2752 Some(worktree) => {
2753 new_collapsed_entries
2754 .remove(&CollapsedEntry::File(worktree.id(), buffer_id));
2755 }
2756 None => {
2757 new_collapsed_entries
2758 .remove(&CollapsedEntry::ExternalFile(buffer_id));
2759 }
2760 }
2761 }
2762
2763 if let Some(worktree) = worktree {
2764 let worktree_id = worktree.id();
2765 let unfolded_dirs = new_unfolded_dirs.entry(worktree_id).or_default();
2766
2767 match entry_id.and_then(|id| worktree.entry_for_id(id)).cloned() {
2768 Some(entry) => {
2769 let entry = GitEntry {
2770 git_summary: status
2771 .map(|status| status.summary())
2772 .unwrap_or_default(),
2773 entry,
2774 };
2775 let mut traversal = GitTraversal::new(
2776 &repo_snapshots,
2777 worktree.traverse_from_path(
2778 true,
2779 true,
2780 true,
2781 entry.path.as_ref(),
2782 ),
2783 );
2784
2785 let mut entries_to_add = HashMap::default();
2786 worktree_excerpts
2787 .entry(worktree_id)
2788 .or_default()
2789 .insert(entry.id, (buffer_id, excerpts));
2790 let mut current_entry = entry;
2791 loop {
2792 if current_entry.is_dir() {
2793 let is_root =
2794 worktree.root_entry().map(|entry| entry.id)
2795 == Some(current_entry.id);
2796 if is_root {
2797 root_entries.insert(current_entry.id);
2798 if auto_fold_dirs {
2799 unfolded_dirs.insert(current_entry.id);
2800 }
2801 }
2802 if is_new {
2803 new_collapsed_entries.remove(&CollapsedEntry::Dir(
2804 worktree_id,
2805 current_entry.id,
2806 ));
2807 }
2808 }
2809
2810 let new_entry_added = entries_to_add
2811 .insert(current_entry.id, current_entry)
2812 .is_none();
2813 if new_entry_added
2814 && traversal.back_to_parent()
2815 && let Some(parent_entry) = traversal.entry()
2816 {
2817 current_entry = parent_entry.to_owned();
2818 continue;
2819 }
2820 break;
2821 }
2822 new_worktree_entries
2823 .entry(worktree_id)
2824 .or_insert_with(HashMap::default)
2825 .extend(entries_to_add);
2826 }
2827 None => {
2828 if processed_external_buffers.insert(buffer_id) {
2829 external_excerpts
2830 .entry(buffer_id)
2831 .or_insert_with(Vec::new)
2832 .extend(excerpts);
2833 }
2834 }
2835 }
2836 } else if processed_external_buffers.insert(buffer_id) {
2837 external_excerpts
2838 .entry(buffer_id)
2839 .or_insert_with(Vec::new)
2840 .extend(excerpts);
2841 }
2842 }
2843
2844 let mut new_children_count =
2845 HashMap::<WorktreeId, HashMap<Arc<Path>, FsChildren>>::default();
2846
2847 let worktree_entries = new_worktree_entries
2848 .into_iter()
2849 .map(|(worktree_id, entries)| {
2850 let mut entries = entries.into_values().collect::<Vec<_>>();
2851 entries.sort_by(|a, b| a.path.as_ref().cmp(b.path.as_ref()));
2852 (worktree_id, entries)
2853 })
2854 .flat_map(|(worktree_id, entries)| {
2855 {
2856 entries
2857 .into_iter()
2858 .filter_map(|entry| {
2859 if auto_fold_dirs && let Some(parent) = entry.path.parent()
2860 {
2861 let children = new_children_count
2862 .entry(worktree_id)
2863 .or_default()
2864 .entry(Arc::from(parent))
2865 .or_default();
2866 if entry.is_dir() {
2867 children.dirs += 1;
2868 } else {
2869 children.files += 1;
2870 }
2871 }
2872
2873 if entry.is_dir() {
2874 Some(FsEntry::Directory(FsEntryDirectory {
2875 worktree_id,
2876 entry,
2877 }))
2878 } else {
2879 let (buffer_id, excerpts) = worktree_excerpts
2880 .get_mut(&worktree_id)
2881 .and_then(|worktree_excerpts| {
2882 worktree_excerpts.remove(&entry.id)
2883 })?;
2884 Some(FsEntry::File(FsEntryFile {
2885 worktree_id,
2886 buffer_id,
2887 entry,
2888 excerpts,
2889 }))
2890 }
2891 })
2892 .collect::<Vec<_>>()
2893 }
2894 })
2895 .collect::<Vec<_>>();
2896
2897 let mut visited_dirs = Vec::new();
2898 let mut new_depth_map = HashMap::default();
2899 let new_visible_entries = external_excerpts
2900 .into_iter()
2901 .sorted_by_key(|(id, _)| *id)
2902 .map(|(buffer_id, excerpts)| {
2903 FsEntry::ExternalFile(FsEntryExternalFile {
2904 buffer_id,
2905 excerpts,
2906 })
2907 })
2908 .chain(worktree_entries)
2909 .filter(|visible_item| {
2910 match visible_item {
2911 FsEntry::Directory(directory) => {
2912 let parent_id = back_to_common_visited_parent(
2913 &mut visited_dirs,
2914 &directory.worktree_id,
2915 &directory.entry,
2916 );
2917
2918 let mut depth = 0;
2919 if !root_entries.contains(&directory.entry.id) {
2920 if auto_fold_dirs {
2921 let children = new_children_count
2922 .get(&directory.worktree_id)
2923 .and_then(|children_count| {
2924 children_count.get(&directory.entry.path)
2925 })
2926 .copied()
2927 .unwrap_or_default();
2928
2929 if !children.may_be_fold_part()
2930 || (children.dirs == 0
2931 && visited_dirs
2932 .last()
2933 .map(|(parent_dir_id, _)| {
2934 new_unfolded_dirs
2935 .get(&directory.worktree_id)
2936 .is_none_or(|unfolded_dirs| {
2937 unfolded_dirs
2938 .contains(parent_dir_id)
2939 })
2940 })
2941 .unwrap_or(true))
2942 {
2943 new_unfolded_dirs
2944 .entry(directory.worktree_id)
2945 .or_default()
2946 .insert(directory.entry.id);
2947 }
2948 }
2949
2950 depth = parent_id
2951 .and_then(|(worktree_id, id)| {
2952 new_depth_map.get(&(worktree_id, id)).copied()
2953 })
2954 .unwrap_or(0)
2955 + 1;
2956 };
2957 visited_dirs
2958 .push((directory.entry.id, directory.entry.path.clone()));
2959 new_depth_map
2960 .insert((directory.worktree_id, directory.entry.id), depth);
2961 }
2962 FsEntry::File(FsEntryFile {
2963 worktree_id,
2964 entry: file_entry,
2965 ..
2966 }) => {
2967 let parent_id = back_to_common_visited_parent(
2968 &mut visited_dirs,
2969 worktree_id,
2970 file_entry,
2971 );
2972 let depth = if root_entries.contains(&file_entry.id) {
2973 0
2974 } else {
2975 parent_id
2976 .and_then(|(worktree_id, id)| {
2977 new_depth_map.get(&(worktree_id, id)).copied()
2978 })
2979 .unwrap_or(0)
2980 + 1
2981 };
2982 new_depth_map.insert((*worktree_id, file_entry.id), depth);
2983 }
2984 FsEntry::ExternalFile(..) => {
2985 visited_dirs.clear();
2986 }
2987 }
2988
2989 true
2990 })
2991 .collect::<Vec<_>>();
2992
2993 anyhow::Ok((
2994 new_collapsed_entries,
2995 new_unfolded_dirs,
2996 new_visible_entries,
2997 new_depth_map,
2998 new_children_count,
2999 ))
3000 })
3001 .await
3002 .log_err()
3003 else {
3004 return;
3005 };
3006
3007 outline_panel
3008 .update_in(cx, |outline_panel, window, cx| {
3009 outline_panel.updating_fs_entries = false;
3010 outline_panel.new_entries_for_fs_update.clear();
3011 outline_panel.excerpts = new_excerpts;
3012 outline_panel.collapsed_entries = new_collapsed_entries;
3013 outline_panel.unfolded_dirs = new_unfolded_dirs;
3014 outline_panel.fs_entries = new_fs_entries;
3015 outline_panel.fs_entries_depth = new_depth_map;
3016 outline_panel.fs_children_count = new_children_count;
3017 outline_panel.update_non_fs_items(window, cx);
3018
3019 // Only update cached entries if we don't have outlines to fetch
3020 // If we do have outlines to fetch, let fetch_outdated_outlines handle the update
3021 if outline_panel.excerpt_fetch_ranges(cx).is_empty() {
3022 outline_panel.update_cached_entries(debounce, window, cx);
3023 }
3024
3025 cx.notify();
3026 })
3027 .ok();
3028 });
3029 }
3030
3031 fn replace_active_editor(
3032 &mut self,
3033 new_active_item: Box<dyn ItemHandle>,
3034 new_active_editor: Entity<Editor>,
3035 window: &mut Window,
3036 cx: &mut Context<Self>,
3037 ) {
3038 self.clear_previous(window, cx);
3039
3040 let default_expansion_depth =
3041 OutlinePanelSettings::get_global(cx).expand_outlines_with_depth;
3042 // We'll apply the expansion depth after outlines are loaded
3043 self.pending_default_expansion_depth = Some(default_expansion_depth);
3044
3045 let buffer_search_subscription = cx.subscribe_in(
3046 &new_active_editor,
3047 window,
3048 |outline_panel: &mut Self,
3049 _,
3050 e: &SearchEvent,
3051 window: &mut Window,
3052 cx: &mut Context<Self>| {
3053 if matches!(e, SearchEvent::MatchesInvalidated) {
3054 let update_cached_items = outline_panel.update_search_matches(window, cx);
3055 if update_cached_items {
3056 outline_panel.selected_entry.invalidate();
3057 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
3058 }
3059 };
3060 outline_panel.autoscroll(cx);
3061 },
3062 );
3063 self.active_item = Some(ActiveItem {
3064 _buffer_search_subscription: buffer_search_subscription,
3065 _editor_subscription: subscribe_for_editor_events(&new_active_editor, window, cx),
3066 item_handle: new_active_item.downgrade_item(),
3067 active_editor: new_active_editor.downgrade(),
3068 });
3069 self.new_entries_for_fs_update
3070 .extend(new_active_editor.read(cx).buffer().read(cx).excerpt_ids());
3071 self.selected_entry.invalidate();
3072 self.update_fs_entries(new_active_editor, None, window, cx);
3073 }
3074
3075 fn clear_previous(&mut self, window: &mut Window, cx: &mut App) {
3076 self.fs_entries_update_task = Task::ready(());
3077 self.outline_fetch_tasks.clear();
3078 self.cached_entries_update_task = Task::ready(());
3079 self.reveal_selection_task = Task::ready(Ok(()));
3080 self.filter_editor
3081 .update(cx, |editor, cx| editor.clear(window, cx));
3082 self.collapsed_entries.clear();
3083 self.unfolded_dirs.clear();
3084 self.active_item = None;
3085 self.fs_entries.clear();
3086 self.fs_entries_depth.clear();
3087 self.fs_children_count.clear();
3088 self.excerpts.clear();
3089 self.cached_entries = Vec::new();
3090 self.selected_entry = SelectedEntry::None;
3091 self.pinned = false;
3092 self.mode = ItemsDisplayMode::Outline;
3093 self.pending_default_expansion_depth = None;
3094 }
3095
3096 fn location_for_editor_selection(
3097 &self,
3098 editor: &Entity<Editor>,
3099 window: &mut Window,
3100 cx: &mut Context<Self>,
3101 ) -> Option<PanelEntry> {
3102 let selection = editor.update(cx, |editor, cx| {
3103 editor.selections.newest::<language::Point>(cx).head()
3104 });
3105 let editor_snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
3106 let multi_buffer = editor.read(cx).buffer();
3107 let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
3108 let (excerpt_id, buffer, _) = editor
3109 .read(cx)
3110 .buffer()
3111 .read(cx)
3112 .excerpt_containing(selection, cx)?;
3113 let buffer_id = buffer.read(cx).remote_id();
3114
3115 if editor.read(cx).is_buffer_folded(buffer_id, cx) {
3116 return self
3117 .fs_entries
3118 .iter()
3119 .find(|fs_entry| match fs_entry {
3120 FsEntry::Directory(..) => false,
3121 FsEntry::File(FsEntryFile {
3122 buffer_id: other_buffer_id,
3123 ..
3124 })
3125 | FsEntry::ExternalFile(FsEntryExternalFile {
3126 buffer_id: other_buffer_id,
3127 ..
3128 }) => buffer_id == *other_buffer_id,
3129 })
3130 .cloned()
3131 .map(PanelEntry::Fs);
3132 }
3133
3134 let selection_display_point = selection.to_display_point(&editor_snapshot);
3135
3136 match &self.mode {
3137 ItemsDisplayMode::Search(search_state) => search_state
3138 .matches
3139 .iter()
3140 .rev()
3141 .min_by_key(|&(match_range, _)| {
3142 let match_display_range =
3143 match_range.clone().to_display_points(&editor_snapshot);
3144 let start_distance = if selection_display_point < match_display_range.start {
3145 match_display_range.start - selection_display_point
3146 } else {
3147 selection_display_point - match_display_range.start
3148 };
3149 let end_distance = if selection_display_point < match_display_range.end {
3150 match_display_range.end - selection_display_point
3151 } else {
3152 selection_display_point - match_display_range.end
3153 };
3154 start_distance + end_distance
3155 })
3156 .and_then(|(closest_range, _)| {
3157 self.cached_entries.iter().find_map(|cached_entry| {
3158 if let PanelEntry::Search(SearchEntry { match_range, .. }) =
3159 &cached_entry.entry
3160 {
3161 if match_range == closest_range {
3162 Some(cached_entry.entry.clone())
3163 } else {
3164 None
3165 }
3166 } else {
3167 None
3168 }
3169 })
3170 }),
3171 ItemsDisplayMode::Outline => self.outline_location(
3172 buffer_id,
3173 excerpt_id,
3174 multi_buffer_snapshot,
3175 editor_snapshot,
3176 selection_display_point,
3177 ),
3178 }
3179 }
3180
3181 fn outline_location(
3182 &self,
3183 buffer_id: BufferId,
3184 excerpt_id: ExcerptId,
3185 multi_buffer_snapshot: editor::MultiBufferSnapshot,
3186 editor_snapshot: editor::EditorSnapshot,
3187 selection_display_point: DisplayPoint,
3188 ) -> Option<PanelEntry> {
3189 let excerpt_outlines = self
3190 .excerpts
3191 .get(&buffer_id)
3192 .and_then(|excerpts| excerpts.get(&excerpt_id))
3193 .into_iter()
3194 .flat_map(|excerpt| excerpt.iter_outlines())
3195 .flat_map(|outline| {
3196 let start = multi_buffer_snapshot
3197 .anchor_in_excerpt(excerpt_id, outline.range.start)?
3198 .to_display_point(&editor_snapshot);
3199 let end = multi_buffer_snapshot
3200 .anchor_in_excerpt(excerpt_id, outline.range.end)?
3201 .to_display_point(&editor_snapshot);
3202 Some((start..end, outline))
3203 })
3204 .collect::<Vec<_>>();
3205
3206 let mut matching_outline_indices = Vec::new();
3207 let mut children = HashMap::default();
3208 let mut parents_stack = Vec::<(&Range<DisplayPoint>, &&Outline, usize)>::new();
3209
3210 for (i, (outline_range, outline)) in excerpt_outlines.iter().enumerate() {
3211 if outline_range
3212 .to_inclusive()
3213 .contains(&selection_display_point)
3214 {
3215 matching_outline_indices.push(i);
3216 } else if (outline_range.start.row()..outline_range.end.row())
3217 .to_inclusive()
3218 .contains(&selection_display_point.row())
3219 {
3220 matching_outline_indices.push(i);
3221 }
3222
3223 while let Some((parent_range, parent_outline, _)) = parents_stack.last() {
3224 if parent_outline.depth >= outline.depth
3225 || !parent_range.contains(&outline_range.start)
3226 {
3227 parents_stack.pop();
3228 } else {
3229 break;
3230 }
3231 }
3232 if let Some((_, _, parent_index)) = parents_stack.last_mut() {
3233 children
3234 .entry(*parent_index)
3235 .or_insert_with(Vec::new)
3236 .push(i);
3237 }
3238 parents_stack.push((outline_range, outline, i));
3239 }
3240
3241 let outline_item = matching_outline_indices
3242 .into_iter()
3243 .flat_map(|i| Some((i, excerpt_outlines.get(i)?)))
3244 .filter(|(i, _)| {
3245 children
3246 .get(i)
3247 .map(|children| {
3248 children.iter().all(|child_index| {
3249 excerpt_outlines
3250 .get(*child_index)
3251 .map(|(child_range, _)| child_range.start > selection_display_point)
3252 .unwrap_or(false)
3253 })
3254 })
3255 .unwrap_or(true)
3256 })
3257 .min_by_key(|(_, (outline_range, outline))| {
3258 let distance_from_start = if outline_range.start > selection_display_point {
3259 outline_range.start - selection_display_point
3260 } else {
3261 selection_display_point - outline_range.start
3262 };
3263 let distance_from_end = if outline_range.end > selection_display_point {
3264 outline_range.end - selection_display_point
3265 } else {
3266 selection_display_point - outline_range.end
3267 };
3268
3269 (
3270 cmp::Reverse(outline.depth),
3271 distance_from_start + distance_from_end,
3272 )
3273 })
3274 .map(|(_, (_, outline))| *outline)
3275 .cloned();
3276
3277 let closest_container = match outline_item {
3278 Some(outline) => PanelEntry::Outline(OutlineEntry::Outline(OutlineEntryOutline {
3279 buffer_id,
3280 excerpt_id,
3281 outline,
3282 })),
3283 None => {
3284 self.cached_entries.iter().rev().find_map(|cached_entry| {
3285 match &cached_entry.entry {
3286 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
3287 if excerpt.buffer_id == buffer_id && excerpt.id == excerpt_id {
3288 Some(cached_entry.entry.clone())
3289 } else {
3290 None
3291 }
3292 }
3293 PanelEntry::Fs(
3294 FsEntry::ExternalFile(FsEntryExternalFile {
3295 buffer_id: file_buffer_id,
3296 excerpts: file_excerpts,
3297 })
3298 | FsEntry::File(FsEntryFile {
3299 buffer_id: file_buffer_id,
3300 excerpts: file_excerpts,
3301 ..
3302 }),
3303 ) => {
3304 if file_buffer_id == &buffer_id && file_excerpts.contains(&excerpt_id) {
3305 Some(cached_entry.entry.clone())
3306 } else {
3307 None
3308 }
3309 }
3310 _ => None,
3311 }
3312 })?
3313 }
3314 };
3315 Some(closest_container)
3316 }
3317
3318 fn fetch_outdated_outlines(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3319 let excerpt_fetch_ranges = self.excerpt_fetch_ranges(cx);
3320 if excerpt_fetch_ranges.is_empty() {
3321 return;
3322 }
3323
3324 let syntax_theme = cx.theme().syntax().clone();
3325 let first_update = Arc::new(AtomicBool::new(true));
3326 for (buffer_id, (buffer_snapshot, excerpt_ranges)) in excerpt_fetch_ranges {
3327 for (excerpt_id, excerpt_range) in excerpt_ranges {
3328 let syntax_theme = syntax_theme.clone();
3329 let buffer_snapshot = buffer_snapshot.clone();
3330 let first_update = first_update.clone();
3331 self.outline_fetch_tasks.insert(
3332 (buffer_id, excerpt_id),
3333 cx.spawn_in(window, async move |outline_panel, cx| {
3334 let buffer_language = buffer_snapshot.language().cloned();
3335 let fetched_outlines = cx
3336 .background_spawn(async move {
3337 let mut outlines = buffer_snapshot.outline_items_containing(
3338 excerpt_range.context,
3339 false,
3340 Some(&syntax_theme),
3341 );
3342 outlines.retain(|outline| {
3343 buffer_language.is_none()
3344 || buffer_language.as_ref()
3345 == buffer_snapshot.language_at(outline.range.start)
3346 });
3347
3348 let outlines_with_children = outlines
3349 .windows(2)
3350 .filter_map(|window| {
3351 let current = &window[0];
3352 let next = &window[1];
3353 if next.depth > current.depth {
3354 Some((current.range.clone(), current.depth))
3355 } else {
3356 None
3357 }
3358 })
3359 .collect::<HashSet<_>>();
3360
3361 (outlines, outlines_with_children)
3362 })
3363 .await;
3364
3365 let (fetched_outlines, outlines_with_children) = fetched_outlines;
3366
3367 outline_panel
3368 .update_in(cx, |outline_panel, window, cx| {
3369 let pending_default_depth =
3370 outline_panel.pending_default_expansion_depth.take();
3371
3372 let debounce =
3373 if first_update.fetch_and(false, atomic::Ordering::AcqRel) {
3374 None
3375 } else {
3376 Some(UPDATE_DEBOUNCE)
3377 };
3378
3379 if let Some(excerpt) = outline_panel
3380 .excerpts
3381 .entry(buffer_id)
3382 .or_default()
3383 .get_mut(&excerpt_id)
3384 {
3385 excerpt.outlines = ExcerptOutlines::Outlines(fetched_outlines);
3386
3387 if let Some(default_depth) = pending_default_depth
3388 && let ExcerptOutlines::Outlines(outlines) =
3389 &excerpt.outlines
3390 {
3391 outlines
3392 .iter()
3393 .filter(|outline| {
3394 (default_depth == 0
3395 || outline.depth >= default_depth)
3396 && outlines_with_children.contains(&(
3397 outline.range.clone(),
3398 outline.depth,
3399 ))
3400 })
3401 .for_each(|outline| {
3402 outline_panel.collapsed_entries.insert(
3403 CollapsedEntry::Outline(
3404 buffer_id,
3405 excerpt_id,
3406 outline.range.clone(),
3407 ),
3408 );
3409 });
3410 }
3411
3412 // Even if no outlines to check, we still need to update cached entries
3413 // to show the outline entries that were just fetched
3414 outline_panel.update_cached_entries(debounce, window, cx);
3415 }
3416 })
3417 .ok();
3418 }),
3419 );
3420 }
3421 }
3422 }
3423
3424 fn is_singleton_active(&self, cx: &App) -> bool {
3425 self.active_editor()
3426 .is_some_and(|active_editor| active_editor.read(cx).buffer().read(cx).is_singleton())
3427 }
3428
3429 fn invalidate_outlines(&mut self, ids: &[ExcerptId]) {
3430 self.outline_fetch_tasks.clear();
3431 let mut ids = ids.iter().collect::<HashSet<_>>();
3432 for excerpts in self.excerpts.values_mut() {
3433 ids.retain(|id| {
3434 if let Some(excerpt) = excerpts.get_mut(id) {
3435 excerpt.invalidate_outlines();
3436 false
3437 } else {
3438 true
3439 }
3440 });
3441 if ids.is_empty() {
3442 break;
3443 }
3444 }
3445 }
3446
3447 fn excerpt_fetch_ranges(
3448 &self,
3449 cx: &App,
3450 ) -> HashMap<
3451 BufferId,
3452 (
3453 BufferSnapshot,
3454 HashMap<ExcerptId, ExcerptRange<language::Anchor>>,
3455 ),
3456 > {
3457 self.fs_entries
3458 .iter()
3459 .fold(HashMap::default(), |mut excerpts_to_fetch, fs_entry| {
3460 match fs_entry {
3461 FsEntry::File(FsEntryFile {
3462 buffer_id,
3463 excerpts: file_excerpts,
3464 ..
3465 })
3466 | FsEntry::ExternalFile(FsEntryExternalFile {
3467 buffer_id,
3468 excerpts: file_excerpts,
3469 }) => {
3470 let excerpts = self.excerpts.get(buffer_id);
3471 for &file_excerpt in file_excerpts {
3472 if let Some(excerpt) = excerpts
3473 .and_then(|excerpts| excerpts.get(&file_excerpt))
3474 .filter(|excerpt| excerpt.should_fetch_outlines())
3475 {
3476 match excerpts_to_fetch.entry(*buffer_id) {
3477 hash_map::Entry::Occupied(mut o) => {
3478 o.get_mut().1.insert(file_excerpt, excerpt.range.clone());
3479 }
3480 hash_map::Entry::Vacant(v) => {
3481 if let Some(buffer_snapshot) =
3482 self.buffer_snapshot_for_id(*buffer_id, cx)
3483 {
3484 v.insert((buffer_snapshot, HashMap::default()))
3485 .1
3486 .insert(file_excerpt, excerpt.range.clone());
3487 }
3488 }
3489 }
3490 }
3491 }
3492 }
3493 FsEntry::Directory(..) => {}
3494 }
3495 excerpts_to_fetch
3496 })
3497 }
3498
3499 fn buffer_snapshot_for_id(&self, buffer_id: BufferId, cx: &App) -> Option<BufferSnapshot> {
3500 let editor = self.active_editor()?;
3501 Some(
3502 editor
3503 .read(cx)
3504 .buffer()
3505 .read(cx)
3506 .buffer(buffer_id)?
3507 .read(cx)
3508 .snapshot(),
3509 )
3510 }
3511
3512 fn abs_path(&self, entry: &PanelEntry, cx: &App) -> Option<PathBuf> {
3513 match entry {
3514 PanelEntry::Fs(
3515 FsEntry::File(FsEntryFile { buffer_id, .. })
3516 | FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }),
3517 ) => self
3518 .buffer_snapshot_for_id(*buffer_id, cx)
3519 .and_then(|buffer_snapshot| {
3520 let file = File::from_dyn(buffer_snapshot.file())?;
3521 file.worktree.read(cx).absolutize(&file.path).ok()
3522 }),
3523 PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory {
3524 worktree_id, entry, ..
3525 })) => self
3526 .project
3527 .read(cx)
3528 .worktree_for_id(*worktree_id, cx)?
3529 .read(cx)
3530 .absolutize(&entry.path)
3531 .ok(),
3532 PanelEntry::FoldedDirs(FoldedDirsEntry {
3533 worktree_id,
3534 entries: dirs,
3535 ..
3536 }) => dirs.last().and_then(|entry| {
3537 self.project
3538 .read(cx)
3539 .worktree_for_id(*worktree_id, cx)
3540 .and_then(|worktree| worktree.read(cx).absolutize(&entry.path).ok())
3541 }),
3542 PanelEntry::Search(_) | PanelEntry::Outline(..) => None,
3543 }
3544 }
3545
3546 fn relative_path(&self, entry: &FsEntry, cx: &App) -> Option<Arc<Path>> {
3547 match entry {
3548 FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }) => {
3549 let buffer_snapshot = self.buffer_snapshot_for_id(*buffer_id, cx)?;
3550 Some(buffer_snapshot.file()?.path().clone())
3551 }
3552 FsEntry::Directory(FsEntryDirectory { entry, .. }) => Some(entry.path.clone()),
3553 FsEntry::File(FsEntryFile { entry, .. }) => Some(entry.path.clone()),
3554 }
3555 }
3556
3557 fn update_cached_entries(
3558 &mut self,
3559 debounce: Option<Duration>,
3560 window: &mut Window,
3561 cx: &mut Context<OutlinePanel>,
3562 ) {
3563 if !self.active {
3564 return;
3565 }
3566
3567 let is_singleton = self.is_singleton_active(cx);
3568 let query = self.query(cx);
3569 self.updating_cached_entries = true;
3570 self.cached_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| {
3571 if let Some(debounce) = debounce {
3572 cx.background_executor().timer(debounce).await;
3573 }
3574 let Some(new_cached_entries) = outline_panel
3575 .update_in(cx, |outline_panel, window, cx| {
3576 outline_panel.generate_cached_entries(is_singleton, query, window, cx)
3577 })
3578 .ok()
3579 else {
3580 return;
3581 };
3582 let (new_cached_entries, max_width_item_index) = new_cached_entries.await;
3583 outline_panel
3584 .update_in(cx, |outline_panel, window, cx| {
3585 outline_panel.cached_entries = new_cached_entries;
3586 outline_panel.max_width_item_index = max_width_item_index;
3587 if (outline_panel.selected_entry.is_invalidated()
3588 || matches!(outline_panel.selected_entry, SelectedEntry::None))
3589 && let Some(new_selected_entry) =
3590 outline_panel.active_editor().and_then(|active_editor| {
3591 outline_panel.location_for_editor_selection(
3592 &active_editor,
3593 window,
3594 cx,
3595 )
3596 })
3597 {
3598 outline_panel.select_entry(new_selected_entry, false, window, cx);
3599 }
3600
3601 outline_panel.autoscroll(cx);
3602 outline_panel.updating_cached_entries = false;
3603 cx.notify();
3604 })
3605 .ok();
3606 });
3607 }
3608
3609 fn generate_cached_entries(
3610 &self,
3611 is_singleton: bool,
3612 query: Option<String>,
3613 window: &mut Window,
3614 cx: &mut Context<Self>,
3615 ) -> Task<(Vec<CachedEntry>, Option<usize>)> {
3616 let project = self.project.clone();
3617 let Some(active_editor) = self.active_editor() else {
3618 return Task::ready((Vec::new(), None));
3619 };
3620 cx.spawn_in(window, async move |outline_panel, cx| {
3621 let mut generation_state = GenerationState::default();
3622
3623 let Ok(()) = outline_panel.update(cx, |outline_panel, cx| {
3624 let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
3625 let mut folded_dirs_entry = None::<(usize, FoldedDirsEntry)>;
3626 let track_matches = query.is_some();
3627
3628 #[derive(Debug)]
3629 struct ParentStats {
3630 path: Arc<Path>,
3631 folded: bool,
3632 expanded: bool,
3633 depth: usize,
3634 }
3635 let mut parent_dirs = Vec::<ParentStats>::new();
3636 for entry in outline_panel.fs_entries.clone() {
3637 let is_expanded = outline_panel.is_expanded(&entry);
3638 let (depth, should_add) = match &entry {
3639 FsEntry::Directory(directory_entry) => {
3640 let mut should_add = true;
3641 let is_root = project
3642 .read(cx)
3643 .worktree_for_id(directory_entry.worktree_id, cx)
3644 .is_some_and(|worktree| {
3645 worktree.read(cx).root_entry() == Some(&directory_entry.entry)
3646 });
3647 let folded = auto_fold_dirs
3648 && !is_root
3649 && outline_panel
3650 .unfolded_dirs
3651 .get(&directory_entry.worktree_id)
3652 .is_none_or(|unfolded_dirs| {
3653 !unfolded_dirs.contains(&directory_entry.entry.id)
3654 });
3655 let fs_depth = outline_panel
3656 .fs_entries_depth
3657 .get(&(directory_entry.worktree_id, directory_entry.entry.id))
3658 .copied()
3659 .unwrap_or(0);
3660 while let Some(parent) = parent_dirs.last() {
3661 if !is_root && directory_entry.entry.path.starts_with(&parent.path)
3662 {
3663 break;
3664 }
3665 parent_dirs.pop();
3666 }
3667 let auto_fold = match parent_dirs.last() {
3668 Some(parent) => {
3669 parent.folded
3670 && Some(parent.path.as_ref())
3671 == directory_entry.entry.path.parent()
3672 && outline_panel
3673 .fs_children_count
3674 .get(&directory_entry.worktree_id)
3675 .and_then(|entries| {
3676 entries.get(&directory_entry.entry.path)
3677 })
3678 .copied()
3679 .unwrap_or_default()
3680 .may_be_fold_part()
3681 }
3682 None => false,
3683 };
3684 let folded = folded || auto_fold;
3685 let (depth, parent_expanded, parent_folded) = match parent_dirs.last() {
3686 Some(parent) => {
3687 let parent_folded = parent.folded;
3688 let parent_expanded = parent.expanded;
3689 let new_depth = if parent_folded {
3690 parent.depth
3691 } else {
3692 parent.depth + 1
3693 };
3694 parent_dirs.push(ParentStats {
3695 path: directory_entry.entry.path.clone(),
3696 folded,
3697 expanded: parent_expanded && is_expanded,
3698 depth: new_depth,
3699 });
3700 (new_depth, parent_expanded, parent_folded)
3701 }
3702 None => {
3703 parent_dirs.push(ParentStats {
3704 path: directory_entry.entry.path.clone(),
3705 folded,
3706 expanded: is_expanded,
3707 depth: fs_depth,
3708 });
3709 (fs_depth, true, false)
3710 }
3711 };
3712
3713 if let Some((folded_depth, mut folded_dirs)) = folded_dirs_entry.take()
3714 {
3715 if folded
3716 && directory_entry.worktree_id == folded_dirs.worktree_id
3717 && directory_entry.entry.path.parent()
3718 == folded_dirs
3719 .entries
3720 .last()
3721 .map(|entry| entry.path.as_ref())
3722 {
3723 folded_dirs.entries.push(directory_entry.entry.clone());
3724 folded_dirs_entry = Some((folded_depth, folded_dirs))
3725 } else {
3726 if !is_singleton {
3727 let start_of_collapsed_dir_sequence = !parent_expanded
3728 && parent_dirs
3729 .iter()
3730 .rev()
3731 .nth(folded_dirs.entries.len() + 1)
3732 .is_none_or(|parent| parent.expanded);
3733 if start_of_collapsed_dir_sequence
3734 || parent_expanded
3735 || query.is_some()
3736 {
3737 if parent_folded {
3738 folded_dirs
3739 .entries
3740 .push(directory_entry.entry.clone());
3741 should_add = false;
3742 }
3743 let new_folded_dirs =
3744 PanelEntry::FoldedDirs(folded_dirs.clone());
3745 outline_panel.push_entry(
3746 &mut generation_state,
3747 track_matches,
3748 new_folded_dirs,
3749 folded_depth,
3750 cx,
3751 );
3752 }
3753 }
3754
3755 folded_dirs_entry = if parent_folded {
3756 None
3757 } else {
3758 Some((
3759 depth,
3760 FoldedDirsEntry {
3761 worktree_id: directory_entry.worktree_id,
3762 entries: vec![directory_entry.entry.clone()],
3763 },
3764 ))
3765 };
3766 }
3767 } else if folded {
3768 folded_dirs_entry = Some((
3769 depth,
3770 FoldedDirsEntry {
3771 worktree_id: directory_entry.worktree_id,
3772 entries: vec![directory_entry.entry.clone()],
3773 },
3774 ));
3775 }
3776
3777 let should_add =
3778 should_add && parent_expanded && folded_dirs_entry.is_none();
3779 (depth, should_add)
3780 }
3781 FsEntry::ExternalFile(..) => {
3782 if let Some((folded_depth, folded_dir)) = folded_dirs_entry.take() {
3783 let parent_expanded = parent_dirs
3784 .iter()
3785 .rev()
3786 .find(|parent| {
3787 folded_dir
3788 .entries
3789 .iter()
3790 .all(|entry| entry.path != parent.path)
3791 })
3792 .is_none_or(|parent| parent.expanded);
3793 if !is_singleton && (parent_expanded || query.is_some()) {
3794 outline_panel.push_entry(
3795 &mut generation_state,
3796 track_matches,
3797 PanelEntry::FoldedDirs(folded_dir),
3798 folded_depth,
3799 cx,
3800 );
3801 }
3802 }
3803 parent_dirs.clear();
3804 (0, true)
3805 }
3806 FsEntry::File(file) => {
3807 if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() {
3808 let parent_expanded = parent_dirs
3809 .iter()
3810 .rev()
3811 .find(|parent| {
3812 folded_dirs
3813 .entries
3814 .iter()
3815 .all(|entry| entry.path != parent.path)
3816 })
3817 .is_none_or(|parent| parent.expanded);
3818 if !is_singleton && (parent_expanded || query.is_some()) {
3819 outline_panel.push_entry(
3820 &mut generation_state,
3821 track_matches,
3822 PanelEntry::FoldedDirs(folded_dirs),
3823 folded_depth,
3824 cx,
3825 );
3826 }
3827 }
3828
3829 let fs_depth = outline_panel
3830 .fs_entries_depth
3831 .get(&(file.worktree_id, file.entry.id))
3832 .copied()
3833 .unwrap_or(0);
3834 while let Some(parent) = parent_dirs.last() {
3835 if file.entry.path.starts_with(&parent.path) {
3836 break;
3837 }
3838 parent_dirs.pop();
3839 }
3840 match parent_dirs.last() {
3841 Some(parent) => {
3842 let new_depth = parent.depth + 1;
3843 (new_depth, parent.expanded)
3844 }
3845 None => (fs_depth, true),
3846 }
3847 }
3848 };
3849
3850 if !is_singleton
3851 && (should_add || (query.is_some() && folded_dirs_entry.is_none()))
3852 {
3853 outline_panel.push_entry(
3854 &mut generation_state,
3855 track_matches,
3856 PanelEntry::Fs(entry.clone()),
3857 depth,
3858 cx,
3859 );
3860 }
3861
3862 match outline_panel.mode {
3863 ItemsDisplayMode::Search(_) => {
3864 if is_singleton || query.is_some() || (should_add && is_expanded) {
3865 outline_panel.add_search_entries(
3866 &mut generation_state,
3867 &active_editor,
3868 entry.clone(),
3869 depth,
3870 query.clone(),
3871 is_singleton,
3872 cx,
3873 );
3874 }
3875 }
3876 ItemsDisplayMode::Outline => {
3877 let excerpts_to_consider =
3878 if is_singleton || query.is_some() || (should_add && is_expanded) {
3879 match &entry {
3880 FsEntry::File(FsEntryFile {
3881 buffer_id,
3882 excerpts,
3883 ..
3884 })
3885 | FsEntry::ExternalFile(FsEntryExternalFile {
3886 buffer_id,
3887 excerpts,
3888 ..
3889 }) => Some((*buffer_id, excerpts)),
3890 _ => None,
3891 }
3892 } else {
3893 None
3894 };
3895 if let Some((buffer_id, entry_excerpts)) = excerpts_to_consider
3896 && !active_editor.read(cx).is_buffer_folded(buffer_id, cx)
3897 {
3898 outline_panel.add_excerpt_entries(
3899 &mut generation_state,
3900 buffer_id,
3901 entry_excerpts,
3902 depth,
3903 track_matches,
3904 is_singleton,
3905 query.as_deref(),
3906 cx,
3907 );
3908 }
3909 }
3910 }
3911
3912 if is_singleton
3913 && matches!(entry, FsEntry::File(..) | FsEntry::ExternalFile(..))
3914 && !generation_state.entries.iter().any(|item| {
3915 matches!(item.entry, PanelEntry::Outline(..) | PanelEntry::Search(_))
3916 })
3917 {
3918 outline_panel.push_entry(
3919 &mut generation_state,
3920 track_matches,
3921 PanelEntry::Fs(entry.clone()),
3922 0,
3923 cx,
3924 );
3925 }
3926 }
3927
3928 if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() {
3929 let parent_expanded = parent_dirs
3930 .iter()
3931 .rev()
3932 .find(|parent| {
3933 folded_dirs
3934 .entries
3935 .iter()
3936 .all(|entry| entry.path != parent.path)
3937 })
3938 .is_none_or(|parent| parent.expanded);
3939 if parent_expanded || query.is_some() {
3940 outline_panel.push_entry(
3941 &mut generation_state,
3942 track_matches,
3943 PanelEntry::FoldedDirs(folded_dirs),
3944 folded_depth,
3945 cx,
3946 );
3947 }
3948 }
3949 }) else {
3950 return (Vec::new(), None);
3951 };
3952
3953 let Some(query) = query else {
3954 return (
3955 generation_state.entries,
3956 generation_state
3957 .max_width_estimate_and_index
3958 .map(|(_, index)| index),
3959 );
3960 };
3961
3962 let mut matched_ids = match_strings(
3963 &generation_state.match_candidates,
3964 &query,
3965 true,
3966 true,
3967 usize::MAX,
3968 &AtomicBool::default(),
3969 cx.background_executor().clone(),
3970 )
3971 .await
3972 .into_iter()
3973 .map(|string_match| (string_match.candidate_id, string_match))
3974 .collect::<HashMap<_, _>>();
3975
3976 let mut id = 0;
3977 generation_state.entries.retain_mut(|cached_entry| {
3978 let retain = match matched_ids.remove(&id) {
3979 Some(string_match) => {
3980 cached_entry.string_match = Some(string_match);
3981 true
3982 }
3983 None => false,
3984 };
3985 id += 1;
3986 retain
3987 });
3988
3989 (
3990 generation_state.entries,
3991 generation_state
3992 .max_width_estimate_and_index
3993 .map(|(_, index)| index),
3994 )
3995 })
3996 }
3997
3998 fn push_entry(
3999 &self,
4000 state: &mut GenerationState,
4001 track_matches: bool,
4002 entry: PanelEntry,
4003 depth: usize,
4004 cx: &mut App,
4005 ) {
4006 let entry = if let PanelEntry::FoldedDirs(folded_dirs_entry) = &entry {
4007 match folded_dirs_entry.entries.len() {
4008 0 => {
4009 debug_panic!("Empty folded dirs receiver");
4010 return;
4011 }
4012 1 => PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory {
4013 worktree_id: folded_dirs_entry.worktree_id,
4014 entry: folded_dirs_entry.entries[0].clone(),
4015 })),
4016 _ => entry,
4017 }
4018 } else {
4019 entry
4020 };
4021
4022 if track_matches {
4023 let id = state.entries.len();
4024 match &entry {
4025 PanelEntry::Fs(fs_entry) => {
4026 if let Some(file_name) =
4027 self.relative_path(fs_entry, cx).as_deref().map(file_name)
4028 {
4029 state
4030 .match_candidates
4031 .push(StringMatchCandidate::new(id, &file_name));
4032 }
4033 }
4034 PanelEntry::FoldedDirs(folded_dir_entry) => {
4035 let dir_names = self.dir_names_string(
4036 &folded_dir_entry.entries,
4037 folded_dir_entry.worktree_id,
4038 cx,
4039 );
4040 {
4041 state
4042 .match_candidates
4043 .push(StringMatchCandidate::new(id, &dir_names));
4044 }
4045 }
4046 PanelEntry::Outline(OutlineEntry::Outline(outline_entry)) => state
4047 .match_candidates
4048 .push(StringMatchCandidate::new(id, &outline_entry.outline.text)),
4049 PanelEntry::Outline(OutlineEntry::Excerpt(_)) => {}
4050 PanelEntry::Search(new_search_entry) => {
4051 if let Some(search_data) = new_search_entry.render_data.get() {
4052 state
4053 .match_candidates
4054 .push(StringMatchCandidate::new(id, &search_data.context_text));
4055 }
4056 }
4057 }
4058 }
4059
4060 let width_estimate = self.width_estimate(depth, &entry, cx);
4061 if Some(width_estimate)
4062 > state
4063 .max_width_estimate_and_index
4064 .map(|(estimate, _)| estimate)
4065 {
4066 state.max_width_estimate_and_index = Some((width_estimate, state.entries.len()));
4067 }
4068 state.entries.push(CachedEntry {
4069 depth,
4070 entry,
4071 string_match: None,
4072 });
4073 }
4074
4075 fn dir_names_string(&self, entries: &[GitEntry], worktree_id: WorktreeId, cx: &App) -> String {
4076 let dir_names_segment = entries
4077 .iter()
4078 .map(|entry| self.entry_name(&worktree_id, entry, cx))
4079 .collect::<PathBuf>();
4080 dir_names_segment.to_string_lossy().to_string()
4081 }
4082
4083 fn query(&self, cx: &App) -> Option<String> {
4084 let query = self.filter_editor.read(cx).text(cx);
4085 if query.trim().is_empty() {
4086 None
4087 } else {
4088 Some(query)
4089 }
4090 }
4091
4092 fn is_expanded(&self, entry: &FsEntry) -> bool {
4093 let entry_to_check = match entry {
4094 FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }) => {
4095 CollapsedEntry::ExternalFile(*buffer_id)
4096 }
4097 FsEntry::File(FsEntryFile {
4098 worktree_id,
4099 buffer_id,
4100 ..
4101 }) => CollapsedEntry::File(*worktree_id, *buffer_id),
4102 FsEntry::Directory(FsEntryDirectory {
4103 worktree_id, entry, ..
4104 }) => CollapsedEntry::Dir(*worktree_id, entry.id),
4105 };
4106 !self.collapsed_entries.contains(&entry_to_check)
4107 }
4108
4109 fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context<OutlinePanel>) -> bool {
4110 if !self.active {
4111 return false;
4112 }
4113
4114 let mut update_cached_items = false;
4115 update_cached_items |= self.update_search_matches(window, cx);
4116 self.fetch_outdated_outlines(window, cx);
4117 if update_cached_items {
4118 self.selected_entry.invalidate();
4119 }
4120 update_cached_items
4121 }
4122
4123 fn update_search_matches(
4124 &mut self,
4125 window: &mut Window,
4126 cx: &mut Context<OutlinePanel>,
4127 ) -> bool {
4128 if !self.active {
4129 return false;
4130 }
4131
4132 let project_search = self
4133 .active_item()
4134 .and_then(|item| item.downcast::<ProjectSearchView>());
4135 let project_search_matches = project_search
4136 .as_ref()
4137 .map(|project_search| project_search.read(cx).get_matches(cx))
4138 .unwrap_or_default();
4139
4140 let buffer_search = self
4141 .active_item()
4142 .as_deref()
4143 .and_then(|active_item| {
4144 self.workspace
4145 .upgrade()
4146 .and_then(|workspace| workspace.read(cx).pane_for(active_item))
4147 })
4148 .and_then(|pane| {
4149 pane.read(cx)
4150 .toolbar()
4151 .read(cx)
4152 .item_of_type::<BufferSearchBar>()
4153 });
4154 let buffer_search_matches = self
4155 .active_editor()
4156 .map(|active_editor| {
4157 active_editor.update(cx, |editor, cx| editor.get_matches(window, cx))
4158 })
4159 .unwrap_or_default();
4160
4161 let mut update_cached_entries = false;
4162 if buffer_search_matches.is_empty() && project_search_matches.is_empty() {
4163 if matches!(self.mode, ItemsDisplayMode::Search(_)) {
4164 self.mode = ItemsDisplayMode::Outline;
4165 update_cached_entries = true;
4166 }
4167 } else {
4168 let (kind, new_search_matches, new_search_query) = if buffer_search_matches.is_empty() {
4169 (
4170 SearchKind::Project,
4171 project_search_matches,
4172 project_search
4173 .map(|project_search| project_search.read(cx).search_query_text(cx))
4174 .unwrap_or_default(),
4175 )
4176 } else {
4177 (
4178 SearchKind::Buffer,
4179 buffer_search_matches,
4180 buffer_search
4181 .map(|buffer_search| buffer_search.read(cx).query(cx))
4182 .unwrap_or_default(),
4183 )
4184 };
4185
4186 let mut previous_matches = HashMap::default();
4187 update_cached_entries = match &mut self.mode {
4188 ItemsDisplayMode::Search(current_search_state) => {
4189 let update = current_search_state.query != new_search_query
4190 || current_search_state.kind != kind
4191 || current_search_state.matches.is_empty()
4192 || current_search_state.matches.iter().enumerate().any(
4193 |(i, (match_range, _))| new_search_matches.get(i) != Some(match_range),
4194 );
4195 if current_search_state.kind == kind {
4196 previous_matches.extend(current_search_state.matches.drain(..));
4197 }
4198 update
4199 }
4200 ItemsDisplayMode::Outline => true,
4201 };
4202 self.mode = ItemsDisplayMode::Search(SearchState::new(
4203 kind,
4204 new_search_query,
4205 previous_matches,
4206 new_search_matches,
4207 cx.theme().syntax().clone(),
4208 window,
4209 cx,
4210 ));
4211 }
4212 update_cached_entries
4213 }
4214
4215 fn add_excerpt_entries(
4216 &mut self,
4217 state: &mut GenerationState,
4218 buffer_id: BufferId,
4219 entries_to_add: &[ExcerptId],
4220 parent_depth: usize,
4221 track_matches: bool,
4222 is_singleton: bool,
4223 query: Option<&str>,
4224 cx: &mut Context<Self>,
4225 ) {
4226 if let Some(excerpts) = self.excerpts.get(&buffer_id) {
4227 let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx);
4228
4229 for &excerpt_id in entries_to_add {
4230 let Some(excerpt) = excerpts.get(&excerpt_id) else {
4231 continue;
4232 };
4233 let excerpt_depth = parent_depth + 1;
4234 self.push_entry(
4235 state,
4236 track_matches,
4237 PanelEntry::Outline(OutlineEntry::Excerpt(OutlineEntryExcerpt {
4238 buffer_id,
4239 id: excerpt_id,
4240 range: excerpt.range.clone(),
4241 })),
4242 excerpt_depth,
4243 cx,
4244 );
4245
4246 let mut outline_base_depth = excerpt_depth + 1;
4247 if is_singleton {
4248 outline_base_depth = 0;
4249 state.clear();
4250 } else if query.is_none()
4251 && self
4252 .collapsed_entries
4253 .contains(&CollapsedEntry::Excerpt(buffer_id, excerpt_id))
4254 {
4255 continue;
4256 }
4257
4258 let mut last_depth_at_level: Vec<Option<Range<Anchor>>> = vec![None; 10];
4259
4260 let all_outlines: Vec<_> = excerpt.iter_outlines().collect();
4261
4262 let mut outline_has_children = HashMap::default();
4263 let mut visible_outlines = Vec::new();
4264 let mut collapsed_state: Option<(usize, Range<Anchor>)> = None;
4265
4266 for (i, &outline) in all_outlines.iter().enumerate() {
4267 let has_children = all_outlines
4268 .get(i + 1)
4269 .map(|next| next.depth > outline.depth)
4270 .unwrap_or(false);
4271
4272 outline_has_children
4273 .insert((outline.range.clone(), outline.depth), has_children);
4274
4275 let mut should_include = true;
4276
4277 if let Some((collapsed_depth, collapsed_range)) = &collapsed_state {
4278 if outline.depth <= *collapsed_depth {
4279 collapsed_state = None;
4280 } else if let Some(buffer_snapshot) = buffer_snapshot.as_ref() {
4281 let outline_start = outline.range.start;
4282 if outline_start
4283 .cmp(&collapsed_range.start, buffer_snapshot)
4284 .is_ge()
4285 && outline_start
4286 .cmp(&collapsed_range.end, buffer_snapshot)
4287 .is_lt()
4288 {
4289 should_include = false; // Skip - inside collapsed range
4290 } else {
4291 collapsed_state = None;
4292 }
4293 }
4294 }
4295
4296 // Check if this outline itself is collapsed
4297 if should_include
4298 && self.collapsed_entries.contains(&CollapsedEntry::Outline(
4299 buffer_id,
4300 excerpt_id,
4301 outline.range.clone(),
4302 ))
4303 {
4304 collapsed_state = Some((outline.depth, outline.range.clone()));
4305 }
4306
4307 if should_include {
4308 visible_outlines.push(outline);
4309 }
4310 }
4311
4312 self.outline_children_cache
4313 .entry(buffer_id)
4314 .or_default()
4315 .extend(outline_has_children);
4316
4317 for outline in visible_outlines {
4318 let outline_entry = OutlineEntryOutline {
4319 buffer_id,
4320 excerpt_id,
4321 outline: outline.clone(),
4322 };
4323
4324 if outline.depth < last_depth_at_level.len() {
4325 last_depth_at_level[outline.depth] = Some(outline.range.clone());
4326 // Clear deeper levels when we go back to a shallower depth
4327 for d in (outline.depth + 1)..last_depth_at_level.len() {
4328 last_depth_at_level[d] = None;
4329 }
4330 }
4331
4332 self.push_entry(
4333 state,
4334 track_matches,
4335 PanelEntry::Outline(OutlineEntry::Outline(outline_entry)),
4336 outline_base_depth + outline.depth,
4337 cx,
4338 );
4339 }
4340 }
4341 }
4342 }
4343
4344 fn add_search_entries(
4345 &mut self,
4346 state: &mut GenerationState,
4347 active_editor: &Entity<Editor>,
4348 parent_entry: FsEntry,
4349 parent_depth: usize,
4350 filter_query: Option<String>,
4351 is_singleton: bool,
4352 cx: &mut Context<Self>,
4353 ) {
4354 let ItemsDisplayMode::Search(search_state) = &mut self.mode else {
4355 return;
4356 };
4357
4358 let kind = search_state.kind;
4359 let related_excerpts = match &parent_entry {
4360 FsEntry::Directory(_) => return,
4361 FsEntry::ExternalFile(external) => &external.excerpts,
4362 FsEntry::File(file) => &file.excerpts,
4363 }
4364 .iter()
4365 .copied()
4366 .collect::<HashSet<_>>();
4367
4368 let depth = if is_singleton { 0 } else { parent_depth + 1 };
4369 let new_search_matches = search_state
4370 .matches
4371 .iter()
4372 .filter(|(match_range, _)| {
4373 related_excerpts.contains(&match_range.start.excerpt_id)
4374 || related_excerpts.contains(&match_range.end.excerpt_id)
4375 })
4376 .filter(|(match_range, _)| {
4377 let editor = active_editor.read(cx);
4378 let snapshot = editor.buffer().read(cx).snapshot(cx);
4379 if let Some(buffer_id) = snapshot.buffer_id_for_anchor(match_range.start)
4380 && editor.is_buffer_folded(buffer_id, cx)
4381 {
4382 return false;
4383 }
4384 if let Some(buffer_id) = snapshot.buffer_id_for_anchor(match_range.end)
4385 && editor.is_buffer_folded(buffer_id, cx)
4386 {
4387 return false;
4388 }
4389 true
4390 });
4391
4392 let new_search_entries = new_search_matches
4393 .map(|(match_range, search_data)| SearchEntry {
4394 match_range: match_range.clone(),
4395 kind,
4396 render_data: Arc::clone(search_data),
4397 })
4398 .collect::<Vec<_>>();
4399 for new_search_entry in new_search_entries {
4400 self.push_entry(
4401 state,
4402 filter_query.is_some(),
4403 PanelEntry::Search(new_search_entry),
4404 depth,
4405 cx,
4406 );
4407 }
4408 }
4409
4410 fn active_editor(&self) -> Option<Entity<Editor>> {
4411 self.active_item.as_ref()?.active_editor.upgrade()
4412 }
4413
4414 fn active_item(&self) -> Option<Box<dyn ItemHandle>> {
4415 self.active_item.as_ref()?.item_handle.upgrade()
4416 }
4417
4418 fn should_replace_active_item(&self, new_active_item: &dyn ItemHandle) -> bool {
4419 self.active_item().is_none_or(|active_item| {
4420 !self.pinned && active_item.item_id() != new_active_item.item_id()
4421 })
4422 }
4423
4424 pub fn toggle_active_editor_pin(
4425 &mut self,
4426 _: &ToggleActiveEditorPin,
4427 window: &mut Window,
4428 cx: &mut Context<Self>,
4429 ) {
4430 self.pinned = !self.pinned;
4431 if !self.pinned
4432 && let Some((active_item, active_editor)) = self
4433 .workspace
4434 .upgrade()
4435 .and_then(|workspace| workspace_active_editor(workspace.read(cx), cx))
4436 && self.should_replace_active_item(active_item.as_ref())
4437 {
4438 self.replace_active_editor(active_item, active_editor, window, cx);
4439 }
4440
4441 cx.notify();
4442 }
4443
4444 fn selected_entry(&self) -> Option<&PanelEntry> {
4445 match &self.selected_entry {
4446 SelectedEntry::Invalidated(entry) => entry.as_ref(),
4447 SelectedEntry::Valid(entry, _) => Some(entry),
4448 SelectedEntry::None => None,
4449 }
4450 }
4451
4452 fn select_entry(
4453 &mut self,
4454 entry: PanelEntry,
4455 focus: bool,
4456 window: &mut Window,
4457 cx: &mut Context<Self>,
4458 ) {
4459 if focus {
4460 self.focus_handle.focus(window);
4461 }
4462 let ix = self
4463 .cached_entries
4464 .iter()
4465 .enumerate()
4466 .find(|(_, cached_entry)| &cached_entry.entry == &entry)
4467 .map(|(i, _)| i)
4468 .unwrap_or_default();
4469
4470 self.selected_entry = SelectedEntry::Valid(entry, ix);
4471
4472 self.autoscroll(cx);
4473 cx.notify();
4474 }
4475
4476 fn width_estimate(&self, depth: usize, entry: &PanelEntry, cx: &App) -> u64 {
4477 let item_text_chars = match entry {
4478 PanelEntry::Fs(FsEntry::ExternalFile(external)) => self
4479 .buffer_snapshot_for_id(external.buffer_id, cx)
4480 .and_then(|snapshot| {
4481 Some(snapshot.file()?.path().file_name()?.to_string_lossy().len())
4482 })
4483 .unwrap_or_default(),
4484 PanelEntry::Fs(FsEntry::Directory(directory)) => directory
4485 .entry
4486 .path
4487 .file_name()
4488 .map(|name| name.to_string_lossy().len())
4489 .unwrap_or_default(),
4490 PanelEntry::Fs(FsEntry::File(file)) => file
4491 .entry
4492 .path
4493 .file_name()
4494 .map(|name| name.to_string_lossy().len())
4495 .unwrap_or_default(),
4496 PanelEntry::FoldedDirs(folded_dirs) => {
4497 folded_dirs
4498 .entries
4499 .iter()
4500 .map(|dir| {
4501 dir.path
4502 .file_name()
4503 .map(|name| name.to_string_lossy().len())
4504 .unwrap_or_default()
4505 })
4506 .sum::<usize>()
4507 + folded_dirs.entries.len().saturating_sub(1) * MAIN_SEPARATOR_STR.len()
4508 }
4509 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => self
4510 .excerpt_label(excerpt.buffer_id, &excerpt.range, cx)
4511 .map(|label| label.len())
4512 .unwrap_or_default(),
4513 PanelEntry::Outline(OutlineEntry::Outline(entry)) => entry.outline.text.len(),
4514 PanelEntry::Search(search) => search
4515 .render_data
4516 .get()
4517 .map(|data| data.context_text.len())
4518 .unwrap_or_default(),
4519 };
4520
4521 (item_text_chars + depth) as u64
4522 }
4523
4524 fn render_main_contents(
4525 &mut self,
4526 query: Option<String>,
4527 show_indent_guides: bool,
4528 indent_size: f32,
4529 window: &mut Window,
4530 cx: &mut Context<Self>,
4531 ) -> impl IntoElement {
4532 let contents = if self.cached_entries.is_empty() {
4533 let header = if self.updating_fs_entries || self.updating_cached_entries {
4534 None
4535 } else if query.is_some() {
4536 Some("No matches for query")
4537 } else {
4538 Some("No outlines available")
4539 };
4540
4541 v_flex()
4542 .id("empty-outline-state")
4543 .flex_1()
4544 .justify_center()
4545 .size_full()
4546 .when_some(header, |panel, header| {
4547 panel
4548 .child(h_flex().justify_center().child(Label::new(header)))
4549 .when_some(query.clone(), |panel, query| {
4550 panel.child(h_flex().justify_center().child(Label::new(query)))
4551 })
4552 .child(
4553 h_flex()
4554 .pt(DynamicSpacing::Base04.rems(cx))
4555 .justify_center()
4556 .child({
4557 let keystroke =
4558 match self.position(window, cx) {
4559 DockPosition::Left => window
4560 .keystroke_text_for(&workspace::ToggleLeftDock),
4561 DockPosition::Bottom => window
4562 .keystroke_text_for(&workspace::ToggleBottomDock),
4563 DockPosition::Right => window
4564 .keystroke_text_for(&workspace::ToggleRightDock),
4565 };
4566 Label::new(format!("Toggle this panel with {keystroke}"))
4567 }),
4568 )
4569 })
4570 } else {
4571 let list_contents = {
4572 let items_len = self.cached_entries.len();
4573 let multi_buffer_snapshot = self
4574 .active_editor()
4575 .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx));
4576 uniform_list(
4577 "entries",
4578 items_len,
4579 cx.processor(move |outline_panel, range: Range<usize>, window, cx| {
4580 let entries = outline_panel.cached_entries.get(range);
4581 entries
4582 .map(|entries| entries.to_vec())
4583 .unwrap_or_default()
4584 .into_iter()
4585 .filter_map(|cached_entry| match cached_entry.entry {
4586 PanelEntry::Fs(entry) => Some(outline_panel.render_entry(
4587 &entry,
4588 cached_entry.depth,
4589 cached_entry.string_match.as_ref(),
4590 window,
4591 cx,
4592 )),
4593 PanelEntry::FoldedDirs(folded_dirs_entry) => {
4594 Some(outline_panel.render_folded_dirs(
4595 &folded_dirs_entry,
4596 cached_entry.depth,
4597 cached_entry.string_match.as_ref(),
4598 window,
4599 cx,
4600 ))
4601 }
4602 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
4603 outline_panel.render_excerpt(
4604 &excerpt,
4605 cached_entry.depth,
4606 window,
4607 cx,
4608 )
4609 }
4610 PanelEntry::Outline(OutlineEntry::Outline(entry)) => {
4611 Some(outline_panel.render_outline(
4612 &entry,
4613 cached_entry.depth,
4614 cached_entry.string_match.as_ref(),
4615 window,
4616 cx,
4617 ))
4618 }
4619 PanelEntry::Search(SearchEntry {
4620 match_range,
4621 render_data,
4622 kind,
4623 ..
4624 }) => outline_panel.render_search_match(
4625 multi_buffer_snapshot.as_ref(),
4626 &match_range,
4627 &render_data,
4628 kind,
4629 cached_entry.depth,
4630 cached_entry.string_match.as_ref(),
4631 window,
4632 cx,
4633 ),
4634 })
4635 .collect()
4636 }),
4637 )
4638 .with_sizing_behavior(ListSizingBehavior::Infer)
4639 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4640 .with_width_from_item(self.max_width_item_index)
4641 .track_scroll(self.scroll_handle.clone())
4642 .when(show_indent_guides, |list| {
4643 list.with_decoration(
4644 ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx))
4645 .with_compute_indents_fn(cx.entity(), |outline_panel, range, _, _| {
4646 let entries = outline_panel.cached_entries.get(range);
4647 if let Some(entries) = entries {
4648 entries.iter().map(|item| item.depth).collect()
4649 } else {
4650 smallvec::SmallVec::new()
4651 }
4652 })
4653 .with_render_fn(cx.entity(), move |outline_panel, params, _, _| {
4654 const LEFT_OFFSET: Pixels = px(14.);
4655
4656 let indent_size = params.indent_size;
4657 let item_height = params.item_height;
4658 let active_indent_guide_ix = find_active_indent_guide_ix(
4659 outline_panel,
4660 ¶ms.indent_guides,
4661 );
4662
4663 params
4664 .indent_guides
4665 .into_iter()
4666 .enumerate()
4667 .map(|(ix, layout)| {
4668 let bounds = Bounds::new(
4669 point(
4670 layout.offset.x * indent_size + LEFT_OFFSET,
4671 layout.offset.y * item_height,
4672 ),
4673 size(px(1.), layout.length * item_height),
4674 );
4675 ui::RenderedIndentGuide {
4676 bounds,
4677 layout,
4678 is_active: active_indent_guide_ix == Some(ix),
4679 hitbox: None,
4680 }
4681 })
4682 .collect()
4683 }),
4684 )
4685 })
4686 };
4687
4688 v_flex()
4689 .flex_shrink()
4690 .size_full()
4691 .child(list_contents.size_full().flex_shrink())
4692 .custom_scrollbars(
4693 Scrollbars::for_settings::<OutlinePanelSettings>()
4694 .tracked_scroll_handle(self.scroll_handle.clone())
4695 .with_track_along(
4696 ScrollAxes::Horizontal,
4697 cx.theme().colors().panel_background,
4698 )
4699 .tracked_entity(cx.entity_id()),
4700 window,
4701 cx,
4702 )
4703 }
4704 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4705 deferred(
4706 anchored()
4707 .position(*position)
4708 .anchor(gpui::Corner::TopLeft)
4709 .child(menu.clone()),
4710 )
4711 .with_priority(1)
4712 }));
4713
4714 v_flex().w_full().flex_1().overflow_hidden().child(contents)
4715 }
4716
4717 fn render_filter_footer(&mut self, pinned: bool, cx: &mut Context<Self>) -> Div {
4718 v_flex().flex_none().child(horizontal_separator(cx)).child(
4719 h_flex()
4720 .p_2()
4721 .w_full()
4722 .child(self.filter_editor.clone())
4723 .child(
4724 div().child(
4725 IconButton::new(
4726 "outline-panel-menu",
4727 if pinned {
4728 IconName::Unpin
4729 } else {
4730 IconName::Pin
4731 },
4732 )
4733 .tooltip(Tooltip::text(if pinned {
4734 "Unpin Outline"
4735 } else {
4736 "Pin Active Outline"
4737 }))
4738 .shape(IconButtonShape::Square)
4739 .on_click(cx.listener(
4740 |outline_panel, _, window, cx| {
4741 outline_panel.toggle_active_editor_pin(
4742 &ToggleActiveEditorPin,
4743 window,
4744 cx,
4745 );
4746 },
4747 )),
4748 ),
4749 ),
4750 )
4751 }
4752
4753 fn buffers_inside_directory(
4754 &self,
4755 dir_worktree: WorktreeId,
4756 dir_entry: &GitEntry,
4757 ) -> HashSet<BufferId> {
4758 if !dir_entry.is_dir() {
4759 debug_panic!("buffers_inside_directory called on a non-directory entry {dir_entry:?}");
4760 return HashSet::default();
4761 }
4762
4763 self.fs_entries
4764 .iter()
4765 .skip_while(|fs_entry| match fs_entry {
4766 FsEntry::Directory(directory) => {
4767 directory.worktree_id != dir_worktree || &directory.entry != dir_entry
4768 }
4769 _ => true,
4770 })
4771 .skip(1)
4772 .take_while(|fs_entry| match fs_entry {
4773 FsEntry::ExternalFile(..) => false,
4774 FsEntry::Directory(directory) => {
4775 directory.worktree_id == dir_worktree
4776 && directory.entry.path.starts_with(&dir_entry.path)
4777 }
4778 FsEntry::File(file) => {
4779 file.worktree_id == dir_worktree && file.entry.path.starts_with(&dir_entry.path)
4780 }
4781 })
4782 .filter_map(|fs_entry| match fs_entry {
4783 FsEntry::File(file) => Some(file.buffer_id),
4784 _ => None,
4785 })
4786 .collect()
4787 }
4788}
4789
4790fn workspace_active_editor(
4791 workspace: &Workspace,
4792 cx: &App,
4793) -> Option<(Box<dyn ItemHandle>, Entity<Editor>)> {
4794 let active_item = workspace.active_item(cx)?;
4795 let active_editor = active_item
4796 .act_as::<Editor>(cx)
4797 .filter(|editor| editor.read(cx).mode().is_full())?;
4798 Some((active_item, active_editor))
4799}
4800
4801fn back_to_common_visited_parent(
4802 visited_dirs: &mut Vec<(ProjectEntryId, Arc<Path>)>,
4803 worktree_id: &WorktreeId,
4804 new_entry: &Entry,
4805) -> Option<(WorktreeId, ProjectEntryId)> {
4806 while let Some((visited_dir_id, visited_path)) = visited_dirs.last() {
4807 match new_entry.path.parent() {
4808 Some(parent_path) => {
4809 if parent_path == visited_path.as_ref() {
4810 return Some((*worktree_id, *visited_dir_id));
4811 }
4812 }
4813 None => {
4814 break;
4815 }
4816 }
4817 visited_dirs.pop();
4818 }
4819 None
4820}
4821
4822fn file_name(path: &Path) -> String {
4823 let mut current_path = path;
4824 loop {
4825 if let Some(file_name) = current_path.file_name() {
4826 return file_name.to_string_lossy().into_owned();
4827 }
4828 match current_path.parent() {
4829 Some(parent) => current_path = parent,
4830 None => return path.to_string_lossy().into_owned(),
4831 }
4832 }
4833}
4834
4835impl Panel for OutlinePanel {
4836 fn persistent_name() -> &'static str {
4837 "Outline Panel"
4838 }
4839
4840 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4841 match OutlinePanelSettings::get_global(cx).dock {
4842 OutlinePanelDockPosition::Left => DockPosition::Left,
4843 OutlinePanelDockPosition::Right => DockPosition::Right,
4844 }
4845 }
4846
4847 fn position_is_valid(&self, position: DockPosition) -> bool {
4848 matches!(position, DockPosition::Left | DockPosition::Right)
4849 }
4850
4851 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4852 settings::update_settings_file::<OutlinePanelSettings>(
4853 self.fs.clone(),
4854 cx,
4855 move |settings, _| {
4856 let dock = match position {
4857 DockPosition::Left | DockPosition::Bottom => OutlinePanelDockPosition::Left,
4858 DockPosition::Right => OutlinePanelDockPosition::Right,
4859 };
4860 settings.dock = Some(dock);
4861 },
4862 );
4863 }
4864
4865 fn size(&self, _: &Window, cx: &App) -> Pixels {
4866 self.width
4867 .unwrap_or_else(|| OutlinePanelSettings::get_global(cx).default_width)
4868 }
4869
4870 fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
4871 self.width = size;
4872 cx.notify();
4873 cx.defer_in(window, |this, _, cx| {
4874 this.serialize(cx);
4875 });
4876 }
4877
4878 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4879 OutlinePanelSettings::get_global(cx)
4880 .button
4881 .then_some(IconName::ListTree)
4882 }
4883
4884 fn icon_tooltip(&self, _window: &Window, _: &App) -> Option<&'static str> {
4885 Some("Outline Panel")
4886 }
4887
4888 fn toggle_action(&self) -> Box<dyn Action> {
4889 Box::new(ToggleFocus)
4890 }
4891
4892 fn starts_open(&self, _window: &Window, _: &App) -> bool {
4893 self.active
4894 }
4895
4896 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
4897 cx.spawn_in(window, async move |outline_panel, cx| {
4898 outline_panel
4899 .update_in(cx, |outline_panel, window, cx| {
4900 let old_active = outline_panel.active;
4901 outline_panel.active = active;
4902 if old_active != active {
4903 if active
4904 && let Some((active_item, active_editor)) =
4905 outline_panel.workspace.upgrade().and_then(|workspace| {
4906 workspace_active_editor(workspace.read(cx), cx)
4907 })
4908 {
4909 if outline_panel.should_replace_active_item(active_item.as_ref()) {
4910 outline_panel.replace_active_editor(
4911 active_item,
4912 active_editor,
4913 window,
4914 cx,
4915 );
4916 } else {
4917 outline_panel.update_fs_entries(active_editor, None, window, cx)
4918 }
4919 return;
4920 }
4921
4922 if !outline_panel.pinned {
4923 outline_panel.clear_previous(window, cx);
4924 }
4925 }
4926 outline_panel.serialize(cx);
4927 })
4928 .ok();
4929 })
4930 .detach()
4931 }
4932
4933 fn activation_priority(&self) -> u32 {
4934 5
4935 }
4936}
4937
4938impl Focusable for OutlinePanel {
4939 fn focus_handle(&self, cx: &App) -> FocusHandle {
4940 self.filter_editor.focus_handle(cx)
4941 }
4942}
4943
4944impl EventEmitter<Event> for OutlinePanel {}
4945
4946impl EventEmitter<PanelEvent> for OutlinePanel {}
4947
4948impl Render for OutlinePanel {
4949 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4950 let (is_local, is_via_ssh) = self.project.read_with(cx, |project, _| {
4951 (project.is_local(), project.is_via_remote_server())
4952 });
4953 let query = self.query(cx);
4954 let pinned = self.pinned;
4955 let settings = OutlinePanelSettings::get_global(cx);
4956 let indent_size = settings.indent_size;
4957 let show_indent_guides = settings.indent_guides.show == ShowIndentGuides::Always;
4958
4959 let search_query = match &self.mode {
4960 ItemsDisplayMode::Search(search_query) => Some(search_query),
4961 _ => None,
4962 };
4963
4964 v_flex()
4965 .id("outline-panel")
4966 .size_full()
4967 .overflow_hidden()
4968 .relative()
4969 .key_context(self.dispatch_context(window, cx))
4970 .on_action(cx.listener(Self::open_selected_entry))
4971 .on_action(cx.listener(Self::cancel))
4972 .on_action(cx.listener(Self::select_next))
4973 .on_action(cx.listener(Self::select_previous))
4974 .on_action(cx.listener(Self::select_first))
4975 .on_action(cx.listener(Self::select_last))
4976 .on_action(cx.listener(Self::select_parent))
4977 .on_action(cx.listener(Self::expand_selected_entry))
4978 .on_action(cx.listener(Self::collapse_selected_entry))
4979 .on_action(cx.listener(Self::expand_all_entries))
4980 .on_action(cx.listener(Self::collapse_all_entries))
4981 .on_action(cx.listener(Self::copy_path))
4982 .on_action(cx.listener(Self::copy_relative_path))
4983 .on_action(cx.listener(Self::toggle_active_editor_pin))
4984 .on_action(cx.listener(Self::unfold_directory))
4985 .on_action(cx.listener(Self::fold_directory))
4986 .on_action(cx.listener(Self::open_excerpts))
4987 .on_action(cx.listener(Self::open_excerpts_split))
4988 .when(is_local, |el| {
4989 el.on_action(cx.listener(Self::reveal_in_finder))
4990 })
4991 .when(is_local || is_via_ssh, |el| {
4992 el.on_action(cx.listener(Self::open_in_terminal))
4993 })
4994 .on_mouse_down(
4995 MouseButton::Right,
4996 cx.listener(move |outline_panel, event: &MouseDownEvent, window, cx| {
4997 if let Some(entry) = outline_panel.selected_entry().cloned() {
4998 outline_panel.deploy_context_menu(event.position, entry, window, cx)
4999 } else if let Some(entry) = outline_panel.fs_entries.first().cloned() {
5000 outline_panel.deploy_context_menu(
5001 event.position,
5002 PanelEntry::Fs(entry),
5003 window,
5004 cx,
5005 )
5006 }
5007 }),
5008 )
5009 .track_focus(&self.focus_handle)
5010 .when_some(search_query, |outline_panel, search_state| {
5011 outline_panel.child(
5012 h_flex()
5013 .py_1p5()
5014 .px_2()
5015 .h(DynamicSpacing::Base32.px(cx))
5016 .flex_shrink_0()
5017 .border_b_1()
5018 .border_color(cx.theme().colors().border)
5019 .gap_0p5()
5020 .child(Label::new("Searching:").color(Color::Muted))
5021 .child(Label::new(search_state.query.to_string())),
5022 )
5023 })
5024 .child(self.render_main_contents(query, show_indent_guides, indent_size, window, cx))
5025 .child(self.render_filter_footer(pinned, cx))
5026 }
5027}
5028
5029fn find_active_indent_guide_ix(
5030 outline_panel: &OutlinePanel,
5031 candidates: &[IndentGuideLayout],
5032) -> Option<usize> {
5033 let SelectedEntry::Valid(_, target_ix) = &outline_panel.selected_entry else {
5034 return None;
5035 };
5036 let target_depth = outline_panel
5037 .cached_entries
5038 .get(*target_ix)
5039 .map(|cached_entry| cached_entry.depth)?;
5040
5041 let (target_ix, target_depth) = if let Some(target_depth) = outline_panel
5042 .cached_entries
5043 .get(target_ix + 1)
5044 .filter(|cached_entry| cached_entry.depth > target_depth)
5045 .map(|entry| entry.depth)
5046 {
5047 (target_ix + 1, target_depth.saturating_sub(1))
5048 } else {
5049 (*target_ix, target_depth.saturating_sub(1))
5050 };
5051
5052 candidates
5053 .iter()
5054 .enumerate()
5055 .find(|(_, guide)| {
5056 guide.offset.y <= target_ix
5057 && target_ix < guide.offset.y + guide.length
5058 && guide.offset.x == target_depth
5059 })
5060 .map(|(ix, _)| ix)
5061}
5062
5063fn subscribe_for_editor_events(
5064 editor: &Entity<Editor>,
5065 window: &mut Window,
5066 cx: &mut Context<OutlinePanel>,
5067) -> Subscription {
5068 let debounce = Some(UPDATE_DEBOUNCE);
5069 cx.subscribe_in(
5070 editor,
5071 window,
5072 move |outline_panel, editor, e: &EditorEvent, window, cx| {
5073 if !outline_panel.active {
5074 return;
5075 }
5076 match e {
5077 EditorEvent::SelectionsChanged { local: true } => {
5078 outline_panel.reveal_entry_for_selection(editor.clone(), window, cx);
5079 cx.notify();
5080 }
5081 EditorEvent::ExcerptsAdded { excerpts, .. } => {
5082 outline_panel
5083 .new_entries_for_fs_update
5084 .extend(excerpts.iter().map(|&(excerpt_id, _)| excerpt_id));
5085 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5086 }
5087 EditorEvent::ExcerptsRemoved { ids, .. } => {
5088 let mut ids = ids.iter().collect::<HashSet<_>>();
5089 for excerpts in outline_panel.excerpts.values_mut() {
5090 excerpts.retain(|excerpt_id, _| !ids.remove(excerpt_id));
5091 if ids.is_empty() {
5092 break;
5093 }
5094 }
5095 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5096 }
5097 EditorEvent::ExcerptsExpanded { ids } => {
5098 outline_panel.invalidate_outlines(ids);
5099 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
5100 if update_cached_items {
5101 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5102 }
5103 }
5104 EditorEvent::ExcerptsEdited { ids } => {
5105 outline_panel.invalidate_outlines(ids);
5106 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
5107 if update_cached_items {
5108 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5109 }
5110 }
5111 EditorEvent::BufferFoldToggled { ids, .. } => {
5112 outline_panel.invalidate_outlines(ids);
5113 let mut latest_unfolded_buffer_id = None;
5114 let mut latest_folded_buffer_id = None;
5115 let mut ignore_selections_change = false;
5116 outline_panel.new_entries_for_fs_update.extend(
5117 ids.iter()
5118 .filter(|id| {
5119 outline_panel
5120 .excerpts
5121 .iter()
5122 .find_map(|(buffer_id, excerpts)| {
5123 if excerpts.contains_key(id) {
5124 ignore_selections_change |= outline_panel
5125 .preserve_selection_on_buffer_fold_toggles
5126 .remove(buffer_id);
5127 Some(buffer_id)
5128 } else {
5129 None
5130 }
5131 })
5132 .map(|buffer_id| {
5133 if editor.read(cx).is_buffer_folded(*buffer_id, cx) {
5134 latest_folded_buffer_id = Some(*buffer_id);
5135 false
5136 } else {
5137 latest_unfolded_buffer_id = Some(*buffer_id);
5138 true
5139 }
5140 })
5141 .unwrap_or(true)
5142 })
5143 .copied(),
5144 );
5145 if !ignore_selections_change
5146 && let Some(entry_to_select) = latest_unfolded_buffer_id
5147 .or(latest_folded_buffer_id)
5148 .and_then(|toggled_buffer_id| {
5149 outline_panel.fs_entries.iter().find_map(
5150 |fs_entry| match fs_entry {
5151 FsEntry::ExternalFile(external) => {
5152 if external.buffer_id == toggled_buffer_id {
5153 Some(fs_entry.clone())
5154 } else {
5155 None
5156 }
5157 }
5158 FsEntry::File(FsEntryFile { buffer_id, .. }) => {
5159 if *buffer_id == toggled_buffer_id {
5160 Some(fs_entry.clone())
5161 } else {
5162 None
5163 }
5164 }
5165 FsEntry::Directory(..) => None,
5166 },
5167 )
5168 })
5169 .map(PanelEntry::Fs)
5170 {
5171 outline_panel.select_entry(entry_to_select, true, window, cx);
5172 }
5173
5174 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5175 }
5176 EditorEvent::Reparsed(buffer_id) => {
5177 if let Some(excerpts) = outline_panel.excerpts.get_mut(buffer_id) {
5178 for excerpt in excerpts.values_mut() {
5179 excerpt.invalidate_outlines();
5180 }
5181 }
5182 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
5183 if update_cached_items {
5184 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5185 }
5186 }
5187 _ => {}
5188 }
5189 },
5190 )
5191}
5192
5193fn empty_icon() -> AnyElement {
5194 h_flex()
5195 .size(IconSize::default().rems())
5196 .invisible()
5197 .flex_none()
5198 .into_any_element()
5199}
5200
5201fn horizontal_separator(cx: &mut App) -> Div {
5202 div().mx_2().border_primary(cx).border_t_1()
5203}
5204
5205#[derive(Debug, Default)]
5206struct GenerationState {
5207 entries: Vec<CachedEntry>,
5208 match_candidates: Vec<StringMatchCandidate>,
5209 max_width_estimate_and_index: Option<(u64, usize)>,
5210}
5211
5212impl GenerationState {
5213 fn clear(&mut self) {
5214 self.entries.clear();
5215 self.match_candidates.clear();
5216 self.max_width_estimate_and_index = None;
5217 }
5218}
5219
5220#[cfg(test)]
5221mod tests {
5222 use db::indoc;
5223 use gpui::{TestAppContext, VisualTestContext, WindowHandle};
5224 use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
5225 use pretty_assertions::assert_eq;
5226 use project::FakeFs;
5227 use search::project_search::{self, perform_project_search};
5228 use serde_json::json;
5229 use util::path;
5230 use workspace::{OpenOptions, OpenVisible};
5231
5232 use super::*;
5233
5234 const SELECTED_MARKER: &str = " <==== selected";
5235
5236 #[gpui::test(iterations = 10)]
5237 async fn test_project_search_results_toggling(cx: &mut TestAppContext) {
5238 init_test(cx);
5239
5240 let fs = FakeFs::new(cx.background_executor.clone());
5241 let root = path!("/rust-analyzer");
5242 populate_with_test_ra_project(&fs, root).await;
5243 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5244 project.read_with(cx, |project, _| {
5245 project.languages().add(Arc::new(rust_lang()))
5246 });
5247 let workspace = add_outline_panel(&project, cx).await;
5248 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5249 let outline_panel = outline_panel(&workspace, cx);
5250 outline_panel.update_in(cx, |outline_panel, window, cx| {
5251 outline_panel.set_active(true, window, cx)
5252 });
5253
5254 workspace
5255 .update(cx, |workspace, window, cx| {
5256 ProjectSearchView::deploy_search(
5257 workspace,
5258 &workspace::DeploySearch::default(),
5259 window,
5260 cx,
5261 )
5262 })
5263 .unwrap();
5264 let search_view = workspace
5265 .update(cx, |workspace, _, cx| {
5266 workspace
5267 .active_pane()
5268 .read(cx)
5269 .items()
5270 .find_map(|item| item.downcast::<ProjectSearchView>())
5271 .expect("Project search view expected to appear after new search event trigger")
5272 })
5273 .unwrap();
5274
5275 let query = "param_names_for_lifetime_elision_hints";
5276 perform_project_search(&search_view, query, cx);
5277 search_view.update(cx, |search_view, cx| {
5278 search_view
5279 .results_editor()
5280 .update(cx, |results_editor, cx| {
5281 assert_eq!(
5282 results_editor.display_text(cx).match_indices(query).count(),
5283 9
5284 );
5285 });
5286 });
5287
5288 let all_matches = format!(
5289 r#"{root}/
5290 crates/
5291 ide/src/
5292 inlay_hints/
5293 fn_lifetime_fn.rs
5294 search: match config.param_names_for_lifetime_elision_hints {{
5295 search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {{
5296 search: Some(it) if config.param_names_for_lifetime_elision_hints => {{
5297 search: InlayHintsConfig {{ param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }},
5298 inlay_hints.rs
5299 search: pub param_names_for_lifetime_elision_hints: bool,
5300 search: param_names_for_lifetime_elision_hints: self
5301 static_index.rs
5302 search: param_names_for_lifetime_elision_hints: false,
5303 rust-analyzer/src/
5304 cli/
5305 analysis_stats.rs
5306 search: param_names_for_lifetime_elision_hints: true,
5307 config.rs
5308 search: param_names_for_lifetime_elision_hints: self"#
5309 );
5310
5311 let select_first_in_all_matches = |line_to_select: &str| {
5312 assert!(all_matches.contains(line_to_select));
5313 all_matches.replacen(
5314 line_to_select,
5315 &format!("{line_to_select}{SELECTED_MARKER}"),
5316 1,
5317 )
5318 };
5319
5320 cx.executor()
5321 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5322 cx.run_until_parked();
5323 outline_panel.update(cx, |outline_panel, cx| {
5324 assert_eq!(
5325 display_entries(
5326 &project,
5327 &snapshot(outline_panel, cx),
5328 &outline_panel.cached_entries,
5329 outline_panel.selected_entry(),
5330 cx,
5331 ),
5332 select_first_in_all_matches(
5333 "search: match config.param_names_for_lifetime_elision_hints {"
5334 )
5335 );
5336 });
5337
5338 outline_panel.update_in(cx, |outline_panel, window, cx| {
5339 outline_panel.select_parent(&SelectParent, window, cx);
5340 assert_eq!(
5341 display_entries(
5342 &project,
5343 &snapshot(outline_panel, cx),
5344 &outline_panel.cached_entries,
5345 outline_panel.selected_entry(),
5346 cx,
5347 ),
5348 select_first_in_all_matches("fn_lifetime_fn.rs")
5349 );
5350 });
5351 outline_panel.update_in(cx, |outline_panel, window, cx| {
5352 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5353 });
5354 cx.executor()
5355 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5356 cx.run_until_parked();
5357 outline_panel.update(cx, |outline_panel, cx| {
5358 assert_eq!(
5359 display_entries(
5360 &project,
5361 &snapshot(outline_panel, cx),
5362 &outline_panel.cached_entries,
5363 outline_panel.selected_entry(),
5364 cx,
5365 ),
5366 format!(
5367 r#"{root}/
5368 crates/
5369 ide/src/
5370 inlay_hints/
5371 fn_lifetime_fn.rs{SELECTED_MARKER}
5372 inlay_hints.rs
5373 search: pub param_names_for_lifetime_elision_hints: bool,
5374 search: param_names_for_lifetime_elision_hints: self
5375 static_index.rs
5376 search: param_names_for_lifetime_elision_hints: false,
5377 rust-analyzer/src/
5378 cli/
5379 analysis_stats.rs
5380 search: param_names_for_lifetime_elision_hints: true,
5381 config.rs
5382 search: param_names_for_lifetime_elision_hints: self"#,
5383 )
5384 );
5385 });
5386
5387 outline_panel.update_in(cx, |outline_panel, window, cx| {
5388 outline_panel.expand_all_entries(&ExpandAllEntries, window, cx);
5389 });
5390 cx.executor()
5391 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5392 cx.run_until_parked();
5393 outline_panel.update_in(cx, |outline_panel, window, cx| {
5394 outline_panel.select_parent(&SelectParent, window, cx);
5395 assert_eq!(
5396 display_entries(
5397 &project,
5398 &snapshot(outline_panel, cx),
5399 &outline_panel.cached_entries,
5400 outline_panel.selected_entry(),
5401 cx,
5402 ),
5403 select_first_in_all_matches("inlay_hints/")
5404 );
5405 });
5406
5407 outline_panel.update_in(cx, |outline_panel, window, cx| {
5408 outline_panel.select_parent(&SelectParent, window, cx);
5409 assert_eq!(
5410 display_entries(
5411 &project,
5412 &snapshot(outline_panel, cx),
5413 &outline_panel.cached_entries,
5414 outline_panel.selected_entry(),
5415 cx,
5416 ),
5417 select_first_in_all_matches("ide/src/")
5418 );
5419 });
5420
5421 outline_panel.update_in(cx, |outline_panel, window, cx| {
5422 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5423 });
5424 cx.executor()
5425 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5426 cx.run_until_parked();
5427 outline_panel.update(cx, |outline_panel, cx| {
5428 assert_eq!(
5429 display_entries(
5430 &project,
5431 &snapshot(outline_panel, cx),
5432 &outline_panel.cached_entries,
5433 outline_panel.selected_entry(),
5434 cx,
5435 ),
5436 format!(
5437 r#"{root}/
5438 crates/
5439 ide/src/{SELECTED_MARKER}
5440 rust-analyzer/src/
5441 cli/
5442 analysis_stats.rs
5443 search: param_names_for_lifetime_elision_hints: true,
5444 config.rs
5445 search: param_names_for_lifetime_elision_hints: self"#,
5446 )
5447 );
5448 });
5449 outline_panel.update_in(cx, |outline_panel, window, cx| {
5450 outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
5451 });
5452 cx.executor()
5453 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5454 cx.run_until_parked();
5455 outline_panel.update(cx, |outline_panel, cx| {
5456 assert_eq!(
5457 display_entries(
5458 &project,
5459 &snapshot(outline_panel, cx),
5460 &outline_panel.cached_entries,
5461 outline_panel.selected_entry(),
5462 cx,
5463 ),
5464 select_first_in_all_matches("ide/src/")
5465 );
5466 });
5467 }
5468
5469 #[gpui::test(iterations = 10)]
5470 async fn test_item_filtering(cx: &mut TestAppContext) {
5471 init_test(cx);
5472
5473 let fs = FakeFs::new(cx.background_executor.clone());
5474 let root = path!("/rust-analyzer");
5475 populate_with_test_ra_project(&fs, root).await;
5476 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5477 project.read_with(cx, |project, _| {
5478 project.languages().add(Arc::new(rust_lang()))
5479 });
5480 let workspace = add_outline_panel(&project, cx).await;
5481 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5482 let outline_panel = outline_panel(&workspace, cx);
5483 outline_panel.update_in(cx, |outline_panel, window, cx| {
5484 outline_panel.set_active(true, window, cx)
5485 });
5486
5487 workspace
5488 .update(cx, |workspace, window, cx| {
5489 ProjectSearchView::deploy_search(
5490 workspace,
5491 &workspace::DeploySearch::default(),
5492 window,
5493 cx,
5494 )
5495 })
5496 .unwrap();
5497 let search_view = workspace
5498 .update(cx, |workspace, _, cx| {
5499 workspace
5500 .active_pane()
5501 .read(cx)
5502 .items()
5503 .find_map(|item| item.downcast::<ProjectSearchView>())
5504 .expect("Project search view expected to appear after new search event trigger")
5505 })
5506 .unwrap();
5507
5508 let query = "param_names_for_lifetime_elision_hints";
5509 perform_project_search(&search_view, query, cx);
5510 search_view.update(cx, |search_view, cx| {
5511 search_view
5512 .results_editor()
5513 .update(cx, |results_editor, cx| {
5514 assert_eq!(
5515 results_editor.display_text(cx).match_indices(query).count(),
5516 9
5517 );
5518 });
5519 });
5520 let all_matches = format!(
5521 r#"{root}/
5522 crates/
5523 ide/src/
5524 inlay_hints/
5525 fn_lifetime_fn.rs
5526 search: match config.param_names_for_lifetime_elision_hints {{
5527 search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {{
5528 search: Some(it) if config.param_names_for_lifetime_elision_hints => {{
5529 search: InlayHintsConfig {{ param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }},
5530 inlay_hints.rs
5531 search: pub param_names_for_lifetime_elision_hints: bool,
5532 search: param_names_for_lifetime_elision_hints: self
5533 static_index.rs
5534 search: param_names_for_lifetime_elision_hints: false,
5535 rust-analyzer/src/
5536 cli/
5537 analysis_stats.rs
5538 search: param_names_for_lifetime_elision_hints: true,
5539 config.rs
5540 search: param_names_for_lifetime_elision_hints: self"#
5541 );
5542
5543 cx.executor()
5544 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5545 cx.run_until_parked();
5546 outline_panel.update(cx, |outline_panel, cx| {
5547 assert_eq!(
5548 display_entries(
5549 &project,
5550 &snapshot(outline_panel, cx),
5551 &outline_panel.cached_entries,
5552 None,
5553 cx,
5554 ),
5555 all_matches,
5556 );
5557 });
5558
5559 let filter_text = "a";
5560 outline_panel.update_in(cx, |outline_panel, window, cx| {
5561 outline_panel.filter_editor.update(cx, |filter_editor, cx| {
5562 filter_editor.set_text(filter_text, window, cx);
5563 });
5564 });
5565 cx.executor()
5566 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5567 cx.run_until_parked();
5568
5569 outline_panel.update(cx, |outline_panel, cx| {
5570 assert_eq!(
5571 display_entries(
5572 &project,
5573 &snapshot(outline_panel, cx),
5574 &outline_panel.cached_entries,
5575 None,
5576 cx,
5577 ),
5578 all_matches
5579 .lines()
5580 .skip(1) // `/rust-analyzer/` is a root entry with path `` and it will be filtered out
5581 .filter(|item| item.contains(filter_text))
5582 .collect::<Vec<_>>()
5583 .join("\n"),
5584 );
5585 });
5586
5587 outline_panel.update_in(cx, |outline_panel, window, cx| {
5588 outline_panel.filter_editor.update(cx, |filter_editor, cx| {
5589 filter_editor.set_text("", window, cx);
5590 });
5591 });
5592 cx.executor()
5593 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5594 cx.run_until_parked();
5595 outline_panel.update(cx, |outline_panel, cx| {
5596 assert_eq!(
5597 display_entries(
5598 &project,
5599 &snapshot(outline_panel, cx),
5600 &outline_panel.cached_entries,
5601 None,
5602 cx,
5603 ),
5604 all_matches,
5605 );
5606 });
5607 }
5608
5609 #[gpui::test(iterations = 10)]
5610 async fn test_item_opening(cx: &mut TestAppContext) {
5611 init_test(cx);
5612
5613 let fs = FakeFs::new(cx.background_executor.clone());
5614 let root = path!("/rust-analyzer");
5615 populate_with_test_ra_project(&fs, root).await;
5616 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5617 project.read_with(cx, |project, _| {
5618 project.languages().add(Arc::new(rust_lang()))
5619 });
5620 let workspace = add_outline_panel(&project, cx).await;
5621 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5622 let outline_panel = outline_panel(&workspace, cx);
5623 outline_panel.update_in(cx, |outline_panel, window, cx| {
5624 outline_panel.set_active(true, window, cx)
5625 });
5626
5627 workspace
5628 .update(cx, |workspace, window, cx| {
5629 ProjectSearchView::deploy_search(
5630 workspace,
5631 &workspace::DeploySearch::default(),
5632 window,
5633 cx,
5634 )
5635 })
5636 .unwrap();
5637 let search_view = workspace
5638 .update(cx, |workspace, _, cx| {
5639 workspace
5640 .active_pane()
5641 .read(cx)
5642 .items()
5643 .find_map(|item| item.downcast::<ProjectSearchView>())
5644 .expect("Project search view expected to appear after new search event trigger")
5645 })
5646 .unwrap();
5647
5648 let query = "param_names_for_lifetime_elision_hints";
5649 perform_project_search(&search_view, query, cx);
5650 search_view.update(cx, |search_view, cx| {
5651 search_view
5652 .results_editor()
5653 .update(cx, |results_editor, cx| {
5654 assert_eq!(
5655 results_editor.display_text(cx).match_indices(query).count(),
5656 9
5657 );
5658 });
5659 });
5660 let all_matches = format!(
5661 r#"{root}/
5662 crates/
5663 ide/src/
5664 inlay_hints/
5665 fn_lifetime_fn.rs
5666 search: match config.param_names_for_lifetime_elision_hints {{
5667 search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {{
5668 search: Some(it) if config.param_names_for_lifetime_elision_hints => {{
5669 search: InlayHintsConfig {{ param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }},
5670 inlay_hints.rs
5671 search: pub param_names_for_lifetime_elision_hints: bool,
5672 search: param_names_for_lifetime_elision_hints: self
5673 static_index.rs
5674 search: param_names_for_lifetime_elision_hints: false,
5675 rust-analyzer/src/
5676 cli/
5677 analysis_stats.rs
5678 search: param_names_for_lifetime_elision_hints: true,
5679 config.rs
5680 search: param_names_for_lifetime_elision_hints: self"#
5681 );
5682 let select_first_in_all_matches = |line_to_select: &str| {
5683 assert!(all_matches.contains(line_to_select));
5684 all_matches.replacen(
5685 line_to_select,
5686 &format!("{line_to_select}{SELECTED_MARKER}"),
5687 1,
5688 )
5689 };
5690 cx.executor()
5691 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5692 cx.run_until_parked();
5693
5694 let active_editor = outline_panel.read_with(cx, |outline_panel, _| {
5695 outline_panel
5696 .active_editor()
5697 .expect("should have an active editor open")
5698 });
5699 let initial_outline_selection =
5700 "search: match config.param_names_for_lifetime_elision_hints {";
5701 outline_panel.update_in(cx, |outline_panel, window, cx| {
5702 assert_eq!(
5703 display_entries(
5704 &project,
5705 &snapshot(outline_panel, cx),
5706 &outline_panel.cached_entries,
5707 outline_panel.selected_entry(),
5708 cx,
5709 ),
5710 select_first_in_all_matches(initial_outline_selection)
5711 );
5712 assert_eq!(
5713 selected_row_text(&active_editor, cx),
5714 initial_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5715 "Should place the initial editor selection on the corresponding search result"
5716 );
5717
5718 outline_panel.select_next(&SelectNext, window, cx);
5719 outline_panel.select_next(&SelectNext, window, cx);
5720 });
5721
5722 let navigated_outline_selection =
5723 "search: Some(it) if config.param_names_for_lifetime_elision_hints => {";
5724 outline_panel.update(cx, |outline_panel, cx| {
5725 assert_eq!(
5726 display_entries(
5727 &project,
5728 &snapshot(outline_panel, cx),
5729 &outline_panel.cached_entries,
5730 outline_panel.selected_entry(),
5731 cx,
5732 ),
5733 select_first_in_all_matches(navigated_outline_selection)
5734 );
5735 });
5736 cx.executor()
5737 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5738 outline_panel.update(cx, |_, cx| {
5739 assert_eq!(
5740 selected_row_text(&active_editor, cx),
5741 navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5742 "Should still have the initial caret position after SelectNext calls"
5743 );
5744 });
5745
5746 outline_panel.update_in(cx, |outline_panel, window, cx| {
5747 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
5748 });
5749 outline_panel.update(cx, |_outline_panel, cx| {
5750 assert_eq!(
5751 selected_row_text(&active_editor, cx),
5752 navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5753 "After opening, should move the caret to the opened outline entry's position"
5754 );
5755 });
5756
5757 outline_panel.update_in(cx, |outline_panel, window, cx| {
5758 outline_panel.select_next(&SelectNext, window, cx);
5759 });
5760 let next_navigated_outline_selection = "search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },";
5761 outline_panel.update(cx, |outline_panel, cx| {
5762 assert_eq!(
5763 display_entries(
5764 &project,
5765 &snapshot(outline_panel, cx),
5766 &outline_panel.cached_entries,
5767 outline_panel.selected_entry(),
5768 cx,
5769 ),
5770 select_first_in_all_matches(next_navigated_outline_selection)
5771 );
5772 });
5773 cx.executor()
5774 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5775 outline_panel.update(cx, |_outline_panel, cx| {
5776 assert_eq!(
5777 selected_row_text(&active_editor, cx),
5778 next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5779 "Should again preserve the selection after another SelectNext call"
5780 );
5781 });
5782
5783 outline_panel.update_in(cx, |outline_panel, window, cx| {
5784 outline_panel.open_excerpts(&editor::actions::OpenExcerpts, window, cx);
5785 });
5786 cx.executor()
5787 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5788 cx.run_until_parked();
5789 let new_active_editor = outline_panel.read_with(cx, |outline_panel, _| {
5790 outline_panel
5791 .active_editor()
5792 .expect("should have an active editor open")
5793 });
5794 outline_panel.update(cx, |outline_panel, cx| {
5795 assert_ne!(
5796 active_editor, new_active_editor,
5797 "After opening an excerpt, new editor should be open"
5798 );
5799 assert_eq!(
5800 display_entries(
5801 &project,
5802 &snapshot(outline_panel, cx),
5803 &outline_panel.cached_entries,
5804 outline_panel.selected_entry(),
5805 cx,
5806 ),
5807 "fn_lifetime_fn.rs <==== selected"
5808 );
5809 assert_eq!(
5810 selected_row_text(&new_active_editor, cx),
5811 next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5812 "When opening the excerpt, should navigate to the place corresponding the outline entry"
5813 );
5814 });
5815 }
5816
5817 #[gpui::test]
5818 async fn test_multiple_workrees(cx: &mut TestAppContext) {
5819 init_test(cx);
5820
5821 let fs = FakeFs::new(cx.background_executor.clone());
5822 fs.insert_tree(
5823 path!("/root"),
5824 json!({
5825 "one": {
5826 "a.txt": "aaa aaa"
5827 },
5828 "two": {
5829 "b.txt": "a aaa"
5830 }
5831
5832 }),
5833 )
5834 .await;
5835 let project = Project::test(fs.clone(), [Path::new(path!("/root/one"))], cx).await;
5836 let workspace = add_outline_panel(&project, cx).await;
5837 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5838 let outline_panel = outline_panel(&workspace, cx);
5839 outline_panel.update_in(cx, |outline_panel, window, cx| {
5840 outline_panel.set_active(true, window, cx)
5841 });
5842
5843 let items = workspace
5844 .update(cx, |workspace, window, cx| {
5845 workspace.open_paths(
5846 vec![PathBuf::from(path!("/root/two"))],
5847 OpenOptions {
5848 visible: Some(OpenVisible::OnlyDirectories),
5849 ..Default::default()
5850 },
5851 None,
5852 window,
5853 cx,
5854 )
5855 })
5856 .unwrap()
5857 .await;
5858 assert_eq!(items.len(), 1, "Were opening another worktree directory");
5859 assert!(
5860 items[0].is_none(),
5861 "Directory should be opened successfully"
5862 );
5863
5864 workspace
5865 .update(cx, |workspace, window, cx| {
5866 ProjectSearchView::deploy_search(
5867 workspace,
5868 &workspace::DeploySearch::default(),
5869 window,
5870 cx,
5871 )
5872 })
5873 .unwrap();
5874 let search_view = workspace
5875 .update(cx, |workspace, _, cx| {
5876 workspace
5877 .active_pane()
5878 .read(cx)
5879 .items()
5880 .find_map(|item| item.downcast::<ProjectSearchView>())
5881 .expect("Project search view expected to appear after new search event trigger")
5882 })
5883 .unwrap();
5884
5885 let query = "aaa";
5886 perform_project_search(&search_view, query, cx);
5887 search_view.update(cx, |search_view, cx| {
5888 search_view
5889 .results_editor()
5890 .update(cx, |results_editor, cx| {
5891 assert_eq!(
5892 results_editor.display_text(cx).match_indices(query).count(),
5893 3
5894 );
5895 });
5896 });
5897
5898 cx.executor()
5899 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5900 cx.run_until_parked();
5901 outline_panel.update(cx, |outline_panel, cx| {
5902 assert_eq!(
5903 display_entries(
5904 &project,
5905 &snapshot(outline_panel, cx),
5906 &outline_panel.cached_entries,
5907 outline_panel.selected_entry(),
5908 cx,
5909 ),
5910 format!(
5911 r#"{}/
5912 a.txt
5913 search: aaa aaa <==== selected
5914 search: aaa aaa
5915{}/
5916 b.txt
5917 search: a aaa"#,
5918 path!("/root/one"),
5919 path!("/root/two"),
5920 ),
5921 );
5922 });
5923
5924 outline_panel.update_in(cx, |outline_panel, window, cx| {
5925 outline_panel.select_previous(&SelectPrevious, window, cx);
5926 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
5927 });
5928 cx.executor()
5929 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5930 cx.run_until_parked();
5931 outline_panel.update(cx, |outline_panel, cx| {
5932 assert_eq!(
5933 display_entries(
5934 &project,
5935 &snapshot(outline_panel, cx),
5936 &outline_panel.cached_entries,
5937 outline_panel.selected_entry(),
5938 cx,
5939 ),
5940 format!(
5941 r#"{}/
5942 a.txt <==== selected
5943{}/
5944 b.txt
5945 search: a aaa"#,
5946 path!("/root/one"),
5947 path!("/root/two"),
5948 ),
5949 );
5950 });
5951
5952 outline_panel.update_in(cx, |outline_panel, window, cx| {
5953 outline_panel.select_next(&SelectNext, window, cx);
5954 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
5955 });
5956 cx.executor()
5957 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5958 cx.run_until_parked();
5959 outline_panel.update(cx, |outline_panel, cx| {
5960 assert_eq!(
5961 display_entries(
5962 &project,
5963 &snapshot(outline_panel, cx),
5964 &outline_panel.cached_entries,
5965 outline_panel.selected_entry(),
5966 cx,
5967 ),
5968 format!(
5969 r#"{}/
5970 a.txt
5971{}/ <==== selected"#,
5972 path!("/root/one"),
5973 path!("/root/two"),
5974 ),
5975 );
5976 });
5977
5978 outline_panel.update_in(cx, |outline_panel, window, cx| {
5979 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
5980 });
5981 cx.executor()
5982 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5983 cx.run_until_parked();
5984 outline_panel.update(cx, |outline_panel, cx| {
5985 assert_eq!(
5986 display_entries(
5987 &project,
5988 &snapshot(outline_panel, cx),
5989 &outline_panel.cached_entries,
5990 outline_panel.selected_entry(),
5991 cx,
5992 ),
5993 format!(
5994 r#"{}/
5995 a.txt
5996{}/ <==== selected
5997 b.txt
5998 search: a aaa"#,
5999 path!("/root/one"),
6000 path!("/root/two"),
6001 )
6002 );
6003 });
6004 }
6005
6006 #[gpui::test]
6007 async fn test_navigating_in_singleton(cx: &mut TestAppContext) {
6008 init_test(cx);
6009
6010 let root = path!("/root");
6011 let fs = FakeFs::new(cx.background_executor.clone());
6012 fs.insert_tree(
6013 root,
6014 json!({
6015 "src": {
6016 "lib.rs": indoc!("
6017#[derive(Clone, Debug, PartialEq, Eq, Hash)]
6018struct OutlineEntryExcerpt {
6019 id: ExcerptId,
6020 buffer_id: BufferId,
6021 range: ExcerptRange<language::Anchor>,
6022}"),
6023 }
6024 }),
6025 )
6026 .await;
6027 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
6028 project.read_with(cx, |project, _| {
6029 project.languages().add(Arc::new(
6030 rust_lang()
6031 .with_outline_query(
6032 r#"
6033 (struct_item
6034 (visibility_modifier)? @context
6035 "struct" @context
6036 name: (_) @name) @item
6037
6038 (field_declaration
6039 (visibility_modifier)? @context
6040 name: (_) @name) @item
6041"#,
6042 )
6043 .unwrap(),
6044 ))
6045 });
6046 let workspace = add_outline_panel(&project, cx).await;
6047 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6048 let outline_panel = outline_panel(&workspace, cx);
6049 cx.update(|window, cx| {
6050 outline_panel.update(cx, |outline_panel, cx| {
6051 outline_panel.set_active(true, window, cx)
6052 });
6053 });
6054
6055 let _editor = workspace
6056 .update(cx, |workspace, window, cx| {
6057 workspace.open_abs_path(
6058 PathBuf::from(path!("/root/src/lib.rs")),
6059 OpenOptions {
6060 visible: Some(OpenVisible::All),
6061 ..Default::default()
6062 },
6063 window,
6064 cx,
6065 )
6066 })
6067 .unwrap()
6068 .await
6069 .expect("Failed to open Rust source file")
6070 .downcast::<Editor>()
6071 .expect("Should open an editor for Rust source file");
6072
6073 cx.executor()
6074 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6075 cx.run_until_parked();
6076 outline_panel.update(cx, |outline_panel, cx| {
6077 assert_eq!(
6078 display_entries(
6079 &project,
6080 &snapshot(outline_panel, cx),
6081 &outline_panel.cached_entries,
6082 outline_panel.selected_entry(),
6083 cx,
6084 ),
6085 indoc!(
6086 "
6087outline: struct OutlineEntryExcerpt
6088 outline: id
6089 outline: buffer_id
6090 outline: range"
6091 )
6092 );
6093 });
6094
6095 cx.update(|window, cx| {
6096 outline_panel.update(cx, |outline_panel, cx| {
6097 outline_panel.select_next(&SelectNext, window, cx);
6098 });
6099 });
6100 cx.executor()
6101 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6102 cx.run_until_parked();
6103 outline_panel.update(cx, |outline_panel, cx| {
6104 assert_eq!(
6105 display_entries(
6106 &project,
6107 &snapshot(outline_panel, cx),
6108 &outline_panel.cached_entries,
6109 outline_panel.selected_entry(),
6110 cx,
6111 ),
6112 indoc!(
6113 "
6114outline: struct OutlineEntryExcerpt <==== selected
6115 outline: id
6116 outline: buffer_id
6117 outline: range"
6118 )
6119 );
6120 });
6121
6122 cx.update(|window, cx| {
6123 outline_panel.update(cx, |outline_panel, cx| {
6124 outline_panel.select_next(&SelectNext, window, cx);
6125 });
6126 });
6127 cx.executor()
6128 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6129 cx.run_until_parked();
6130 outline_panel.update(cx, |outline_panel, cx| {
6131 assert_eq!(
6132 display_entries(
6133 &project,
6134 &snapshot(outline_panel, cx),
6135 &outline_panel.cached_entries,
6136 outline_panel.selected_entry(),
6137 cx,
6138 ),
6139 indoc!(
6140 "
6141outline: struct OutlineEntryExcerpt
6142 outline: id <==== selected
6143 outline: buffer_id
6144 outline: range"
6145 )
6146 );
6147 });
6148
6149 cx.update(|window, cx| {
6150 outline_panel.update(cx, |outline_panel, cx| {
6151 outline_panel.select_next(&SelectNext, window, cx);
6152 });
6153 });
6154 cx.executor()
6155 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6156 cx.run_until_parked();
6157 outline_panel.update(cx, |outline_panel, cx| {
6158 assert_eq!(
6159 display_entries(
6160 &project,
6161 &snapshot(outline_panel, cx),
6162 &outline_panel.cached_entries,
6163 outline_panel.selected_entry(),
6164 cx,
6165 ),
6166 indoc!(
6167 "
6168outline: struct OutlineEntryExcerpt
6169 outline: id
6170 outline: buffer_id <==== selected
6171 outline: range"
6172 )
6173 );
6174 });
6175
6176 cx.update(|window, cx| {
6177 outline_panel.update(cx, |outline_panel, cx| {
6178 outline_panel.select_next(&SelectNext, window, cx);
6179 });
6180 });
6181 cx.executor()
6182 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6183 cx.run_until_parked();
6184 outline_panel.update(cx, |outline_panel, cx| {
6185 assert_eq!(
6186 display_entries(
6187 &project,
6188 &snapshot(outline_panel, cx),
6189 &outline_panel.cached_entries,
6190 outline_panel.selected_entry(),
6191 cx,
6192 ),
6193 indoc!(
6194 "
6195outline: struct OutlineEntryExcerpt
6196 outline: id
6197 outline: buffer_id
6198 outline: range <==== selected"
6199 )
6200 );
6201 });
6202
6203 cx.update(|window, cx| {
6204 outline_panel.update(cx, |outline_panel, cx| {
6205 outline_panel.select_next(&SelectNext, window, cx);
6206 });
6207 });
6208 cx.executor()
6209 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6210 cx.run_until_parked();
6211 outline_panel.update(cx, |outline_panel, cx| {
6212 assert_eq!(
6213 display_entries(
6214 &project,
6215 &snapshot(outline_panel, cx),
6216 &outline_panel.cached_entries,
6217 outline_panel.selected_entry(),
6218 cx,
6219 ),
6220 indoc!(
6221 "
6222outline: struct OutlineEntryExcerpt <==== selected
6223 outline: id
6224 outline: buffer_id
6225 outline: range"
6226 )
6227 );
6228 });
6229
6230 cx.update(|window, cx| {
6231 outline_panel.update(cx, |outline_panel, cx| {
6232 outline_panel.select_previous(&SelectPrevious, window, cx);
6233 });
6234 });
6235 cx.executor()
6236 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6237 cx.run_until_parked();
6238 outline_panel.update(cx, |outline_panel, cx| {
6239 assert_eq!(
6240 display_entries(
6241 &project,
6242 &snapshot(outline_panel, cx),
6243 &outline_panel.cached_entries,
6244 outline_panel.selected_entry(),
6245 cx,
6246 ),
6247 indoc!(
6248 "
6249outline: struct OutlineEntryExcerpt
6250 outline: id
6251 outline: buffer_id
6252 outline: range <==== selected"
6253 )
6254 );
6255 });
6256
6257 cx.update(|window, cx| {
6258 outline_panel.update(cx, |outline_panel, cx| {
6259 outline_panel.select_previous(&SelectPrevious, window, cx);
6260 });
6261 });
6262 cx.executor()
6263 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6264 cx.run_until_parked();
6265 outline_panel.update(cx, |outline_panel, cx| {
6266 assert_eq!(
6267 display_entries(
6268 &project,
6269 &snapshot(outline_panel, cx),
6270 &outline_panel.cached_entries,
6271 outline_panel.selected_entry(),
6272 cx,
6273 ),
6274 indoc!(
6275 "
6276outline: struct OutlineEntryExcerpt
6277 outline: id
6278 outline: buffer_id <==== selected
6279 outline: range"
6280 )
6281 );
6282 });
6283
6284 cx.update(|window, cx| {
6285 outline_panel.update(cx, |outline_panel, cx| {
6286 outline_panel.select_previous(&SelectPrevious, window, cx);
6287 });
6288 });
6289 cx.executor()
6290 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6291 cx.run_until_parked();
6292 outline_panel.update(cx, |outline_panel, cx| {
6293 assert_eq!(
6294 display_entries(
6295 &project,
6296 &snapshot(outline_panel, cx),
6297 &outline_panel.cached_entries,
6298 outline_panel.selected_entry(),
6299 cx,
6300 ),
6301 indoc!(
6302 "
6303outline: struct OutlineEntryExcerpt
6304 outline: id <==== selected
6305 outline: buffer_id
6306 outline: range"
6307 )
6308 );
6309 });
6310
6311 cx.update(|window, cx| {
6312 outline_panel.update(cx, |outline_panel, cx| {
6313 outline_panel.select_previous(&SelectPrevious, window, cx);
6314 });
6315 });
6316 cx.executor()
6317 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6318 cx.run_until_parked();
6319 outline_panel.update(cx, |outline_panel, cx| {
6320 assert_eq!(
6321 display_entries(
6322 &project,
6323 &snapshot(outline_panel, cx),
6324 &outline_panel.cached_entries,
6325 outline_panel.selected_entry(),
6326 cx,
6327 ),
6328 indoc!(
6329 "
6330outline: struct OutlineEntryExcerpt <==== selected
6331 outline: id
6332 outline: buffer_id
6333 outline: range"
6334 )
6335 );
6336 });
6337
6338 cx.update(|window, cx| {
6339 outline_panel.update(cx, |outline_panel, cx| {
6340 outline_panel.select_previous(&SelectPrevious, window, cx);
6341 });
6342 });
6343 cx.executor()
6344 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6345 cx.run_until_parked();
6346 outline_panel.update(cx, |outline_panel, cx| {
6347 assert_eq!(
6348 display_entries(
6349 &project,
6350 &snapshot(outline_panel, cx),
6351 &outline_panel.cached_entries,
6352 outline_panel.selected_entry(),
6353 cx,
6354 ),
6355 indoc!(
6356 "
6357outline: struct OutlineEntryExcerpt
6358 outline: id
6359 outline: buffer_id
6360 outline: range <==== selected"
6361 )
6362 );
6363 });
6364 }
6365
6366 #[gpui::test(iterations = 10)]
6367 async fn test_frontend_repo_structure(cx: &mut TestAppContext) {
6368 init_test(cx);
6369
6370 let root = path!("/frontend-project");
6371 let fs = FakeFs::new(cx.background_executor.clone());
6372 fs.insert_tree(
6373 root,
6374 json!({
6375 "public": {
6376 "lottie": {
6377 "syntax-tree.json": r#"{ "something": "static" }"#
6378 }
6379 },
6380 "src": {
6381 "app": {
6382 "(site)": {
6383 "(about)": {
6384 "jobs": {
6385 "[slug]": {
6386 "page.tsx": r#"static"#
6387 }
6388 }
6389 },
6390 "(blog)": {
6391 "post": {
6392 "[slug]": {
6393 "page.tsx": r#"static"#
6394 }
6395 }
6396 },
6397 }
6398 },
6399 "components": {
6400 "ErrorBoundary.tsx": r#"static"#,
6401 }
6402 }
6403
6404 }),
6405 )
6406 .await;
6407 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
6408 let workspace = add_outline_panel(&project, cx).await;
6409 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6410 let outline_panel = outline_panel(&workspace, cx);
6411 outline_panel.update_in(cx, |outline_panel, window, cx| {
6412 outline_panel.set_active(true, window, cx)
6413 });
6414
6415 workspace
6416 .update(cx, |workspace, window, cx| {
6417 ProjectSearchView::deploy_search(
6418 workspace,
6419 &workspace::DeploySearch::default(),
6420 window,
6421 cx,
6422 )
6423 })
6424 .unwrap();
6425 let search_view = workspace
6426 .update(cx, |workspace, _, cx| {
6427 workspace
6428 .active_pane()
6429 .read(cx)
6430 .items()
6431 .find_map(|item| item.downcast::<ProjectSearchView>())
6432 .expect("Project search view expected to appear after new search event trigger")
6433 })
6434 .unwrap();
6435
6436 let query = "static";
6437 perform_project_search(&search_view, query, cx);
6438 search_view.update(cx, |search_view, cx| {
6439 search_view
6440 .results_editor()
6441 .update(cx, |results_editor, cx| {
6442 assert_eq!(
6443 results_editor.display_text(cx).match_indices(query).count(),
6444 4
6445 );
6446 });
6447 });
6448
6449 cx.executor()
6450 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6451 cx.run_until_parked();
6452 outline_panel.update(cx, |outline_panel, cx| {
6453 assert_eq!(
6454 display_entries(
6455 &project,
6456 &snapshot(outline_panel, cx),
6457 &outline_panel.cached_entries,
6458 outline_panel.selected_entry(),
6459 cx,
6460 ),
6461 format!(
6462 r#"{root}/
6463 public/lottie/
6464 syntax-tree.json
6465 search: {{ "something": "static" }} <==== selected
6466 src/
6467 app/(site)/
6468 (about)/jobs/[slug]/
6469 page.tsx
6470 search: static
6471 (blog)/post/[slug]/
6472 page.tsx
6473 search: static
6474 components/
6475 ErrorBoundary.tsx
6476 search: static"#
6477 )
6478 );
6479 });
6480
6481 outline_panel.update_in(cx, |outline_panel, window, cx| {
6482 // Move to 5th element in the list, 3 items down.
6483 for _ in 0..2 {
6484 outline_panel.select_next(&SelectNext, window, cx);
6485 }
6486 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
6487 });
6488 cx.executor()
6489 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6490 cx.run_until_parked();
6491 outline_panel.update(cx, |outline_panel, cx| {
6492 assert_eq!(
6493 display_entries(
6494 &project,
6495 &snapshot(outline_panel, cx),
6496 &outline_panel.cached_entries,
6497 outline_panel.selected_entry(),
6498 cx,
6499 ),
6500 format!(
6501 r#"{root}/
6502 public/lottie/
6503 syntax-tree.json
6504 search: {{ "something": "static" }}
6505 src/
6506 app/(site)/ <==== selected
6507 components/
6508 ErrorBoundary.tsx
6509 search: static"#
6510 )
6511 );
6512 });
6513
6514 outline_panel.update_in(cx, |outline_panel, window, cx| {
6515 // Move to the next visible non-FS entry
6516 for _ in 0..3 {
6517 outline_panel.select_next(&SelectNext, window, cx);
6518 }
6519 });
6520 cx.run_until_parked();
6521 outline_panel.update(cx, |outline_panel, cx| {
6522 assert_eq!(
6523 display_entries(
6524 &project,
6525 &snapshot(outline_panel, cx),
6526 &outline_panel.cached_entries,
6527 outline_panel.selected_entry(),
6528 cx,
6529 ),
6530 format!(
6531 r#"{root}/
6532 public/lottie/
6533 syntax-tree.json
6534 search: {{ "something": "static" }}
6535 src/
6536 app/(site)/
6537 components/
6538 ErrorBoundary.tsx
6539 search: static <==== selected"#
6540 )
6541 );
6542 });
6543
6544 outline_panel.update_in(cx, |outline_panel, window, cx| {
6545 outline_panel
6546 .active_editor()
6547 .expect("Should have an active editor")
6548 .update(cx, |editor, cx| {
6549 editor.toggle_fold(&editor::actions::ToggleFold, window, cx)
6550 });
6551 });
6552 cx.executor()
6553 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6554 cx.run_until_parked();
6555 outline_panel.update(cx, |outline_panel, cx| {
6556 assert_eq!(
6557 display_entries(
6558 &project,
6559 &snapshot(outline_panel, cx),
6560 &outline_panel.cached_entries,
6561 outline_panel.selected_entry(),
6562 cx,
6563 ),
6564 format!(
6565 r#"{root}/
6566 public/lottie/
6567 syntax-tree.json
6568 search: {{ "something": "static" }}
6569 src/
6570 app/(site)/
6571 components/
6572 ErrorBoundary.tsx <==== selected"#
6573 )
6574 );
6575 });
6576
6577 outline_panel.update_in(cx, |outline_panel, window, cx| {
6578 outline_panel
6579 .active_editor()
6580 .expect("Should have an active editor")
6581 .update(cx, |editor, cx| {
6582 editor.toggle_fold(&editor::actions::ToggleFold, window, cx)
6583 });
6584 });
6585 cx.executor()
6586 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6587 cx.run_until_parked();
6588 outline_panel.update(cx, |outline_panel, cx| {
6589 assert_eq!(
6590 display_entries(
6591 &project,
6592 &snapshot(outline_panel, cx),
6593 &outline_panel.cached_entries,
6594 outline_panel.selected_entry(),
6595 cx,
6596 ),
6597 format!(
6598 r#"{root}/
6599 public/lottie/
6600 syntax-tree.json
6601 search: {{ "something": "static" }}
6602 src/
6603 app/(site)/
6604 components/
6605 ErrorBoundary.tsx <==== selected
6606 search: static"#
6607 )
6608 );
6609 });
6610 }
6611
6612 async fn add_outline_panel(
6613 project: &Entity<Project>,
6614 cx: &mut TestAppContext,
6615 ) -> WindowHandle<Workspace> {
6616 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6617
6618 let outline_panel = window
6619 .update(cx, |_, window, cx| {
6620 cx.spawn_in(window, async |this, cx| {
6621 OutlinePanel::load(this, cx.clone()).await
6622 })
6623 })
6624 .unwrap()
6625 .await
6626 .expect("Failed to load outline panel");
6627
6628 window
6629 .update(cx, |workspace, window, cx| {
6630 workspace.add_panel(outline_panel, window, cx);
6631 })
6632 .unwrap();
6633 window
6634 }
6635
6636 fn outline_panel(
6637 workspace: &WindowHandle<Workspace>,
6638 cx: &mut TestAppContext,
6639 ) -> Entity<OutlinePanel> {
6640 workspace
6641 .update(cx, |workspace, _, cx| {
6642 workspace
6643 .panel::<OutlinePanel>(cx)
6644 .expect("no outline panel")
6645 })
6646 .unwrap()
6647 }
6648
6649 fn display_entries(
6650 project: &Entity<Project>,
6651 multi_buffer_snapshot: &MultiBufferSnapshot,
6652 cached_entries: &[CachedEntry],
6653 selected_entry: Option<&PanelEntry>,
6654 cx: &mut App,
6655 ) -> String {
6656 let mut display_string = String::new();
6657 for entry in cached_entries {
6658 if !display_string.is_empty() {
6659 display_string += "\n";
6660 }
6661 for _ in 0..entry.depth {
6662 display_string += " ";
6663 }
6664 display_string += &match &entry.entry {
6665 PanelEntry::Fs(entry) => match entry {
6666 FsEntry::ExternalFile(_) => {
6667 panic!("Did not cover external files with tests")
6668 }
6669 FsEntry::Directory(directory) => {
6670 match project
6671 .read(cx)
6672 .worktree_for_id(directory.worktree_id, cx)
6673 .and_then(|worktree| {
6674 if worktree.read(cx).root_entry() == Some(&directory.entry.entry) {
6675 Some(worktree.read(cx).abs_path())
6676 } else {
6677 None
6678 }
6679 }) {
6680 Some(root_path) => format!(
6681 "{}/{}",
6682 root_path.display(),
6683 directory.entry.path.display(),
6684 ),
6685 None => format!(
6686 "{}/",
6687 directory
6688 .entry
6689 .path
6690 .file_name()
6691 .unwrap_or_default()
6692 .to_string_lossy()
6693 ),
6694 }
6695 }
6696 FsEntry::File(file) => file
6697 .entry
6698 .path
6699 .file_name()
6700 .map(|name| name.to_string_lossy().to_string())
6701 .unwrap_or_default(),
6702 },
6703 PanelEntry::FoldedDirs(folded_dirs) => folded_dirs
6704 .entries
6705 .iter()
6706 .filter_map(|dir| dir.path.file_name())
6707 .map(|name| name.to_string_lossy().to_string() + "/")
6708 .collect(),
6709 PanelEntry::Outline(outline_entry) => match outline_entry {
6710 OutlineEntry::Excerpt(_) => continue,
6711 OutlineEntry::Outline(outline_entry) => {
6712 format!("outline: {}", outline_entry.outline.text)
6713 }
6714 },
6715 PanelEntry::Search(search_entry) => {
6716 format!(
6717 "search: {}",
6718 search_entry
6719 .render_data
6720 .get_or_init(|| SearchData::new(
6721 &search_entry.match_range,
6722 multi_buffer_snapshot
6723 ))
6724 .context_text
6725 )
6726 }
6727 };
6728
6729 if Some(&entry.entry) == selected_entry {
6730 display_string += SELECTED_MARKER;
6731 }
6732 }
6733 display_string
6734 }
6735
6736 fn init_test(cx: &mut TestAppContext) {
6737 cx.update(|cx| {
6738 let settings = SettingsStore::test(cx);
6739 cx.set_global(settings);
6740
6741 theme::init(theme::LoadThemes::JustBase, cx);
6742
6743 language::init(cx);
6744 editor::init(cx);
6745 workspace::init_settings(cx);
6746 Project::init_settings(cx);
6747 project_search::init(cx);
6748 super::init(cx);
6749 });
6750 }
6751
6752 // Based on https://github.com/rust-lang/rust-analyzer/
6753 async fn populate_with_test_ra_project(fs: &FakeFs, root: &str) {
6754 fs.insert_tree(
6755 root,
6756 json!({
6757 "crates": {
6758 "ide": {
6759 "src": {
6760 "inlay_hints": {
6761 "fn_lifetime_fn.rs": r##"
6762 pub(super) fn hints(
6763 acc: &mut Vec<InlayHint>,
6764 config: &InlayHintsConfig,
6765 func: ast::Fn,
6766 ) -> Option<()> {
6767 // ... snip
6768
6769 let mut used_names: FxHashMap<SmolStr, usize> =
6770 match config.param_names_for_lifetime_elision_hints {
6771 true => generic_param_list
6772 .iter()
6773 .flat_map(|gpl| gpl.lifetime_params())
6774 .filter_map(|param| param.lifetime())
6775 .filter_map(|lt| Some((SmolStr::from(lt.text().as_str().get(1..)?), 0)))
6776 .collect(),
6777 false => Default::default(),
6778 };
6779 {
6780 let mut potential_lt_refs = potential_lt_refs.iter().filter(|&&(.., is_elided)| is_elided);
6781 if self_param.is_some() && potential_lt_refs.next().is_some() {
6782 allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
6783 // self can't be used as a lifetime, so no need to check for collisions
6784 "'self".into()
6785 } else {
6786 gen_idx_name()
6787 });
6788 }
6789 potential_lt_refs.for_each(|(name, ..)| {
6790 let name = match name {
6791 Some(it) if config.param_names_for_lifetime_elision_hints => {
6792 if let Some(c) = used_names.get_mut(it.text().as_str()) {
6793 *c += 1;
6794 SmolStr::from(format!("'{text}{c}", text = it.text().as_str()))
6795 } else {
6796 used_names.insert(it.text().as_str().into(), 0);
6797 SmolStr::from_iter(["\'", it.text().as_str()])
6798 }
6799 }
6800 _ => gen_idx_name(),
6801 };
6802 allocated_lifetimes.push(name);
6803 });
6804 }
6805
6806 // ... snip
6807 }
6808
6809 // ... snip
6810
6811 #[test]
6812 fn hints_lifetimes_named() {
6813 check_with_config(
6814 InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
6815 r#"
6816 fn nested_in<'named>(named: & &X< &()>) {}
6817 // ^'named1, 'named2, 'named3, $
6818 //^'named1 ^'named2 ^'named3
6819 "#,
6820 );
6821 }
6822
6823 // ... snip
6824 "##,
6825 },
6826 "inlay_hints.rs": r#"
6827 #[derive(Clone, Debug, PartialEq, Eq)]
6828 pub struct InlayHintsConfig {
6829 // ... snip
6830 pub param_names_for_lifetime_elision_hints: bool,
6831 pub max_length: Option<usize>,
6832 // ... snip
6833 }
6834
6835 impl Config {
6836 pub fn inlay_hints(&self) -> InlayHintsConfig {
6837 InlayHintsConfig {
6838 // ... snip
6839 param_names_for_lifetime_elision_hints: self
6840 .inlayHints_lifetimeElisionHints_useParameterNames()
6841 .to_owned(),
6842 max_length: self.inlayHints_maxLength().to_owned(),
6843 // ... snip
6844 }
6845 }
6846 }
6847 "#,
6848 "static_index.rs": r#"
6849// ... snip
6850 fn add_file(&mut self, file_id: FileId) {
6851 let current_crate = crates_for(self.db, file_id).pop().map(Into::into);
6852 let folds = self.analysis.folding_ranges(file_id).unwrap();
6853 let inlay_hints = self
6854 .analysis
6855 .inlay_hints(
6856 &InlayHintsConfig {
6857 // ... snip
6858 closure_style: hir::ClosureStyle::ImplFn,
6859 param_names_for_lifetime_elision_hints: false,
6860 binding_mode_hints: false,
6861 max_length: Some(25),
6862 closure_capture_hints: false,
6863 // ... snip
6864 },
6865 file_id,
6866 None,
6867 )
6868 .unwrap();
6869 // ... snip
6870 }
6871// ... snip
6872 "#
6873 }
6874 },
6875 "rust-analyzer": {
6876 "src": {
6877 "cli": {
6878 "analysis_stats.rs": r#"
6879 // ... snip
6880 for &file_id in &file_ids {
6881 _ = analysis.inlay_hints(
6882 &InlayHintsConfig {
6883 // ... snip
6884 implicit_drop_hints: true,
6885 lifetime_elision_hints: ide::LifetimeElisionHints::Always,
6886 param_names_for_lifetime_elision_hints: true,
6887 hide_named_constructor_hints: false,
6888 hide_closure_initialization_hints: false,
6889 closure_style: hir::ClosureStyle::ImplFn,
6890 max_length: Some(25),
6891 closing_brace_hints_min_lines: Some(20),
6892 fields_to_resolve: InlayFieldsToResolve::empty(),
6893 range_exclusive_hints: true,
6894 },
6895 file_id.into(),
6896 None,
6897 );
6898 }
6899 // ... snip
6900 "#,
6901 },
6902 "config.rs": r#"
6903 config_data! {
6904 /// Configs that only make sense when they are set by a client. As such they can only be defined
6905 /// by setting them using client's settings (e.g `settings.json` on VS Code).
6906 client: struct ClientDefaultConfigData <- ClientConfigInput -> {
6907 // ... snip
6908 /// Maximum length for inlay hints. Set to null to have an unlimited length.
6909 inlayHints_maxLength: Option<usize> = Some(25),
6910 // ... snip
6911 /// Whether to prefer using parameter names as the name for elided lifetime hints if possible.
6912 inlayHints_lifetimeElisionHints_useParameterNames: bool = false,
6913 // ... snip
6914 }
6915 }
6916
6917 impl Config {
6918 // ... snip
6919 pub fn inlay_hints(&self) -> InlayHintsConfig {
6920 InlayHintsConfig {
6921 // ... snip
6922 param_names_for_lifetime_elision_hints: self
6923 .inlayHints_lifetimeElisionHints_useParameterNames()
6924 .to_owned(),
6925 max_length: self.inlayHints_maxLength().to_owned(),
6926 // ... snip
6927 }
6928 }
6929 // ... snip
6930 }
6931 "#
6932 }
6933 }
6934 }
6935 }),
6936 )
6937 .await;
6938 }
6939
6940 fn rust_lang() -> Language {
6941 Language::new(
6942 LanguageConfig {
6943 name: "Rust".into(),
6944 matcher: LanguageMatcher {
6945 path_suffixes: vec!["rs".to_string()],
6946 ..Default::default()
6947 },
6948 ..Default::default()
6949 },
6950 Some(tree_sitter_rust::LANGUAGE.into()),
6951 )
6952 .with_highlights_query(
6953 r#"
6954 (field_identifier) @field
6955 (struct_expression) @struct
6956 "#,
6957 )
6958 .unwrap()
6959 .with_injection_query(
6960 r#"
6961 (macro_invocation
6962 (token_tree) @injection.content
6963 (#set! injection.language "rust"))
6964 "#,
6965 )
6966 .unwrap()
6967 }
6968
6969 fn snapshot(outline_panel: &OutlinePanel, cx: &App) -> MultiBufferSnapshot {
6970 outline_panel
6971 .active_editor()
6972 .unwrap()
6973 .read(cx)
6974 .buffer()
6975 .read(cx)
6976 .snapshot(cx)
6977 }
6978
6979 fn selected_row_text(editor: &Entity<Editor>, cx: &mut App) -> String {
6980 editor.update(cx, |editor, cx| {
6981 let selections = editor.selections.all::<language::Point>(cx);
6982 assert_eq!(selections.len(), 1, "Active editor should have exactly one selection after any outline panel interactions");
6983 let selection = selections.first().unwrap();
6984 let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
6985 let line_start = language::Point::new(selection.start.row, 0);
6986 let line_end = multi_buffer_snapshot.clip_point(language::Point::new(selection.end.row, u32::MAX), language::Bias::Right);
6987 multi_buffer_snapshot.text_for_range(line_start..line_end).collect::<String>().trim().to_owned()
6988 })
6989 }
6990
6991 #[gpui::test]
6992 async fn test_outline_keyboard_expand_collapse(cx: &mut TestAppContext) {
6993 init_test(cx);
6994
6995 let fs = FakeFs::new(cx.background_executor.clone());
6996 fs.insert_tree(
6997 "/test",
6998 json!({
6999 "src": {
7000 "lib.rs": indoc!("
7001 mod outer {
7002 pub struct OuterStruct {
7003 field: String,
7004 }
7005 impl OuterStruct {
7006 pub fn new() -> Self {
7007 Self { field: String::new() }
7008 }
7009 pub fn method(&self) {
7010 println!(\"{}\", self.field);
7011 }
7012 }
7013 mod inner {
7014 pub fn inner_function() {
7015 let x = 42;
7016 println!(\"{}\", x);
7017 }
7018 pub struct InnerStruct {
7019 value: i32,
7020 }
7021 }
7022 }
7023 fn main() {
7024 let s = outer::OuterStruct::new();
7025 s.method();
7026 }
7027 "),
7028 }
7029 }),
7030 )
7031 .await;
7032
7033 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7034 project.read_with(cx, |project, _| {
7035 project.languages().add(Arc::new(
7036 rust_lang()
7037 .with_outline_query(
7038 r#"
7039 (struct_item
7040 (visibility_modifier)? @context
7041 "struct" @context
7042 name: (_) @name) @item
7043 (impl_item
7044 "impl" @context
7045 trait: (_)? @context
7046 "for"? @context
7047 type: (_) @context
7048 body: (_)) @item
7049 (function_item
7050 (visibility_modifier)? @context
7051 "fn" @context
7052 name: (_) @name
7053 parameters: (_) @context) @item
7054 (mod_item
7055 (visibility_modifier)? @context
7056 "mod" @context
7057 name: (_) @name) @item
7058 (enum_item
7059 (visibility_modifier)? @context
7060 "enum" @context
7061 name: (_) @name) @item
7062 (field_declaration
7063 (visibility_modifier)? @context
7064 name: (_) @name
7065 ":" @context
7066 type: (_) @context) @item
7067 "#,
7068 )
7069 .unwrap(),
7070 ))
7071 });
7072 let workspace = add_outline_panel(&project, cx).await;
7073 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7074 let outline_panel = outline_panel(&workspace, cx);
7075
7076 outline_panel.update_in(cx, |outline_panel, window, cx| {
7077 outline_panel.set_active(true, window, cx)
7078 });
7079
7080 workspace
7081 .update(cx, |workspace, window, cx| {
7082 workspace.open_abs_path(
7083 PathBuf::from("/test/src/lib.rs"),
7084 OpenOptions {
7085 visible: Some(OpenVisible::All),
7086 ..Default::default()
7087 },
7088 window,
7089 cx,
7090 )
7091 })
7092 .unwrap()
7093 .await
7094 .unwrap();
7095
7096 cx.executor()
7097 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7098 cx.run_until_parked();
7099
7100 // Force another update cycle to ensure outlines are fetched
7101 outline_panel.update_in(cx, |panel, window, cx| {
7102 panel.update_non_fs_items(window, cx);
7103 panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
7104 });
7105 cx.executor()
7106 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7107 cx.run_until_parked();
7108
7109 outline_panel.update(cx, |outline_panel, cx| {
7110 assert_eq!(
7111 display_entries(
7112 &project,
7113 &snapshot(outline_panel, cx),
7114 &outline_panel.cached_entries,
7115 outline_panel.selected_entry(),
7116 cx,
7117 ),
7118 indoc!(
7119 "
7120outline: mod outer <==== selected
7121 outline: pub struct OuterStruct
7122 outline: field: String
7123 outline: impl OuterStruct
7124 outline: pub fn new()
7125 outline: pub fn method(&self)
7126 outline: mod inner
7127 outline: pub fn inner_function()
7128 outline: pub struct InnerStruct
7129 outline: value: i32
7130outline: fn main()"
7131 )
7132 );
7133 });
7134
7135 let parent_outline = outline_panel
7136 .read_with(cx, |panel, _cx| {
7137 panel
7138 .cached_entries
7139 .iter()
7140 .find_map(|entry| match &entry.entry {
7141 PanelEntry::Outline(OutlineEntry::Outline(outline))
7142 if panel
7143 .outline_children_cache
7144 .get(&outline.buffer_id)
7145 .and_then(|children_map| {
7146 let key =
7147 (outline.outline.range.clone(), outline.outline.depth);
7148 children_map.get(&key)
7149 })
7150 .copied()
7151 .unwrap_or(false) =>
7152 {
7153 Some(entry.entry.clone())
7154 }
7155 _ => None,
7156 })
7157 })
7158 .expect("Should find an outline with children");
7159
7160 outline_panel.update_in(cx, |panel, window, cx| {
7161 panel.select_entry(parent_outline.clone(), true, window, cx);
7162 panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
7163 });
7164 cx.executor()
7165 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7166 cx.run_until_parked();
7167
7168 outline_panel.update(cx, |outline_panel, cx| {
7169 assert_eq!(
7170 display_entries(
7171 &project,
7172 &snapshot(outline_panel, cx),
7173 &outline_panel.cached_entries,
7174 outline_panel.selected_entry(),
7175 cx,
7176 ),
7177 indoc!(
7178 "
7179outline: mod outer <==== selected
7180outline: fn main()"
7181 )
7182 );
7183 });
7184
7185 outline_panel.update_in(cx, |panel, window, cx| {
7186 panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
7187 });
7188 cx.executor()
7189 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7190 cx.run_until_parked();
7191
7192 outline_panel.update(cx, |outline_panel, cx| {
7193 assert_eq!(
7194 display_entries(
7195 &project,
7196 &snapshot(outline_panel, cx),
7197 &outline_panel.cached_entries,
7198 outline_panel.selected_entry(),
7199 cx,
7200 ),
7201 indoc!(
7202 "
7203outline: mod outer <==== selected
7204 outline: pub struct OuterStruct
7205 outline: field: String
7206 outline: impl OuterStruct
7207 outline: pub fn new()
7208 outline: pub fn method(&self)
7209 outline: mod inner
7210 outline: pub fn inner_function()
7211 outline: pub struct InnerStruct
7212 outline: value: i32
7213outline: fn main()"
7214 )
7215 );
7216 });
7217
7218 outline_panel.update_in(cx, |panel, window, cx| {
7219 panel.collapsed_entries.clear();
7220 panel.update_cached_entries(None, window, cx);
7221 });
7222 cx.executor()
7223 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7224 cx.run_until_parked();
7225
7226 outline_panel.update_in(cx, |panel, window, cx| {
7227 let outlines_with_children: Vec<_> = panel
7228 .cached_entries
7229 .iter()
7230 .filter_map(|entry| match &entry.entry {
7231 PanelEntry::Outline(OutlineEntry::Outline(outline))
7232 if panel
7233 .outline_children_cache
7234 .get(&outline.buffer_id)
7235 .and_then(|children_map| {
7236 let key = (outline.outline.range.clone(), outline.outline.depth);
7237 children_map.get(&key)
7238 })
7239 .copied()
7240 .unwrap_or(false) =>
7241 {
7242 Some(entry.entry.clone())
7243 }
7244 _ => None,
7245 })
7246 .collect();
7247
7248 for outline in outlines_with_children {
7249 panel.select_entry(outline, false, window, cx);
7250 panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
7251 }
7252 });
7253 cx.executor()
7254 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7255 cx.run_until_parked();
7256
7257 outline_panel.update(cx, |outline_panel, cx| {
7258 assert_eq!(
7259 display_entries(
7260 &project,
7261 &snapshot(outline_panel, cx),
7262 &outline_panel.cached_entries,
7263 outline_panel.selected_entry(),
7264 cx,
7265 ),
7266 indoc!(
7267 "
7268outline: mod outer
7269outline: fn main()"
7270 )
7271 );
7272 });
7273
7274 let collapsed_entries_count =
7275 outline_panel.read_with(cx, |panel, _| panel.collapsed_entries.len());
7276 assert!(
7277 collapsed_entries_count > 0,
7278 "Should have collapsed entries tracked"
7279 );
7280 }
7281
7282 #[gpui::test]
7283 async fn test_outline_click_toggle_behavior(cx: &mut TestAppContext) {
7284 init_test(cx);
7285
7286 let fs = FakeFs::new(cx.background_executor.clone());
7287 fs.insert_tree(
7288 "/test",
7289 json!({
7290 "src": {
7291 "main.rs": indoc!("
7292 struct Config {
7293 name: String,
7294 value: i32,
7295 }
7296 impl Config {
7297 fn new(name: String) -> Self {
7298 Self { name, value: 0 }
7299 }
7300 fn get_value(&self) -> i32 {
7301 self.value
7302 }
7303 }
7304 enum Status {
7305 Active,
7306 Inactive,
7307 }
7308 fn process_config(config: Config) -> Status {
7309 if config.get_value() > 0 {
7310 Status::Active
7311 } else {
7312 Status::Inactive
7313 }
7314 }
7315 fn main() {
7316 let config = Config::new(\"test\".to_string());
7317 let status = process_config(config);
7318 }
7319 "),
7320 }
7321 }),
7322 )
7323 .await;
7324
7325 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7326 project.read_with(cx, |project, _| {
7327 project.languages().add(Arc::new(
7328 rust_lang()
7329 .with_outline_query(
7330 r#"
7331 (struct_item
7332 (visibility_modifier)? @context
7333 "struct" @context
7334 name: (_) @name) @item
7335 (impl_item
7336 "impl" @context
7337 trait: (_)? @context
7338 "for"? @context
7339 type: (_) @context
7340 body: (_)) @item
7341 (function_item
7342 (visibility_modifier)? @context
7343 "fn" @context
7344 name: (_) @name
7345 parameters: (_) @context) @item
7346 (mod_item
7347 (visibility_modifier)? @context
7348 "mod" @context
7349 name: (_) @name) @item
7350 (enum_item
7351 (visibility_modifier)? @context
7352 "enum" @context
7353 name: (_) @name) @item
7354 (field_declaration
7355 (visibility_modifier)? @context
7356 name: (_) @name
7357 ":" @context
7358 type: (_) @context) @item
7359 "#,
7360 )
7361 .unwrap(),
7362 ))
7363 });
7364
7365 let workspace = add_outline_panel(&project, cx).await;
7366 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7367 let outline_panel = outline_panel(&workspace, cx);
7368
7369 outline_panel.update_in(cx, |outline_panel, window, cx| {
7370 outline_panel.set_active(true, window, cx)
7371 });
7372
7373 let _editor = workspace
7374 .update(cx, |workspace, window, cx| {
7375 workspace.open_abs_path(
7376 PathBuf::from("/test/src/main.rs"),
7377 OpenOptions {
7378 visible: Some(OpenVisible::All),
7379 ..Default::default()
7380 },
7381 window,
7382 cx,
7383 )
7384 })
7385 .unwrap()
7386 .await
7387 .unwrap();
7388
7389 cx.executor()
7390 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7391 cx.run_until_parked();
7392
7393 outline_panel.update(cx, |outline_panel, _cx| {
7394 outline_panel.selected_entry = SelectedEntry::None;
7395 });
7396
7397 // Check initial state - all entries should be expanded by default
7398 outline_panel.update(cx, |outline_panel, cx| {
7399 assert_eq!(
7400 display_entries(
7401 &project,
7402 &snapshot(outline_panel, cx),
7403 &outline_panel.cached_entries,
7404 outline_panel.selected_entry(),
7405 cx,
7406 ),
7407 indoc!(
7408 "
7409outline: struct Config
7410 outline: name: String
7411 outline: value: i32
7412outline: impl Config
7413 outline: fn new(name: String)
7414 outline: fn get_value(&self)
7415outline: enum Status
7416outline: fn process_config(config: Config)
7417outline: fn main()"
7418 )
7419 );
7420 });
7421
7422 outline_panel.update(cx, |outline_panel, _cx| {
7423 outline_panel.selected_entry = SelectedEntry::None;
7424 });
7425
7426 cx.update(|window, cx| {
7427 outline_panel.update(cx, |outline_panel, cx| {
7428 outline_panel.select_first(&SelectFirst, window, cx);
7429 });
7430 });
7431
7432 cx.executor()
7433 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7434 cx.run_until_parked();
7435
7436 outline_panel.update(cx, |outline_panel, cx| {
7437 assert_eq!(
7438 display_entries(
7439 &project,
7440 &snapshot(outline_panel, cx),
7441 &outline_panel.cached_entries,
7442 outline_panel.selected_entry(),
7443 cx,
7444 ),
7445 indoc!(
7446 "
7447outline: struct Config <==== selected
7448 outline: name: String
7449 outline: value: i32
7450outline: impl Config
7451 outline: fn new(name: String)
7452 outline: fn get_value(&self)
7453outline: enum Status
7454outline: fn process_config(config: Config)
7455outline: fn main()"
7456 )
7457 );
7458 });
7459
7460 cx.update(|window, cx| {
7461 outline_panel.update(cx, |outline_panel, cx| {
7462 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
7463 });
7464 });
7465
7466 cx.executor()
7467 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7468 cx.run_until_parked();
7469
7470 outline_panel.update(cx, |outline_panel, cx| {
7471 assert_eq!(
7472 display_entries(
7473 &project,
7474 &snapshot(outline_panel, cx),
7475 &outline_panel.cached_entries,
7476 outline_panel.selected_entry(),
7477 cx,
7478 ),
7479 indoc!(
7480 "
7481outline: struct Config <==== selected
7482outline: impl Config
7483 outline: fn new(name: String)
7484 outline: fn get_value(&self)
7485outline: enum Status
7486outline: fn process_config(config: Config)
7487outline: fn main()"
7488 )
7489 );
7490 });
7491
7492 cx.update(|window, cx| {
7493 outline_panel.update(cx, |outline_panel, cx| {
7494 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
7495 });
7496 });
7497
7498 cx.executor()
7499 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7500 cx.run_until_parked();
7501
7502 outline_panel.update(cx, |outline_panel, cx| {
7503 assert_eq!(
7504 display_entries(
7505 &project,
7506 &snapshot(outline_panel, cx),
7507 &outline_panel.cached_entries,
7508 outline_panel.selected_entry(),
7509 cx,
7510 ),
7511 indoc!(
7512 "
7513outline: struct Config <==== selected
7514 outline: name: String
7515 outline: value: i32
7516outline: impl Config
7517 outline: fn new(name: String)
7518 outline: fn get_value(&self)
7519outline: enum Status
7520outline: fn process_config(config: Config)
7521outline: fn main()"
7522 )
7523 );
7524 });
7525 }
7526}