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