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