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 let snapshot = editor.buffer().read(cx).snapshot(cx);
4397 if let Some(buffer_id) = snapshot.buffer_id_for_anchor(match_range.start)
4398 && editor.is_buffer_folded(buffer_id, cx)
4399 {
4400 return false;
4401 }
4402 if let Some(buffer_id) = snapshot.buffer_id_for_anchor(match_range.end)
4403 && editor.is_buffer_folded(buffer_id, cx)
4404 {
4405 return false;
4406 }
4407 true
4408 });
4409
4410 let new_search_entries = new_search_matches
4411 .map(|(match_range, search_data)| SearchEntry {
4412 match_range: match_range.clone(),
4413 kind,
4414 render_data: Arc::clone(search_data),
4415 })
4416 .collect::<Vec<_>>();
4417 for new_search_entry in new_search_entries {
4418 self.push_entry(
4419 state,
4420 filter_query.is_some(),
4421 PanelEntry::Search(new_search_entry),
4422 depth,
4423 cx,
4424 );
4425 }
4426 }
4427
4428 fn active_editor(&self) -> Option<Entity<Editor>> {
4429 self.active_item.as_ref()?.active_editor.upgrade()
4430 }
4431
4432 fn active_item(&self) -> Option<Box<dyn ItemHandle>> {
4433 self.active_item.as_ref()?.item_handle.upgrade()
4434 }
4435
4436 fn should_replace_active_item(&self, new_active_item: &dyn ItemHandle) -> bool {
4437 self.active_item().is_none_or(|active_item| {
4438 !self.pinned && active_item.item_id() != new_active_item.item_id()
4439 })
4440 }
4441
4442 pub fn toggle_active_editor_pin(
4443 &mut self,
4444 _: &ToggleActiveEditorPin,
4445 window: &mut Window,
4446 cx: &mut Context<Self>,
4447 ) {
4448 self.pinned = !self.pinned;
4449 if !self.pinned
4450 && let Some((active_item, active_editor)) = self
4451 .workspace
4452 .upgrade()
4453 .and_then(|workspace| workspace_active_editor(workspace.read(cx), cx))
4454 && self.should_replace_active_item(active_item.as_ref())
4455 {
4456 self.replace_active_editor(active_item, active_editor, window, cx);
4457 }
4458
4459 cx.notify();
4460 }
4461
4462 fn selected_entry(&self) -> Option<&PanelEntry> {
4463 match &self.selected_entry {
4464 SelectedEntry::Invalidated(entry) => entry.as_ref(),
4465 SelectedEntry::Valid(entry, _) => Some(entry),
4466 SelectedEntry::None => None,
4467 }
4468 }
4469
4470 fn select_entry(
4471 &mut self,
4472 entry: PanelEntry,
4473 focus: bool,
4474 window: &mut Window,
4475 cx: &mut Context<Self>,
4476 ) {
4477 if focus {
4478 self.focus_handle.focus(window);
4479 }
4480 let ix = self
4481 .cached_entries
4482 .iter()
4483 .enumerate()
4484 .find(|(_, cached_entry)| &cached_entry.entry == &entry)
4485 .map(|(i, _)| i)
4486 .unwrap_or_default();
4487
4488 self.selected_entry = SelectedEntry::Valid(entry, ix);
4489
4490 self.autoscroll(cx);
4491 cx.notify();
4492 }
4493
4494 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4495 if !Self::should_show_scrollbar(cx)
4496 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
4497 {
4498 return None;
4499 }
4500 Some(
4501 div()
4502 .occlude()
4503 .id("project-panel-vertical-scroll")
4504 .on_mouse_move(cx.listener(|_, _, _, cx| {
4505 cx.notify();
4506 cx.stop_propagation()
4507 }))
4508 .on_hover(|_, _, cx| {
4509 cx.stop_propagation();
4510 })
4511 .on_any_mouse_down(|_, _, cx| {
4512 cx.stop_propagation();
4513 })
4514 .on_mouse_up(
4515 MouseButton::Left,
4516 cx.listener(|outline_panel, _, window, cx| {
4517 if !outline_panel.vertical_scrollbar_state.is_dragging()
4518 && !outline_panel.focus_handle.contains_focused(window, cx)
4519 {
4520 outline_panel.hide_scrollbar(window, cx);
4521 cx.notify();
4522 }
4523
4524 cx.stop_propagation();
4525 }),
4526 )
4527 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4528 cx.notify();
4529 }))
4530 .h_full()
4531 .absolute()
4532 .right_1()
4533 .top_1()
4534 .bottom_0()
4535 .w(px(12.))
4536 .cursor_default()
4537 .children(Scrollbar::vertical(self.vertical_scrollbar_state.clone())),
4538 )
4539 }
4540
4541 fn render_horizontal_scrollbar(
4542 &self,
4543 _: &mut Window,
4544 cx: &mut Context<Self>,
4545 ) -> Option<Stateful<Div>> {
4546 if !Self::should_show_scrollbar(cx)
4547 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
4548 {
4549 return None;
4550 }
4551 Scrollbar::horizontal(self.horizontal_scrollbar_state.clone()).map(|scrollbar| {
4552 div()
4553 .occlude()
4554 .id("project-panel-horizontal-scroll")
4555 .on_mouse_move(cx.listener(|_, _, _, cx| {
4556 cx.notify();
4557 cx.stop_propagation()
4558 }))
4559 .on_hover(|_, _, cx| {
4560 cx.stop_propagation();
4561 })
4562 .on_any_mouse_down(|_, _, cx| {
4563 cx.stop_propagation();
4564 })
4565 .on_mouse_up(
4566 MouseButton::Left,
4567 cx.listener(|outline_panel, _, window, cx| {
4568 if !outline_panel.horizontal_scrollbar_state.is_dragging()
4569 && !outline_panel.focus_handle.contains_focused(window, cx)
4570 {
4571 outline_panel.hide_scrollbar(window, cx);
4572 cx.notify();
4573 }
4574
4575 cx.stop_propagation();
4576 }),
4577 )
4578 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4579 cx.notify();
4580 }))
4581 .w_full()
4582 .absolute()
4583 .right_1()
4584 .left_1()
4585 .bottom_0()
4586 .h(px(12.))
4587 .cursor_default()
4588 .child(scrollbar)
4589 })
4590 }
4591
4592 fn should_show_scrollbar(cx: &App) -> bool {
4593 let show = OutlinePanelSettings::get_global(cx)
4594 .scrollbar
4595 .show
4596 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4597 match show {
4598 ShowScrollbar::Auto => true,
4599 ShowScrollbar::System => true,
4600 ShowScrollbar::Always => true,
4601 ShowScrollbar::Never => false,
4602 }
4603 }
4604
4605 fn should_autohide_scrollbar(cx: &App) -> bool {
4606 let show = OutlinePanelSettings::get_global(cx)
4607 .scrollbar
4608 .show
4609 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4610 match show {
4611 ShowScrollbar::Auto => true,
4612 ShowScrollbar::System => cx
4613 .try_global::<ScrollbarAutoHide>()
4614 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
4615 ShowScrollbar::Always => false,
4616 ShowScrollbar::Never => true,
4617 }
4618 }
4619
4620 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4621 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
4622 if !Self::should_autohide_scrollbar(cx) {
4623 return;
4624 }
4625 self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
4626 cx.background_executor()
4627 .timer(SCROLLBAR_SHOW_INTERVAL)
4628 .await;
4629 panel
4630 .update(cx, |panel, cx| {
4631 panel.show_scrollbar = false;
4632 cx.notify();
4633 })
4634 .log_err();
4635 }))
4636 }
4637
4638 fn width_estimate(&self, depth: usize, entry: &PanelEntry, cx: &App) -> u64 {
4639 let item_text_chars = match entry {
4640 PanelEntry::Fs(FsEntry::ExternalFile(external)) => self
4641 .buffer_snapshot_for_id(external.buffer_id, cx)
4642 .and_then(|snapshot| {
4643 Some(snapshot.file()?.path().file_name()?.to_string_lossy().len())
4644 })
4645 .unwrap_or_default(),
4646 PanelEntry::Fs(FsEntry::Directory(directory)) => directory
4647 .entry
4648 .path
4649 .file_name()
4650 .map(|name| name.to_string_lossy().len())
4651 .unwrap_or_default(),
4652 PanelEntry::Fs(FsEntry::File(file)) => file
4653 .entry
4654 .path
4655 .file_name()
4656 .map(|name| name.to_string_lossy().len())
4657 .unwrap_or_default(),
4658 PanelEntry::FoldedDirs(folded_dirs) => {
4659 folded_dirs
4660 .entries
4661 .iter()
4662 .map(|dir| {
4663 dir.path
4664 .file_name()
4665 .map(|name| name.to_string_lossy().len())
4666 .unwrap_or_default()
4667 })
4668 .sum::<usize>()
4669 + folded_dirs.entries.len().saturating_sub(1) * MAIN_SEPARATOR_STR.len()
4670 }
4671 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => self
4672 .excerpt_label(excerpt.buffer_id, &excerpt.range, cx)
4673 .map(|label| label.len())
4674 .unwrap_or_default(),
4675 PanelEntry::Outline(OutlineEntry::Outline(entry)) => entry.outline.text.len(),
4676 PanelEntry::Search(search) => search
4677 .render_data
4678 .get()
4679 .map(|data| data.context_text.len())
4680 .unwrap_or_default(),
4681 };
4682
4683 (item_text_chars + depth) as u64
4684 }
4685
4686 fn render_main_contents(
4687 &mut self,
4688 query: Option<String>,
4689 show_indent_guides: bool,
4690 indent_size: f32,
4691 window: &mut Window,
4692 cx: &mut Context<Self>,
4693 ) -> Div {
4694 let contents = if self.cached_entries.is_empty() {
4695 let header = if self.updating_fs_entries || self.updating_cached_entries {
4696 None
4697 } else if query.is_some() {
4698 Some("No matches for query")
4699 } else {
4700 Some("No outlines available")
4701 };
4702
4703 v_flex()
4704 .flex_1()
4705 .justify_center()
4706 .size_full()
4707 .when_some(header, |panel, header| {
4708 panel
4709 .child(h_flex().justify_center().child(Label::new(header)))
4710 .when_some(query.clone(), |panel, query| {
4711 panel.child(h_flex().justify_center().child(Label::new(query)))
4712 })
4713 .child(
4714 h_flex()
4715 .pt(DynamicSpacing::Base04.rems(cx))
4716 .justify_center()
4717 .child({
4718 let keystroke =
4719 match self.position(window, cx) {
4720 DockPosition::Left => window
4721 .keystroke_text_for(&workspace::ToggleLeftDock),
4722 DockPosition::Bottom => window
4723 .keystroke_text_for(&workspace::ToggleBottomDock),
4724 DockPosition::Right => window
4725 .keystroke_text_for(&workspace::ToggleRightDock),
4726 };
4727 Label::new(format!("Toggle this panel with {keystroke}"))
4728 }),
4729 )
4730 })
4731 } else {
4732 let list_contents = {
4733 let items_len = self.cached_entries.len();
4734 let multi_buffer_snapshot = self
4735 .active_editor()
4736 .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx));
4737 uniform_list(
4738 "entries",
4739 items_len,
4740 cx.processor(move |outline_panel, range: Range<usize>, window, cx| {
4741 let entries = outline_panel.cached_entries.get(range);
4742 entries
4743 .map(|entries| entries.to_vec())
4744 .unwrap_or_default()
4745 .into_iter()
4746 .filter_map(|cached_entry| match cached_entry.entry {
4747 PanelEntry::Fs(entry) => Some(outline_panel.render_entry(
4748 &entry,
4749 cached_entry.depth,
4750 cached_entry.string_match.as_ref(),
4751 window,
4752 cx,
4753 )),
4754 PanelEntry::FoldedDirs(folded_dirs_entry) => {
4755 Some(outline_panel.render_folded_dirs(
4756 &folded_dirs_entry,
4757 cached_entry.depth,
4758 cached_entry.string_match.as_ref(),
4759 window,
4760 cx,
4761 ))
4762 }
4763 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => {
4764 outline_panel.render_excerpt(
4765 &excerpt,
4766 cached_entry.depth,
4767 window,
4768 cx,
4769 )
4770 }
4771 PanelEntry::Outline(OutlineEntry::Outline(entry)) => {
4772 Some(outline_panel.render_outline(
4773 &entry,
4774 cached_entry.depth,
4775 cached_entry.string_match.as_ref(),
4776 window,
4777 cx,
4778 ))
4779 }
4780 PanelEntry::Search(SearchEntry {
4781 match_range,
4782 render_data,
4783 kind,
4784 ..
4785 }) => outline_panel.render_search_match(
4786 multi_buffer_snapshot.as_ref(),
4787 &match_range,
4788 &render_data,
4789 kind,
4790 cached_entry.depth,
4791 cached_entry.string_match.as_ref(),
4792 window,
4793 cx,
4794 ),
4795 })
4796 .collect()
4797 }),
4798 )
4799 .with_sizing_behavior(ListSizingBehavior::Infer)
4800 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4801 .with_width_from_item(self.max_width_item_index)
4802 .track_scroll(self.scroll_handle.clone())
4803 .when(show_indent_guides, |list| {
4804 list.with_decoration(
4805 ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx))
4806 .with_compute_indents_fn(cx.entity(), |outline_panel, range, _, _| {
4807 let entries = outline_panel.cached_entries.get(range);
4808 if let Some(entries) = entries {
4809 entries.iter().map(|item| item.depth).collect()
4810 } else {
4811 smallvec::SmallVec::new()
4812 }
4813 })
4814 .with_render_fn(cx.entity(), move |outline_panel, params, _, _| {
4815 const LEFT_OFFSET: Pixels = px(14.);
4816
4817 let indent_size = params.indent_size;
4818 let item_height = params.item_height;
4819 let active_indent_guide_ix = find_active_indent_guide_ix(
4820 outline_panel,
4821 ¶ms.indent_guides,
4822 );
4823
4824 params
4825 .indent_guides
4826 .into_iter()
4827 .enumerate()
4828 .map(|(ix, layout)| {
4829 let bounds = Bounds::new(
4830 point(
4831 layout.offset.x * indent_size + LEFT_OFFSET,
4832 layout.offset.y * item_height,
4833 ),
4834 size(px(1.), layout.length * item_height),
4835 );
4836 ui::RenderedIndentGuide {
4837 bounds,
4838 layout,
4839 is_active: active_indent_guide_ix == Some(ix),
4840 hitbox: None,
4841 }
4842 })
4843 .collect()
4844 }),
4845 )
4846 })
4847 };
4848
4849 v_flex()
4850 .flex_shrink()
4851 .size_full()
4852 .child(list_contents.size_full().flex_shrink())
4853 .children(self.render_vertical_scrollbar(cx))
4854 .when_some(
4855 self.render_horizontal_scrollbar(window, cx),
4856 |this, scrollbar| this.pb_4().child(scrollbar),
4857 )
4858 }
4859 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4860 deferred(
4861 anchored()
4862 .position(*position)
4863 .anchor(gpui::Corner::TopLeft)
4864 .child(menu.clone()),
4865 )
4866 .with_priority(1)
4867 }));
4868
4869 v_flex().w_full().flex_1().overflow_hidden().child(contents)
4870 }
4871
4872 fn render_filter_footer(&mut self, pinned: bool, cx: &mut Context<Self>) -> Div {
4873 v_flex().flex_none().child(horizontal_separator(cx)).child(
4874 h_flex()
4875 .p_2()
4876 .w_full()
4877 .child(self.filter_editor.clone())
4878 .child(
4879 div().child(
4880 IconButton::new(
4881 "outline-panel-menu",
4882 if pinned {
4883 IconName::Unpin
4884 } else {
4885 IconName::Pin
4886 },
4887 )
4888 .tooltip(Tooltip::text(if pinned {
4889 "Unpin Outline"
4890 } else {
4891 "Pin Active Outline"
4892 }))
4893 .shape(IconButtonShape::Square)
4894 .on_click(cx.listener(
4895 |outline_panel, _, window, cx| {
4896 outline_panel.toggle_active_editor_pin(
4897 &ToggleActiveEditorPin,
4898 window,
4899 cx,
4900 );
4901 },
4902 )),
4903 ),
4904 ),
4905 )
4906 }
4907
4908 fn buffers_inside_directory(
4909 &self,
4910 dir_worktree: WorktreeId,
4911 dir_entry: &GitEntry,
4912 ) -> HashSet<BufferId> {
4913 if !dir_entry.is_dir() {
4914 debug_panic!("buffers_inside_directory called on a non-directory entry {dir_entry:?}");
4915 return HashSet::default();
4916 }
4917
4918 self.fs_entries
4919 .iter()
4920 .skip_while(|fs_entry| match fs_entry {
4921 FsEntry::Directory(directory) => {
4922 directory.worktree_id != dir_worktree || &directory.entry != dir_entry
4923 }
4924 _ => true,
4925 })
4926 .skip(1)
4927 .take_while(|fs_entry| match fs_entry {
4928 FsEntry::ExternalFile(..) => false,
4929 FsEntry::Directory(directory) => {
4930 directory.worktree_id == dir_worktree
4931 && directory.entry.path.starts_with(&dir_entry.path)
4932 }
4933 FsEntry::File(file) => {
4934 file.worktree_id == dir_worktree && file.entry.path.starts_with(&dir_entry.path)
4935 }
4936 })
4937 .filter_map(|fs_entry| match fs_entry {
4938 FsEntry::File(file) => Some(file.buffer_id),
4939 _ => None,
4940 })
4941 .collect()
4942 }
4943}
4944
4945fn workspace_active_editor(
4946 workspace: &Workspace,
4947 cx: &App,
4948) -> Option<(Box<dyn ItemHandle>, Entity<Editor>)> {
4949 let active_item = workspace.active_item(cx)?;
4950 let active_editor = active_item
4951 .act_as::<Editor>(cx)
4952 .filter(|editor| editor.read(cx).mode().is_full())?;
4953 Some((active_item, active_editor))
4954}
4955
4956fn back_to_common_visited_parent(
4957 visited_dirs: &mut Vec<(ProjectEntryId, Arc<Path>)>,
4958 worktree_id: &WorktreeId,
4959 new_entry: &Entry,
4960) -> Option<(WorktreeId, ProjectEntryId)> {
4961 while let Some((visited_dir_id, visited_path)) = visited_dirs.last() {
4962 match new_entry.path.parent() {
4963 Some(parent_path) => {
4964 if parent_path == visited_path.as_ref() {
4965 return Some((*worktree_id, *visited_dir_id));
4966 }
4967 }
4968 None => {
4969 break;
4970 }
4971 }
4972 visited_dirs.pop();
4973 }
4974 None
4975}
4976
4977fn file_name(path: &Path) -> String {
4978 let mut current_path = path;
4979 loop {
4980 if let Some(file_name) = current_path.file_name() {
4981 return file_name.to_string_lossy().into_owned();
4982 }
4983 match current_path.parent() {
4984 Some(parent) => current_path = parent,
4985 None => return path.to_string_lossy().into_owned(),
4986 }
4987 }
4988}
4989
4990impl Panel for OutlinePanel {
4991 fn persistent_name() -> &'static str {
4992 "Outline Panel"
4993 }
4994
4995 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4996 match OutlinePanelSettings::get_global(cx).dock {
4997 OutlinePanelDockPosition::Left => DockPosition::Left,
4998 OutlinePanelDockPosition::Right => DockPosition::Right,
4999 }
5000 }
5001
5002 fn position_is_valid(&self, position: DockPosition) -> bool {
5003 matches!(position, DockPosition::Left | DockPosition::Right)
5004 }
5005
5006 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
5007 settings::update_settings_file::<OutlinePanelSettings>(
5008 self.fs.clone(),
5009 cx,
5010 move |settings, _| {
5011 let dock = match position {
5012 DockPosition::Left | DockPosition::Bottom => OutlinePanelDockPosition::Left,
5013 DockPosition::Right => OutlinePanelDockPosition::Right,
5014 };
5015 settings.dock = Some(dock);
5016 },
5017 );
5018 }
5019
5020 fn size(&self, _: &Window, cx: &App) -> Pixels {
5021 self.width
5022 .unwrap_or_else(|| OutlinePanelSettings::get_global(cx).default_width)
5023 }
5024
5025 fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
5026 self.width = size;
5027 cx.notify();
5028 cx.defer_in(window, |this, _, cx| {
5029 this.serialize(cx);
5030 });
5031 }
5032
5033 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
5034 OutlinePanelSettings::get_global(cx)
5035 .button
5036 .then_some(IconName::ListTree)
5037 }
5038
5039 fn icon_tooltip(&self, _window: &Window, _: &App) -> Option<&'static str> {
5040 Some("Outline Panel")
5041 }
5042
5043 fn toggle_action(&self) -> Box<dyn Action> {
5044 Box::new(ToggleFocus)
5045 }
5046
5047 fn starts_open(&self, _window: &Window, _: &App) -> bool {
5048 self.active
5049 }
5050
5051 fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
5052 cx.spawn_in(window, async move |outline_panel, cx| {
5053 outline_panel
5054 .update_in(cx, |outline_panel, window, cx| {
5055 let old_active = outline_panel.active;
5056 outline_panel.active = active;
5057 if old_active != active {
5058 if active
5059 && let Some((active_item, active_editor)) =
5060 outline_panel.workspace.upgrade().and_then(|workspace| {
5061 workspace_active_editor(workspace.read(cx), cx)
5062 })
5063 {
5064 if outline_panel.should_replace_active_item(active_item.as_ref()) {
5065 outline_panel.replace_active_editor(
5066 active_item,
5067 active_editor,
5068 window,
5069 cx,
5070 );
5071 } else {
5072 outline_panel.update_fs_entries(active_editor, None, window, cx)
5073 }
5074 return;
5075 }
5076
5077 if !outline_panel.pinned {
5078 outline_panel.clear_previous(window, cx);
5079 }
5080 }
5081 outline_panel.serialize(cx);
5082 })
5083 .ok();
5084 })
5085 .detach()
5086 }
5087
5088 fn activation_priority(&self) -> u32 {
5089 5
5090 }
5091}
5092
5093impl Focusable for OutlinePanel {
5094 fn focus_handle(&self, cx: &App) -> FocusHandle {
5095 self.filter_editor.focus_handle(cx)
5096 }
5097}
5098
5099impl EventEmitter<Event> for OutlinePanel {}
5100
5101impl EventEmitter<PanelEvent> for OutlinePanel {}
5102
5103impl Render for OutlinePanel {
5104 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5105 let (is_local, is_via_ssh) = self.project.read_with(cx, |project, _| {
5106 (project.is_local(), project.is_via_remote_server())
5107 });
5108 let query = self.query(cx);
5109 let pinned = self.pinned;
5110 let settings = OutlinePanelSettings::get_global(cx);
5111 let indent_size = settings.indent_size;
5112 let show_indent_guides = settings.indent_guides.show == ShowIndentGuides::Always;
5113
5114 let search_query = match &self.mode {
5115 ItemsDisplayMode::Search(search_query) => Some(search_query),
5116 _ => None,
5117 };
5118
5119 v_flex()
5120 .id("outline-panel")
5121 .size_full()
5122 .overflow_hidden()
5123 .relative()
5124 .on_hover(cx.listener(|this, hovered, window, cx| {
5125 if *hovered {
5126 this.show_scrollbar = true;
5127 this.hide_scrollbar_task.take();
5128 cx.notify();
5129 } else if !this.focus_handle.contains_focused(window, cx) {
5130 this.hide_scrollbar(window, cx);
5131 }
5132 }))
5133 .key_context(self.dispatch_context(window, cx))
5134 .on_action(cx.listener(Self::open_selected_entry))
5135 .on_action(cx.listener(Self::cancel))
5136 .on_action(cx.listener(Self::select_next))
5137 .on_action(cx.listener(Self::select_previous))
5138 .on_action(cx.listener(Self::select_first))
5139 .on_action(cx.listener(Self::select_last))
5140 .on_action(cx.listener(Self::select_parent))
5141 .on_action(cx.listener(Self::expand_selected_entry))
5142 .on_action(cx.listener(Self::collapse_selected_entry))
5143 .on_action(cx.listener(Self::expand_all_entries))
5144 .on_action(cx.listener(Self::collapse_all_entries))
5145 .on_action(cx.listener(Self::copy_path))
5146 .on_action(cx.listener(Self::copy_relative_path))
5147 .on_action(cx.listener(Self::toggle_active_editor_pin))
5148 .on_action(cx.listener(Self::unfold_directory))
5149 .on_action(cx.listener(Self::fold_directory))
5150 .on_action(cx.listener(Self::open_excerpts))
5151 .on_action(cx.listener(Self::open_excerpts_split))
5152 .when(is_local, |el| {
5153 el.on_action(cx.listener(Self::reveal_in_finder))
5154 })
5155 .when(is_local || is_via_ssh, |el| {
5156 el.on_action(cx.listener(Self::open_in_terminal))
5157 })
5158 .on_mouse_down(
5159 MouseButton::Right,
5160 cx.listener(move |outline_panel, event: &MouseDownEvent, window, cx| {
5161 if let Some(entry) = outline_panel.selected_entry().cloned() {
5162 outline_panel.deploy_context_menu(event.position, entry, window, cx)
5163 } else if let Some(entry) = outline_panel.fs_entries.first().cloned() {
5164 outline_panel.deploy_context_menu(
5165 event.position,
5166 PanelEntry::Fs(entry),
5167 window,
5168 cx,
5169 )
5170 }
5171 }),
5172 )
5173 .track_focus(&self.focus_handle)
5174 .when_some(search_query, |outline_panel, search_state| {
5175 outline_panel.child(
5176 h_flex()
5177 .py_1p5()
5178 .px_2()
5179 .h(DynamicSpacing::Base32.px(cx))
5180 .flex_shrink_0()
5181 .border_b_1()
5182 .border_color(cx.theme().colors().border)
5183 .gap_0p5()
5184 .child(Label::new("Searching:").color(Color::Muted))
5185 .child(Label::new(search_state.query.to_string())),
5186 )
5187 })
5188 .child(self.render_main_contents(query, show_indent_guides, indent_size, window, cx))
5189 .child(self.render_filter_footer(pinned, cx))
5190 }
5191}
5192
5193fn find_active_indent_guide_ix(
5194 outline_panel: &OutlinePanel,
5195 candidates: &[IndentGuideLayout],
5196) -> Option<usize> {
5197 let SelectedEntry::Valid(_, target_ix) = &outline_panel.selected_entry else {
5198 return None;
5199 };
5200 let target_depth = outline_panel
5201 .cached_entries
5202 .get(*target_ix)
5203 .map(|cached_entry| cached_entry.depth)?;
5204
5205 let (target_ix, target_depth) = if let Some(target_depth) = outline_panel
5206 .cached_entries
5207 .get(target_ix + 1)
5208 .filter(|cached_entry| cached_entry.depth > target_depth)
5209 .map(|entry| entry.depth)
5210 {
5211 (target_ix + 1, target_depth.saturating_sub(1))
5212 } else {
5213 (*target_ix, target_depth.saturating_sub(1))
5214 };
5215
5216 candidates
5217 .iter()
5218 .enumerate()
5219 .find(|(_, guide)| {
5220 guide.offset.y <= target_ix
5221 && target_ix < guide.offset.y + guide.length
5222 && guide.offset.x == target_depth
5223 })
5224 .map(|(ix, _)| ix)
5225}
5226
5227fn subscribe_for_editor_events(
5228 editor: &Entity<Editor>,
5229 window: &mut Window,
5230 cx: &mut Context<OutlinePanel>,
5231) -> Subscription {
5232 let debounce = Some(UPDATE_DEBOUNCE);
5233 cx.subscribe_in(
5234 editor,
5235 window,
5236 move |outline_panel, editor, e: &EditorEvent, window, cx| {
5237 if !outline_panel.active {
5238 return;
5239 }
5240 match e {
5241 EditorEvent::SelectionsChanged { local: true } => {
5242 outline_panel.reveal_entry_for_selection(editor.clone(), window, cx);
5243 cx.notify();
5244 }
5245 EditorEvent::ExcerptsAdded { excerpts, .. } => {
5246 outline_panel
5247 .new_entries_for_fs_update
5248 .extend(excerpts.iter().map(|&(excerpt_id, _)| excerpt_id));
5249 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5250 }
5251 EditorEvent::ExcerptsRemoved { ids, .. } => {
5252 let mut ids = ids.iter().collect::<HashSet<_>>();
5253 for excerpts in outline_panel.excerpts.values_mut() {
5254 excerpts.retain(|excerpt_id, _| !ids.remove(excerpt_id));
5255 if ids.is_empty() {
5256 break;
5257 }
5258 }
5259 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5260 }
5261 EditorEvent::ExcerptsExpanded { ids } => {
5262 outline_panel.invalidate_outlines(ids);
5263 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
5264 if update_cached_items {
5265 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5266 }
5267 }
5268 EditorEvent::ExcerptsEdited { ids } => {
5269 outline_panel.invalidate_outlines(ids);
5270 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
5271 if update_cached_items {
5272 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5273 }
5274 }
5275 EditorEvent::BufferFoldToggled { ids, .. } => {
5276 outline_panel.invalidate_outlines(ids);
5277 let mut latest_unfolded_buffer_id = None;
5278 let mut latest_folded_buffer_id = None;
5279 let mut ignore_selections_change = false;
5280 outline_panel.new_entries_for_fs_update.extend(
5281 ids.iter()
5282 .filter(|id| {
5283 outline_panel
5284 .excerpts
5285 .iter()
5286 .find_map(|(buffer_id, excerpts)| {
5287 if excerpts.contains_key(id) {
5288 ignore_selections_change |= outline_panel
5289 .preserve_selection_on_buffer_fold_toggles
5290 .remove(buffer_id);
5291 Some(buffer_id)
5292 } else {
5293 None
5294 }
5295 })
5296 .map(|buffer_id| {
5297 if editor.read(cx).is_buffer_folded(*buffer_id, cx) {
5298 latest_folded_buffer_id = Some(*buffer_id);
5299 false
5300 } else {
5301 latest_unfolded_buffer_id = Some(*buffer_id);
5302 true
5303 }
5304 })
5305 .unwrap_or(true)
5306 })
5307 .copied(),
5308 );
5309 if !ignore_selections_change
5310 && let Some(entry_to_select) = latest_unfolded_buffer_id
5311 .or(latest_folded_buffer_id)
5312 .and_then(|toggled_buffer_id| {
5313 outline_panel.fs_entries.iter().find_map(
5314 |fs_entry| match fs_entry {
5315 FsEntry::ExternalFile(external) => {
5316 if external.buffer_id == toggled_buffer_id {
5317 Some(fs_entry.clone())
5318 } else {
5319 None
5320 }
5321 }
5322 FsEntry::File(FsEntryFile { buffer_id, .. }) => {
5323 if *buffer_id == toggled_buffer_id {
5324 Some(fs_entry.clone())
5325 } else {
5326 None
5327 }
5328 }
5329 FsEntry::Directory(..) => None,
5330 },
5331 )
5332 })
5333 .map(PanelEntry::Fs)
5334 {
5335 outline_panel.select_entry(entry_to_select, true, window, cx);
5336 }
5337
5338 outline_panel.update_fs_entries(editor.clone(), debounce, window, cx);
5339 }
5340 EditorEvent::Reparsed(buffer_id) => {
5341 if let Some(excerpts) = outline_panel.excerpts.get_mut(buffer_id) {
5342 for excerpt in excerpts.values_mut() {
5343 excerpt.invalidate_outlines();
5344 }
5345 }
5346 let update_cached_items = outline_panel.update_non_fs_items(window, cx);
5347 if update_cached_items {
5348 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
5349 }
5350 }
5351 _ => {}
5352 }
5353 },
5354 )
5355}
5356
5357fn empty_icon() -> AnyElement {
5358 h_flex()
5359 .size(IconSize::default().rems())
5360 .invisible()
5361 .flex_none()
5362 .into_any_element()
5363}
5364
5365fn horizontal_separator(cx: &mut App) -> Div {
5366 div().mx_2().border_primary(cx).border_t_1()
5367}
5368
5369#[derive(Debug, Default)]
5370struct GenerationState {
5371 entries: Vec<CachedEntry>,
5372 match_candidates: Vec<StringMatchCandidate>,
5373 max_width_estimate_and_index: Option<(u64, usize)>,
5374}
5375
5376impl GenerationState {
5377 fn clear(&mut self) {
5378 self.entries.clear();
5379 self.match_candidates.clear();
5380 self.max_width_estimate_and_index = None;
5381 }
5382}
5383
5384#[cfg(test)]
5385mod tests {
5386 use db::indoc;
5387 use gpui::{TestAppContext, VisualTestContext, WindowHandle};
5388 use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
5389 use pretty_assertions::assert_eq;
5390 use project::FakeFs;
5391 use search::project_search::{self, perform_project_search};
5392 use serde_json::json;
5393 use util::path;
5394 use workspace::{OpenOptions, OpenVisible};
5395
5396 use super::*;
5397
5398 const SELECTED_MARKER: &str = " <==== selected";
5399
5400 #[gpui::test(iterations = 10)]
5401 async fn test_project_search_results_toggling(cx: &mut TestAppContext) {
5402 init_test(cx);
5403
5404 let fs = FakeFs::new(cx.background_executor.clone());
5405 let root = path!("/rust-analyzer");
5406 populate_with_test_ra_project(&fs, root).await;
5407 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5408 project.read_with(cx, |project, _| {
5409 project.languages().add(Arc::new(rust_lang()))
5410 });
5411 let workspace = add_outline_panel(&project, cx).await;
5412 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5413 let outline_panel = outline_panel(&workspace, cx);
5414 outline_panel.update_in(cx, |outline_panel, window, cx| {
5415 outline_panel.set_active(true, window, cx)
5416 });
5417
5418 workspace
5419 .update(cx, |workspace, window, cx| {
5420 ProjectSearchView::deploy_search(
5421 workspace,
5422 &workspace::DeploySearch::default(),
5423 window,
5424 cx,
5425 )
5426 })
5427 .unwrap();
5428 let search_view = workspace
5429 .update(cx, |workspace, _, cx| {
5430 workspace
5431 .active_pane()
5432 .read(cx)
5433 .items()
5434 .find_map(|item| item.downcast::<ProjectSearchView>())
5435 .expect("Project search view expected to appear after new search event trigger")
5436 })
5437 .unwrap();
5438
5439 let query = "param_names_for_lifetime_elision_hints";
5440 perform_project_search(&search_view, query, cx);
5441 search_view.update(cx, |search_view, cx| {
5442 search_view
5443 .results_editor()
5444 .update(cx, |results_editor, cx| {
5445 assert_eq!(
5446 results_editor.display_text(cx).match_indices(query).count(),
5447 9
5448 );
5449 });
5450 });
5451
5452 let all_matches = format!(
5453 r#"{root}/
5454 crates/
5455 ide/src/
5456 inlay_hints/
5457 fn_lifetime_fn.rs
5458 search: match config.param_names_for_lifetime_elision_hints {{
5459 search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {{
5460 search: Some(it) if config.param_names_for_lifetime_elision_hints => {{
5461 search: InlayHintsConfig {{ param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }},
5462 inlay_hints.rs
5463 search: pub param_names_for_lifetime_elision_hints: bool,
5464 search: param_names_for_lifetime_elision_hints: self
5465 static_index.rs
5466 search: param_names_for_lifetime_elision_hints: false,
5467 rust-analyzer/src/
5468 cli/
5469 analysis_stats.rs
5470 search: param_names_for_lifetime_elision_hints: true,
5471 config.rs
5472 search: param_names_for_lifetime_elision_hints: self"#
5473 );
5474
5475 let select_first_in_all_matches = |line_to_select: &str| {
5476 assert!(all_matches.contains(line_to_select));
5477 all_matches.replacen(
5478 line_to_select,
5479 &format!("{line_to_select}{SELECTED_MARKER}"),
5480 1,
5481 )
5482 };
5483
5484 cx.executor()
5485 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5486 cx.run_until_parked();
5487 outline_panel.update(cx, |outline_panel, cx| {
5488 assert_eq!(
5489 display_entries(
5490 &project,
5491 &snapshot(outline_panel, cx),
5492 &outline_panel.cached_entries,
5493 outline_panel.selected_entry(),
5494 cx,
5495 ),
5496 select_first_in_all_matches(
5497 "search: match config.param_names_for_lifetime_elision_hints {"
5498 )
5499 );
5500 });
5501
5502 outline_panel.update_in(cx, |outline_panel, window, cx| {
5503 outline_panel.select_parent(&SelectParent, window, cx);
5504 assert_eq!(
5505 display_entries(
5506 &project,
5507 &snapshot(outline_panel, cx),
5508 &outline_panel.cached_entries,
5509 outline_panel.selected_entry(),
5510 cx,
5511 ),
5512 select_first_in_all_matches("fn_lifetime_fn.rs")
5513 );
5514 });
5515 outline_panel.update_in(cx, |outline_panel, window, cx| {
5516 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5517 });
5518 cx.executor()
5519 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5520 cx.run_until_parked();
5521 outline_panel.update(cx, |outline_panel, cx| {
5522 assert_eq!(
5523 display_entries(
5524 &project,
5525 &snapshot(outline_panel, cx),
5526 &outline_panel.cached_entries,
5527 outline_panel.selected_entry(),
5528 cx,
5529 ),
5530 format!(
5531 r#"{root}/
5532 crates/
5533 ide/src/
5534 inlay_hints/
5535 fn_lifetime_fn.rs{SELECTED_MARKER}
5536 inlay_hints.rs
5537 search: pub param_names_for_lifetime_elision_hints: bool,
5538 search: param_names_for_lifetime_elision_hints: self
5539 static_index.rs
5540 search: param_names_for_lifetime_elision_hints: false,
5541 rust-analyzer/src/
5542 cli/
5543 analysis_stats.rs
5544 search: param_names_for_lifetime_elision_hints: true,
5545 config.rs
5546 search: param_names_for_lifetime_elision_hints: self"#,
5547 )
5548 );
5549 });
5550
5551 outline_panel.update_in(cx, |outline_panel, window, cx| {
5552 outline_panel.expand_all_entries(&ExpandAllEntries, window, cx);
5553 });
5554 cx.executor()
5555 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5556 cx.run_until_parked();
5557 outline_panel.update_in(cx, |outline_panel, window, cx| {
5558 outline_panel.select_parent(&SelectParent, window, cx);
5559 assert_eq!(
5560 display_entries(
5561 &project,
5562 &snapshot(outline_panel, cx),
5563 &outline_panel.cached_entries,
5564 outline_panel.selected_entry(),
5565 cx,
5566 ),
5567 select_first_in_all_matches("inlay_hints/")
5568 );
5569 });
5570
5571 outline_panel.update_in(cx, |outline_panel, window, cx| {
5572 outline_panel.select_parent(&SelectParent, window, cx);
5573 assert_eq!(
5574 display_entries(
5575 &project,
5576 &snapshot(outline_panel, cx),
5577 &outline_panel.cached_entries,
5578 outline_panel.selected_entry(),
5579 cx,
5580 ),
5581 select_first_in_all_matches("ide/src/")
5582 );
5583 });
5584
5585 outline_panel.update_in(cx, |outline_panel, window, cx| {
5586 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
5587 });
5588 cx.executor()
5589 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5590 cx.run_until_parked();
5591 outline_panel.update(cx, |outline_panel, cx| {
5592 assert_eq!(
5593 display_entries(
5594 &project,
5595 &snapshot(outline_panel, cx),
5596 &outline_panel.cached_entries,
5597 outline_panel.selected_entry(),
5598 cx,
5599 ),
5600 format!(
5601 r#"{root}/
5602 crates/
5603 ide/src/{SELECTED_MARKER}
5604 rust-analyzer/src/
5605 cli/
5606 analysis_stats.rs
5607 search: param_names_for_lifetime_elision_hints: true,
5608 config.rs
5609 search: param_names_for_lifetime_elision_hints: self"#,
5610 )
5611 );
5612 });
5613 outline_panel.update_in(cx, |outline_panel, window, cx| {
5614 outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx);
5615 });
5616 cx.executor()
5617 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5618 cx.run_until_parked();
5619 outline_panel.update(cx, |outline_panel, cx| {
5620 assert_eq!(
5621 display_entries(
5622 &project,
5623 &snapshot(outline_panel, cx),
5624 &outline_panel.cached_entries,
5625 outline_panel.selected_entry(),
5626 cx,
5627 ),
5628 select_first_in_all_matches("ide/src/")
5629 );
5630 });
5631 }
5632
5633 #[gpui::test(iterations = 10)]
5634 async fn test_item_filtering(cx: &mut TestAppContext) {
5635 init_test(cx);
5636
5637 let fs = FakeFs::new(cx.background_executor.clone());
5638 let root = path!("/rust-analyzer");
5639 populate_with_test_ra_project(&fs, root).await;
5640 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5641 project.read_with(cx, |project, _| {
5642 project.languages().add(Arc::new(rust_lang()))
5643 });
5644 let workspace = add_outline_panel(&project, cx).await;
5645 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5646 let outline_panel = outline_panel(&workspace, cx);
5647 outline_panel.update_in(cx, |outline_panel, window, cx| {
5648 outline_panel.set_active(true, window, cx)
5649 });
5650
5651 workspace
5652 .update(cx, |workspace, window, cx| {
5653 ProjectSearchView::deploy_search(
5654 workspace,
5655 &workspace::DeploySearch::default(),
5656 window,
5657 cx,
5658 )
5659 })
5660 .unwrap();
5661 let search_view = workspace
5662 .update(cx, |workspace, _, cx| {
5663 workspace
5664 .active_pane()
5665 .read(cx)
5666 .items()
5667 .find_map(|item| item.downcast::<ProjectSearchView>())
5668 .expect("Project search view expected to appear after new search event trigger")
5669 })
5670 .unwrap();
5671
5672 let query = "param_names_for_lifetime_elision_hints";
5673 perform_project_search(&search_view, query, cx);
5674 search_view.update(cx, |search_view, cx| {
5675 search_view
5676 .results_editor()
5677 .update(cx, |results_editor, cx| {
5678 assert_eq!(
5679 results_editor.display_text(cx).match_indices(query).count(),
5680 9
5681 );
5682 });
5683 });
5684 let all_matches = format!(
5685 r#"{root}/
5686 crates/
5687 ide/src/
5688 inlay_hints/
5689 fn_lifetime_fn.rs
5690 search: match config.param_names_for_lifetime_elision_hints {{
5691 search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {{
5692 search: Some(it) if config.param_names_for_lifetime_elision_hints => {{
5693 search: InlayHintsConfig {{ param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }},
5694 inlay_hints.rs
5695 search: pub param_names_for_lifetime_elision_hints: bool,
5696 search: param_names_for_lifetime_elision_hints: self
5697 static_index.rs
5698 search: param_names_for_lifetime_elision_hints: false,
5699 rust-analyzer/src/
5700 cli/
5701 analysis_stats.rs
5702 search: param_names_for_lifetime_elision_hints: true,
5703 config.rs
5704 search: param_names_for_lifetime_elision_hints: self"#
5705 );
5706
5707 cx.executor()
5708 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5709 cx.run_until_parked();
5710 outline_panel.update(cx, |outline_panel, cx| {
5711 assert_eq!(
5712 display_entries(
5713 &project,
5714 &snapshot(outline_panel, cx),
5715 &outline_panel.cached_entries,
5716 None,
5717 cx,
5718 ),
5719 all_matches,
5720 );
5721 });
5722
5723 let filter_text = "a";
5724 outline_panel.update_in(cx, |outline_panel, window, cx| {
5725 outline_panel.filter_editor.update(cx, |filter_editor, cx| {
5726 filter_editor.set_text(filter_text, window, cx);
5727 });
5728 });
5729 cx.executor()
5730 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5731 cx.run_until_parked();
5732
5733 outline_panel.update(cx, |outline_panel, cx| {
5734 assert_eq!(
5735 display_entries(
5736 &project,
5737 &snapshot(outline_panel, cx),
5738 &outline_panel.cached_entries,
5739 None,
5740 cx,
5741 ),
5742 all_matches
5743 .lines()
5744 .skip(1) // `/rust-analyzer/` is a root entry with path `` and it will be filtered out
5745 .filter(|item| item.contains(filter_text))
5746 .collect::<Vec<_>>()
5747 .join("\n"),
5748 );
5749 });
5750
5751 outline_panel.update_in(cx, |outline_panel, window, cx| {
5752 outline_panel.filter_editor.update(cx, |filter_editor, cx| {
5753 filter_editor.set_text("", window, cx);
5754 });
5755 });
5756 cx.executor()
5757 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5758 cx.run_until_parked();
5759 outline_panel.update(cx, |outline_panel, cx| {
5760 assert_eq!(
5761 display_entries(
5762 &project,
5763 &snapshot(outline_panel, cx),
5764 &outline_panel.cached_entries,
5765 None,
5766 cx,
5767 ),
5768 all_matches,
5769 );
5770 });
5771 }
5772
5773 #[gpui::test(iterations = 10)]
5774 async fn test_item_opening(cx: &mut TestAppContext) {
5775 init_test(cx);
5776
5777 let fs = FakeFs::new(cx.background_executor.clone());
5778 let root = path!("/rust-analyzer");
5779 populate_with_test_ra_project(&fs, root).await;
5780 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
5781 project.read_with(cx, |project, _| {
5782 project.languages().add(Arc::new(rust_lang()))
5783 });
5784 let workspace = add_outline_panel(&project, cx).await;
5785 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5786 let outline_panel = outline_panel(&workspace, cx);
5787 outline_panel.update_in(cx, |outline_panel, window, cx| {
5788 outline_panel.set_active(true, window, cx)
5789 });
5790
5791 workspace
5792 .update(cx, |workspace, window, cx| {
5793 ProjectSearchView::deploy_search(
5794 workspace,
5795 &workspace::DeploySearch::default(),
5796 window,
5797 cx,
5798 )
5799 })
5800 .unwrap();
5801 let search_view = workspace
5802 .update(cx, |workspace, _, cx| {
5803 workspace
5804 .active_pane()
5805 .read(cx)
5806 .items()
5807 .find_map(|item| item.downcast::<ProjectSearchView>())
5808 .expect("Project search view expected to appear after new search event trigger")
5809 })
5810 .unwrap();
5811
5812 let query = "param_names_for_lifetime_elision_hints";
5813 perform_project_search(&search_view, query, cx);
5814 search_view.update(cx, |search_view, cx| {
5815 search_view
5816 .results_editor()
5817 .update(cx, |results_editor, cx| {
5818 assert_eq!(
5819 results_editor.display_text(cx).match_indices(query).count(),
5820 9
5821 );
5822 });
5823 });
5824 let all_matches = format!(
5825 r#"{root}/
5826 crates/
5827 ide/src/
5828 inlay_hints/
5829 fn_lifetime_fn.rs
5830 search: match config.param_names_for_lifetime_elision_hints {{
5831 search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {{
5832 search: Some(it) if config.param_names_for_lifetime_elision_hints => {{
5833 search: InlayHintsConfig {{ param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }},
5834 inlay_hints.rs
5835 search: pub param_names_for_lifetime_elision_hints: bool,
5836 search: param_names_for_lifetime_elision_hints: self
5837 static_index.rs
5838 search: param_names_for_lifetime_elision_hints: false,
5839 rust-analyzer/src/
5840 cli/
5841 analysis_stats.rs
5842 search: param_names_for_lifetime_elision_hints: true,
5843 config.rs
5844 search: param_names_for_lifetime_elision_hints: self"#
5845 );
5846 let select_first_in_all_matches = |line_to_select: &str| {
5847 assert!(all_matches.contains(line_to_select));
5848 all_matches.replacen(
5849 line_to_select,
5850 &format!("{line_to_select}{SELECTED_MARKER}"),
5851 1,
5852 )
5853 };
5854 cx.executor()
5855 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5856 cx.run_until_parked();
5857
5858 let active_editor = outline_panel.read_with(cx, |outline_panel, _| {
5859 outline_panel
5860 .active_editor()
5861 .expect("should have an active editor open")
5862 });
5863 let initial_outline_selection =
5864 "search: match config.param_names_for_lifetime_elision_hints {";
5865 outline_panel.update_in(cx, |outline_panel, window, cx| {
5866 assert_eq!(
5867 display_entries(
5868 &project,
5869 &snapshot(outline_panel, cx),
5870 &outline_panel.cached_entries,
5871 outline_panel.selected_entry(),
5872 cx,
5873 ),
5874 select_first_in_all_matches(initial_outline_selection)
5875 );
5876 assert_eq!(
5877 selected_row_text(&active_editor, cx),
5878 initial_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5879 "Should place the initial editor selection on the corresponding search result"
5880 );
5881
5882 outline_panel.select_next(&SelectNext, window, cx);
5883 outline_panel.select_next(&SelectNext, window, cx);
5884 });
5885
5886 let navigated_outline_selection =
5887 "search: Some(it) if config.param_names_for_lifetime_elision_hints => {";
5888 outline_panel.update(cx, |outline_panel, cx| {
5889 assert_eq!(
5890 display_entries(
5891 &project,
5892 &snapshot(outline_panel, cx),
5893 &outline_panel.cached_entries,
5894 outline_panel.selected_entry(),
5895 cx,
5896 ),
5897 select_first_in_all_matches(navigated_outline_selection)
5898 );
5899 });
5900 cx.executor()
5901 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5902 outline_panel.update(cx, |_, cx| {
5903 assert_eq!(
5904 selected_row_text(&active_editor, cx),
5905 navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5906 "Should still have the initial caret position after SelectNext calls"
5907 );
5908 });
5909
5910 outline_panel.update_in(cx, |outline_panel, window, cx| {
5911 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
5912 });
5913 outline_panel.update(cx, |_outline_panel, cx| {
5914 assert_eq!(
5915 selected_row_text(&active_editor, cx),
5916 navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5917 "After opening, should move the caret to the opened outline entry's position"
5918 );
5919 });
5920
5921 outline_panel.update_in(cx, |outline_panel, window, cx| {
5922 outline_panel.select_next(&SelectNext, window, cx);
5923 });
5924 let next_navigated_outline_selection = "search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },";
5925 outline_panel.update(cx, |outline_panel, cx| {
5926 assert_eq!(
5927 display_entries(
5928 &project,
5929 &snapshot(outline_panel, cx),
5930 &outline_panel.cached_entries,
5931 outline_panel.selected_entry(),
5932 cx,
5933 ),
5934 select_first_in_all_matches(next_navigated_outline_selection)
5935 );
5936 });
5937 cx.executor()
5938 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5939 outline_panel.update(cx, |_outline_panel, cx| {
5940 assert_eq!(
5941 selected_row_text(&active_editor, cx),
5942 next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5943 "Should again preserve the selection after another SelectNext call"
5944 );
5945 });
5946
5947 outline_panel.update_in(cx, |outline_panel, window, cx| {
5948 outline_panel.open_excerpts(&editor::actions::OpenExcerpts, window, cx);
5949 });
5950 cx.executor()
5951 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5952 cx.run_until_parked();
5953 let new_active_editor = outline_panel.read_with(cx, |outline_panel, _| {
5954 outline_panel
5955 .active_editor()
5956 .expect("should have an active editor open")
5957 });
5958 outline_panel.update(cx, |outline_panel, cx| {
5959 assert_ne!(
5960 active_editor, new_active_editor,
5961 "After opening an excerpt, new editor should be open"
5962 );
5963 assert_eq!(
5964 display_entries(
5965 &project,
5966 &snapshot(outline_panel, cx),
5967 &outline_panel.cached_entries,
5968 outline_panel.selected_entry(),
5969 cx,
5970 ),
5971 "fn_lifetime_fn.rs <==== selected"
5972 );
5973 assert_eq!(
5974 selected_row_text(&new_active_editor, cx),
5975 next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5976 "When opening the excerpt, should navigate to the place corresponding the outline entry"
5977 );
5978 });
5979 }
5980
5981 #[gpui::test]
5982 async fn test_multiple_workrees(cx: &mut TestAppContext) {
5983 init_test(cx);
5984
5985 let fs = FakeFs::new(cx.background_executor.clone());
5986 fs.insert_tree(
5987 path!("/root"),
5988 json!({
5989 "one": {
5990 "a.txt": "aaa aaa"
5991 },
5992 "two": {
5993 "b.txt": "a aaa"
5994 }
5995
5996 }),
5997 )
5998 .await;
5999 let project = Project::test(fs.clone(), [Path::new(path!("/root/one"))], cx).await;
6000 let workspace = add_outline_panel(&project, cx).await;
6001 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6002 let outline_panel = outline_panel(&workspace, cx);
6003 outline_panel.update_in(cx, |outline_panel, window, cx| {
6004 outline_panel.set_active(true, window, cx)
6005 });
6006
6007 let items = workspace
6008 .update(cx, |workspace, window, cx| {
6009 workspace.open_paths(
6010 vec![PathBuf::from(path!("/root/two"))],
6011 OpenOptions {
6012 visible: Some(OpenVisible::OnlyDirectories),
6013 ..Default::default()
6014 },
6015 None,
6016 window,
6017 cx,
6018 )
6019 })
6020 .unwrap()
6021 .await;
6022 assert_eq!(items.len(), 1, "Were opening another worktree directory");
6023 assert!(
6024 items[0].is_none(),
6025 "Directory should be opened successfully"
6026 );
6027
6028 workspace
6029 .update(cx, |workspace, window, cx| {
6030 ProjectSearchView::deploy_search(
6031 workspace,
6032 &workspace::DeploySearch::default(),
6033 window,
6034 cx,
6035 )
6036 })
6037 .unwrap();
6038 let search_view = workspace
6039 .update(cx, |workspace, _, cx| {
6040 workspace
6041 .active_pane()
6042 .read(cx)
6043 .items()
6044 .find_map(|item| item.downcast::<ProjectSearchView>())
6045 .expect("Project search view expected to appear after new search event trigger")
6046 })
6047 .unwrap();
6048
6049 let query = "aaa";
6050 perform_project_search(&search_view, query, cx);
6051 search_view.update(cx, |search_view, cx| {
6052 search_view
6053 .results_editor()
6054 .update(cx, |results_editor, cx| {
6055 assert_eq!(
6056 results_editor.display_text(cx).match_indices(query).count(),
6057 3
6058 );
6059 });
6060 });
6061
6062 cx.executor()
6063 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6064 cx.run_until_parked();
6065 outline_panel.update(cx, |outline_panel, cx| {
6066 assert_eq!(
6067 display_entries(
6068 &project,
6069 &snapshot(outline_panel, cx),
6070 &outline_panel.cached_entries,
6071 outline_panel.selected_entry(),
6072 cx,
6073 ),
6074 format!(
6075 r#"{}/
6076 a.txt
6077 search: aaa aaa <==== selected
6078 search: aaa aaa
6079{}/
6080 b.txt
6081 search: a aaa"#,
6082 path!("/root/one"),
6083 path!("/root/two"),
6084 ),
6085 );
6086 });
6087
6088 outline_panel.update_in(cx, |outline_panel, window, cx| {
6089 outline_panel.select_previous(&SelectPrevious, window, cx);
6090 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
6091 });
6092 cx.executor()
6093 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6094 cx.run_until_parked();
6095 outline_panel.update(cx, |outline_panel, cx| {
6096 assert_eq!(
6097 display_entries(
6098 &project,
6099 &snapshot(outline_panel, cx),
6100 &outline_panel.cached_entries,
6101 outline_panel.selected_entry(),
6102 cx,
6103 ),
6104 format!(
6105 r#"{}/
6106 a.txt <==== selected
6107{}/
6108 b.txt
6109 search: a aaa"#,
6110 path!("/root/one"),
6111 path!("/root/two"),
6112 ),
6113 );
6114 });
6115
6116 outline_panel.update_in(cx, |outline_panel, window, cx| {
6117 outline_panel.select_next(&SelectNext, window, cx);
6118 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
6119 });
6120 cx.executor()
6121 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6122 cx.run_until_parked();
6123 outline_panel.update(cx, |outline_panel, cx| {
6124 assert_eq!(
6125 display_entries(
6126 &project,
6127 &snapshot(outline_panel, cx),
6128 &outline_panel.cached_entries,
6129 outline_panel.selected_entry(),
6130 cx,
6131 ),
6132 format!(
6133 r#"{}/
6134 a.txt
6135{}/ <==== selected"#,
6136 path!("/root/one"),
6137 path!("/root/two"),
6138 ),
6139 );
6140 });
6141
6142 outline_panel.update_in(cx, |outline_panel, window, cx| {
6143 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
6144 });
6145 cx.executor()
6146 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6147 cx.run_until_parked();
6148 outline_panel.update(cx, |outline_panel, cx| {
6149 assert_eq!(
6150 display_entries(
6151 &project,
6152 &snapshot(outline_panel, cx),
6153 &outline_panel.cached_entries,
6154 outline_panel.selected_entry(),
6155 cx,
6156 ),
6157 format!(
6158 r#"{}/
6159 a.txt
6160{}/ <==== selected
6161 b.txt
6162 search: a aaa"#,
6163 path!("/root/one"),
6164 path!("/root/two"),
6165 )
6166 );
6167 });
6168 }
6169
6170 #[gpui::test]
6171 async fn test_navigating_in_singleton(cx: &mut TestAppContext) {
6172 init_test(cx);
6173
6174 let root = path!("/root");
6175 let fs = FakeFs::new(cx.background_executor.clone());
6176 fs.insert_tree(
6177 root,
6178 json!({
6179 "src": {
6180 "lib.rs": indoc!("
6181#[derive(Clone, Debug, PartialEq, Eq, Hash)]
6182struct OutlineEntryExcerpt {
6183 id: ExcerptId,
6184 buffer_id: BufferId,
6185 range: ExcerptRange<language::Anchor>,
6186}"),
6187 }
6188 }),
6189 )
6190 .await;
6191 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
6192 project.read_with(cx, |project, _| {
6193 project.languages().add(Arc::new(
6194 rust_lang()
6195 .with_outline_query(
6196 r#"
6197 (struct_item
6198 (visibility_modifier)? @context
6199 "struct" @context
6200 name: (_) @name) @item
6201
6202 (field_declaration
6203 (visibility_modifier)? @context
6204 name: (_) @name) @item
6205"#,
6206 )
6207 .unwrap(),
6208 ))
6209 });
6210 let workspace = add_outline_panel(&project, cx).await;
6211 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6212 let outline_panel = outline_panel(&workspace, cx);
6213 cx.update(|window, cx| {
6214 outline_panel.update(cx, |outline_panel, cx| {
6215 outline_panel.set_active(true, window, cx)
6216 });
6217 });
6218
6219 let _editor = workspace
6220 .update(cx, |workspace, window, cx| {
6221 workspace.open_abs_path(
6222 PathBuf::from(path!("/root/src/lib.rs")),
6223 OpenOptions {
6224 visible: Some(OpenVisible::All),
6225 ..Default::default()
6226 },
6227 window,
6228 cx,
6229 )
6230 })
6231 .unwrap()
6232 .await
6233 .expect("Failed to open Rust source file")
6234 .downcast::<Editor>()
6235 .expect("Should open an editor for Rust source file");
6236
6237 cx.executor()
6238 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6239 cx.run_until_parked();
6240 outline_panel.update(cx, |outline_panel, cx| {
6241 assert_eq!(
6242 display_entries(
6243 &project,
6244 &snapshot(outline_panel, cx),
6245 &outline_panel.cached_entries,
6246 outline_panel.selected_entry(),
6247 cx,
6248 ),
6249 indoc!(
6250 "
6251outline: struct OutlineEntryExcerpt
6252 outline: id
6253 outline: buffer_id
6254 outline: range"
6255 )
6256 );
6257 });
6258
6259 cx.update(|window, cx| {
6260 outline_panel.update(cx, |outline_panel, cx| {
6261 outline_panel.select_next(&SelectNext, window, cx);
6262 });
6263 });
6264 cx.executor()
6265 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6266 cx.run_until_parked();
6267 outline_panel.update(cx, |outline_panel, cx| {
6268 assert_eq!(
6269 display_entries(
6270 &project,
6271 &snapshot(outline_panel, cx),
6272 &outline_panel.cached_entries,
6273 outline_panel.selected_entry(),
6274 cx,
6275 ),
6276 indoc!(
6277 "
6278outline: struct OutlineEntryExcerpt <==== selected
6279 outline: id
6280 outline: buffer_id
6281 outline: range"
6282 )
6283 );
6284 });
6285
6286 cx.update(|window, cx| {
6287 outline_panel.update(cx, |outline_panel, cx| {
6288 outline_panel.select_next(&SelectNext, window, cx);
6289 });
6290 });
6291 cx.executor()
6292 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6293 cx.run_until_parked();
6294 outline_panel.update(cx, |outline_panel, cx| {
6295 assert_eq!(
6296 display_entries(
6297 &project,
6298 &snapshot(outline_panel, cx),
6299 &outline_panel.cached_entries,
6300 outline_panel.selected_entry(),
6301 cx,
6302 ),
6303 indoc!(
6304 "
6305outline: struct OutlineEntryExcerpt
6306 outline: id <==== selected
6307 outline: buffer_id
6308 outline: range"
6309 )
6310 );
6311 });
6312
6313 cx.update(|window, cx| {
6314 outline_panel.update(cx, |outline_panel, cx| {
6315 outline_panel.select_next(&SelectNext, window, cx);
6316 });
6317 });
6318 cx.executor()
6319 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6320 cx.run_until_parked();
6321 outline_panel.update(cx, |outline_panel, cx| {
6322 assert_eq!(
6323 display_entries(
6324 &project,
6325 &snapshot(outline_panel, cx),
6326 &outline_panel.cached_entries,
6327 outline_panel.selected_entry(),
6328 cx,
6329 ),
6330 indoc!(
6331 "
6332outline: struct OutlineEntryExcerpt
6333 outline: id
6334 outline: buffer_id <==== selected
6335 outline: range"
6336 )
6337 );
6338 });
6339
6340 cx.update(|window, cx| {
6341 outline_panel.update(cx, |outline_panel, cx| {
6342 outline_panel.select_next(&SelectNext, window, cx);
6343 });
6344 });
6345 cx.executor()
6346 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6347 cx.run_until_parked();
6348 outline_panel.update(cx, |outline_panel, cx| {
6349 assert_eq!(
6350 display_entries(
6351 &project,
6352 &snapshot(outline_panel, cx),
6353 &outline_panel.cached_entries,
6354 outline_panel.selected_entry(),
6355 cx,
6356 ),
6357 indoc!(
6358 "
6359outline: struct OutlineEntryExcerpt
6360 outline: id
6361 outline: buffer_id
6362 outline: range <==== selected"
6363 )
6364 );
6365 });
6366
6367 cx.update(|window, cx| {
6368 outline_panel.update(cx, |outline_panel, cx| {
6369 outline_panel.select_next(&SelectNext, window, cx);
6370 });
6371 });
6372 cx.executor()
6373 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6374 cx.run_until_parked();
6375 outline_panel.update(cx, |outline_panel, cx| {
6376 assert_eq!(
6377 display_entries(
6378 &project,
6379 &snapshot(outline_panel, cx),
6380 &outline_panel.cached_entries,
6381 outline_panel.selected_entry(),
6382 cx,
6383 ),
6384 indoc!(
6385 "
6386outline: struct OutlineEntryExcerpt <==== selected
6387 outline: id
6388 outline: buffer_id
6389 outline: range"
6390 )
6391 );
6392 });
6393
6394 cx.update(|window, cx| {
6395 outline_panel.update(cx, |outline_panel, cx| {
6396 outline_panel.select_previous(&SelectPrevious, window, cx);
6397 });
6398 });
6399 cx.executor()
6400 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6401 cx.run_until_parked();
6402 outline_panel.update(cx, |outline_panel, cx| {
6403 assert_eq!(
6404 display_entries(
6405 &project,
6406 &snapshot(outline_panel, cx),
6407 &outline_panel.cached_entries,
6408 outline_panel.selected_entry(),
6409 cx,
6410 ),
6411 indoc!(
6412 "
6413outline: struct OutlineEntryExcerpt
6414 outline: id
6415 outline: buffer_id
6416 outline: range <==== selected"
6417 )
6418 );
6419 });
6420
6421 cx.update(|window, cx| {
6422 outline_panel.update(cx, |outline_panel, cx| {
6423 outline_panel.select_previous(&SelectPrevious, window, cx);
6424 });
6425 });
6426 cx.executor()
6427 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6428 cx.run_until_parked();
6429 outline_panel.update(cx, |outline_panel, cx| {
6430 assert_eq!(
6431 display_entries(
6432 &project,
6433 &snapshot(outline_panel, cx),
6434 &outline_panel.cached_entries,
6435 outline_panel.selected_entry(),
6436 cx,
6437 ),
6438 indoc!(
6439 "
6440outline: struct OutlineEntryExcerpt
6441 outline: id
6442 outline: buffer_id <==== selected
6443 outline: range"
6444 )
6445 );
6446 });
6447
6448 cx.update(|window, cx| {
6449 outline_panel.update(cx, |outline_panel, cx| {
6450 outline_panel.select_previous(&SelectPrevious, window, cx);
6451 });
6452 });
6453 cx.executor()
6454 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6455 cx.run_until_parked();
6456 outline_panel.update(cx, |outline_panel, cx| {
6457 assert_eq!(
6458 display_entries(
6459 &project,
6460 &snapshot(outline_panel, cx),
6461 &outline_panel.cached_entries,
6462 outline_panel.selected_entry(),
6463 cx,
6464 ),
6465 indoc!(
6466 "
6467outline: struct OutlineEntryExcerpt
6468 outline: id <==== selected
6469 outline: buffer_id
6470 outline: range"
6471 )
6472 );
6473 });
6474
6475 cx.update(|window, cx| {
6476 outline_panel.update(cx, |outline_panel, cx| {
6477 outline_panel.select_previous(&SelectPrevious, window, cx);
6478 });
6479 });
6480 cx.executor()
6481 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6482 cx.run_until_parked();
6483 outline_panel.update(cx, |outline_panel, cx| {
6484 assert_eq!(
6485 display_entries(
6486 &project,
6487 &snapshot(outline_panel, cx),
6488 &outline_panel.cached_entries,
6489 outline_panel.selected_entry(),
6490 cx,
6491 ),
6492 indoc!(
6493 "
6494outline: struct OutlineEntryExcerpt <==== selected
6495 outline: id
6496 outline: buffer_id
6497 outline: range"
6498 )
6499 );
6500 });
6501
6502 cx.update(|window, cx| {
6503 outline_panel.update(cx, |outline_panel, cx| {
6504 outline_panel.select_previous(&SelectPrevious, window, cx);
6505 });
6506 });
6507 cx.executor()
6508 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6509 cx.run_until_parked();
6510 outline_panel.update(cx, |outline_panel, cx| {
6511 assert_eq!(
6512 display_entries(
6513 &project,
6514 &snapshot(outline_panel, cx),
6515 &outline_panel.cached_entries,
6516 outline_panel.selected_entry(),
6517 cx,
6518 ),
6519 indoc!(
6520 "
6521outline: struct OutlineEntryExcerpt
6522 outline: id
6523 outline: buffer_id
6524 outline: range <==== selected"
6525 )
6526 );
6527 });
6528 }
6529
6530 #[gpui::test(iterations = 10)]
6531 async fn test_frontend_repo_structure(cx: &mut TestAppContext) {
6532 init_test(cx);
6533
6534 let root = path!("/frontend-project");
6535 let fs = FakeFs::new(cx.background_executor.clone());
6536 fs.insert_tree(
6537 root,
6538 json!({
6539 "public": {
6540 "lottie": {
6541 "syntax-tree.json": r#"{ "something": "static" }"#
6542 }
6543 },
6544 "src": {
6545 "app": {
6546 "(site)": {
6547 "(about)": {
6548 "jobs": {
6549 "[slug]": {
6550 "page.tsx": r#"static"#
6551 }
6552 }
6553 },
6554 "(blog)": {
6555 "post": {
6556 "[slug]": {
6557 "page.tsx": r#"static"#
6558 }
6559 }
6560 },
6561 }
6562 },
6563 "components": {
6564 "ErrorBoundary.tsx": r#"static"#,
6565 }
6566 }
6567
6568 }),
6569 )
6570 .await;
6571 let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
6572 let workspace = add_outline_panel(&project, cx).await;
6573 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6574 let outline_panel = outline_panel(&workspace, cx);
6575 outline_panel.update_in(cx, |outline_panel, window, cx| {
6576 outline_panel.set_active(true, window, cx)
6577 });
6578
6579 workspace
6580 .update(cx, |workspace, window, cx| {
6581 ProjectSearchView::deploy_search(
6582 workspace,
6583 &workspace::DeploySearch::default(),
6584 window,
6585 cx,
6586 )
6587 })
6588 .unwrap();
6589 let search_view = workspace
6590 .update(cx, |workspace, _, cx| {
6591 workspace
6592 .active_pane()
6593 .read(cx)
6594 .items()
6595 .find_map(|item| item.downcast::<ProjectSearchView>())
6596 .expect("Project search view expected to appear after new search event trigger")
6597 })
6598 .unwrap();
6599
6600 let query = "static";
6601 perform_project_search(&search_view, query, cx);
6602 search_view.update(cx, |search_view, cx| {
6603 search_view
6604 .results_editor()
6605 .update(cx, |results_editor, cx| {
6606 assert_eq!(
6607 results_editor.display_text(cx).match_indices(query).count(),
6608 4
6609 );
6610 });
6611 });
6612
6613 cx.executor()
6614 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6615 cx.run_until_parked();
6616 outline_panel.update(cx, |outline_panel, cx| {
6617 assert_eq!(
6618 display_entries(
6619 &project,
6620 &snapshot(outline_panel, cx),
6621 &outline_panel.cached_entries,
6622 outline_panel.selected_entry(),
6623 cx,
6624 ),
6625 format!(
6626 r#"{root}/
6627 public/lottie/
6628 syntax-tree.json
6629 search: {{ "something": "static" }} <==== selected
6630 src/
6631 app/(site)/
6632 (about)/jobs/[slug]/
6633 page.tsx
6634 search: static
6635 (blog)/post/[slug]/
6636 page.tsx
6637 search: static
6638 components/
6639 ErrorBoundary.tsx
6640 search: static"#
6641 )
6642 );
6643 });
6644
6645 outline_panel.update_in(cx, |outline_panel, window, cx| {
6646 // Move to 5th element in the list, 3 items down.
6647 for _ in 0..2 {
6648 outline_panel.select_next(&SelectNext, window, cx);
6649 }
6650 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
6651 });
6652 cx.executor()
6653 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6654 cx.run_until_parked();
6655 outline_panel.update(cx, |outline_panel, cx| {
6656 assert_eq!(
6657 display_entries(
6658 &project,
6659 &snapshot(outline_panel, cx),
6660 &outline_panel.cached_entries,
6661 outline_panel.selected_entry(),
6662 cx,
6663 ),
6664 format!(
6665 r#"{root}/
6666 public/lottie/
6667 syntax-tree.json
6668 search: {{ "something": "static" }}
6669 src/
6670 app/(site)/ <==== selected
6671 components/
6672 ErrorBoundary.tsx
6673 search: static"#
6674 )
6675 );
6676 });
6677
6678 outline_panel.update_in(cx, |outline_panel, window, cx| {
6679 // Move to the next visible non-FS entry
6680 for _ in 0..3 {
6681 outline_panel.select_next(&SelectNext, window, cx);
6682 }
6683 });
6684 cx.run_until_parked();
6685 outline_panel.update(cx, |outline_panel, cx| {
6686 assert_eq!(
6687 display_entries(
6688 &project,
6689 &snapshot(outline_panel, cx),
6690 &outline_panel.cached_entries,
6691 outline_panel.selected_entry(),
6692 cx,
6693 ),
6694 format!(
6695 r#"{root}/
6696 public/lottie/
6697 syntax-tree.json
6698 search: {{ "something": "static" }}
6699 src/
6700 app/(site)/
6701 components/
6702 ErrorBoundary.tsx
6703 search: static <==== selected"#
6704 )
6705 );
6706 });
6707
6708 outline_panel.update_in(cx, |outline_panel, window, cx| {
6709 outline_panel
6710 .active_editor()
6711 .expect("Should have an active editor")
6712 .update(cx, |editor, cx| {
6713 editor.toggle_fold(&editor::actions::ToggleFold, window, cx)
6714 });
6715 });
6716 cx.executor()
6717 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6718 cx.run_until_parked();
6719 outline_panel.update(cx, |outline_panel, cx| {
6720 assert_eq!(
6721 display_entries(
6722 &project,
6723 &snapshot(outline_panel, cx),
6724 &outline_panel.cached_entries,
6725 outline_panel.selected_entry(),
6726 cx,
6727 ),
6728 format!(
6729 r#"{root}/
6730 public/lottie/
6731 syntax-tree.json
6732 search: {{ "something": "static" }}
6733 src/
6734 app/(site)/
6735 components/
6736 ErrorBoundary.tsx <==== selected"#
6737 )
6738 );
6739 });
6740
6741 outline_panel.update_in(cx, |outline_panel, window, cx| {
6742 outline_panel
6743 .active_editor()
6744 .expect("Should have an active editor")
6745 .update(cx, |editor, cx| {
6746 editor.toggle_fold(&editor::actions::ToggleFold, window, cx)
6747 });
6748 });
6749 cx.executor()
6750 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
6751 cx.run_until_parked();
6752 outline_panel.update(cx, |outline_panel, cx| {
6753 assert_eq!(
6754 display_entries(
6755 &project,
6756 &snapshot(outline_panel, cx),
6757 &outline_panel.cached_entries,
6758 outline_panel.selected_entry(),
6759 cx,
6760 ),
6761 format!(
6762 r#"{root}/
6763 public/lottie/
6764 syntax-tree.json
6765 search: {{ "something": "static" }}
6766 src/
6767 app/(site)/
6768 components/
6769 ErrorBoundary.tsx <==== selected
6770 search: static"#
6771 )
6772 );
6773 });
6774 }
6775
6776 async fn add_outline_panel(
6777 project: &Entity<Project>,
6778 cx: &mut TestAppContext,
6779 ) -> WindowHandle<Workspace> {
6780 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6781
6782 let outline_panel = window
6783 .update(cx, |_, window, cx| {
6784 cx.spawn_in(window, async |this, cx| {
6785 OutlinePanel::load(this, cx.clone()).await
6786 })
6787 })
6788 .unwrap()
6789 .await
6790 .expect("Failed to load outline panel");
6791
6792 window
6793 .update(cx, |workspace, window, cx| {
6794 workspace.add_panel(outline_panel, window, cx);
6795 })
6796 .unwrap();
6797 window
6798 }
6799
6800 fn outline_panel(
6801 workspace: &WindowHandle<Workspace>,
6802 cx: &mut TestAppContext,
6803 ) -> Entity<OutlinePanel> {
6804 workspace
6805 .update(cx, |workspace, _, cx| {
6806 workspace
6807 .panel::<OutlinePanel>(cx)
6808 .expect("no outline panel")
6809 })
6810 .unwrap()
6811 }
6812
6813 fn display_entries(
6814 project: &Entity<Project>,
6815 multi_buffer_snapshot: &MultiBufferSnapshot,
6816 cached_entries: &[CachedEntry],
6817 selected_entry: Option<&PanelEntry>,
6818 cx: &mut App,
6819 ) -> String {
6820 let mut display_string = String::new();
6821 for entry in cached_entries {
6822 if !display_string.is_empty() {
6823 display_string += "\n";
6824 }
6825 for _ in 0..entry.depth {
6826 display_string += " ";
6827 }
6828 display_string += &match &entry.entry {
6829 PanelEntry::Fs(entry) => match entry {
6830 FsEntry::ExternalFile(_) => {
6831 panic!("Did not cover external files with tests")
6832 }
6833 FsEntry::Directory(directory) => {
6834 match project
6835 .read(cx)
6836 .worktree_for_id(directory.worktree_id, cx)
6837 .and_then(|worktree| {
6838 if worktree.read(cx).root_entry() == Some(&directory.entry.entry) {
6839 Some(worktree.read(cx).abs_path())
6840 } else {
6841 None
6842 }
6843 }) {
6844 Some(root_path) => format!(
6845 "{}/{}",
6846 root_path.display(),
6847 directory.entry.path.display(),
6848 ),
6849 None => format!(
6850 "{}/",
6851 directory
6852 .entry
6853 .path
6854 .file_name()
6855 .unwrap_or_default()
6856 .to_string_lossy()
6857 ),
6858 }
6859 }
6860 FsEntry::File(file) => file
6861 .entry
6862 .path
6863 .file_name()
6864 .map(|name| name.to_string_lossy().to_string())
6865 .unwrap_or_default(),
6866 },
6867 PanelEntry::FoldedDirs(folded_dirs) => folded_dirs
6868 .entries
6869 .iter()
6870 .filter_map(|dir| dir.path.file_name())
6871 .map(|name| name.to_string_lossy().to_string() + "/")
6872 .collect(),
6873 PanelEntry::Outline(outline_entry) => match outline_entry {
6874 OutlineEntry::Excerpt(_) => continue,
6875 OutlineEntry::Outline(outline_entry) => {
6876 format!("outline: {}", outline_entry.outline.text)
6877 }
6878 },
6879 PanelEntry::Search(search_entry) => {
6880 format!(
6881 "search: {}",
6882 search_entry
6883 .render_data
6884 .get_or_init(|| SearchData::new(
6885 &search_entry.match_range,
6886 multi_buffer_snapshot
6887 ))
6888 .context_text
6889 )
6890 }
6891 };
6892
6893 if Some(&entry.entry) == selected_entry {
6894 display_string += SELECTED_MARKER;
6895 }
6896 }
6897 display_string
6898 }
6899
6900 fn init_test(cx: &mut TestAppContext) {
6901 cx.update(|cx| {
6902 let settings = SettingsStore::test(cx);
6903 cx.set_global(settings);
6904
6905 theme::init(theme::LoadThemes::JustBase, cx);
6906
6907 language::init(cx);
6908 editor::init(cx);
6909 workspace::init_settings(cx);
6910 Project::init_settings(cx);
6911 project_search::init(cx);
6912 super::init(cx);
6913 });
6914 }
6915
6916 // Based on https://github.com/rust-lang/rust-analyzer/
6917 async fn populate_with_test_ra_project(fs: &FakeFs, root: &str) {
6918 fs.insert_tree(
6919 root,
6920 json!({
6921 "crates": {
6922 "ide": {
6923 "src": {
6924 "inlay_hints": {
6925 "fn_lifetime_fn.rs": r##"
6926 pub(super) fn hints(
6927 acc: &mut Vec<InlayHint>,
6928 config: &InlayHintsConfig,
6929 func: ast::Fn,
6930 ) -> Option<()> {
6931 // ... snip
6932
6933 let mut used_names: FxHashMap<SmolStr, usize> =
6934 match config.param_names_for_lifetime_elision_hints {
6935 true => generic_param_list
6936 .iter()
6937 .flat_map(|gpl| gpl.lifetime_params())
6938 .filter_map(|param| param.lifetime())
6939 .filter_map(|lt| Some((SmolStr::from(lt.text().as_str().get(1..)?), 0)))
6940 .collect(),
6941 false => Default::default(),
6942 };
6943 {
6944 let mut potential_lt_refs = potential_lt_refs.iter().filter(|&&(.., is_elided)| is_elided);
6945 if self_param.is_some() && potential_lt_refs.next().is_some() {
6946 allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
6947 // self can't be used as a lifetime, so no need to check for collisions
6948 "'self".into()
6949 } else {
6950 gen_idx_name()
6951 });
6952 }
6953 potential_lt_refs.for_each(|(name, ..)| {
6954 let name = match name {
6955 Some(it) if config.param_names_for_lifetime_elision_hints => {
6956 if let Some(c) = used_names.get_mut(it.text().as_str()) {
6957 *c += 1;
6958 SmolStr::from(format!("'{text}{c}", text = it.text().as_str()))
6959 } else {
6960 used_names.insert(it.text().as_str().into(), 0);
6961 SmolStr::from_iter(["\'", it.text().as_str()])
6962 }
6963 }
6964 _ => gen_idx_name(),
6965 };
6966 allocated_lifetimes.push(name);
6967 });
6968 }
6969
6970 // ... snip
6971 }
6972
6973 // ... snip
6974
6975 #[test]
6976 fn hints_lifetimes_named() {
6977 check_with_config(
6978 InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
6979 r#"
6980 fn nested_in<'named>(named: & &X< &()>) {}
6981 // ^'named1, 'named2, 'named3, $
6982 //^'named1 ^'named2 ^'named3
6983 "#,
6984 );
6985 }
6986
6987 // ... snip
6988 "##,
6989 },
6990 "inlay_hints.rs": r#"
6991 #[derive(Clone, Debug, PartialEq, Eq)]
6992 pub struct InlayHintsConfig {
6993 // ... snip
6994 pub param_names_for_lifetime_elision_hints: bool,
6995 pub max_length: Option<usize>,
6996 // ... snip
6997 }
6998
6999 impl Config {
7000 pub fn inlay_hints(&self) -> InlayHintsConfig {
7001 InlayHintsConfig {
7002 // ... snip
7003 param_names_for_lifetime_elision_hints: self
7004 .inlayHints_lifetimeElisionHints_useParameterNames()
7005 .to_owned(),
7006 max_length: self.inlayHints_maxLength().to_owned(),
7007 // ... snip
7008 }
7009 }
7010 }
7011 "#,
7012 "static_index.rs": r#"
7013// ... snip
7014 fn add_file(&mut self, file_id: FileId) {
7015 let current_crate = crates_for(self.db, file_id).pop().map(Into::into);
7016 let folds = self.analysis.folding_ranges(file_id).unwrap();
7017 let inlay_hints = self
7018 .analysis
7019 .inlay_hints(
7020 &InlayHintsConfig {
7021 // ... snip
7022 closure_style: hir::ClosureStyle::ImplFn,
7023 param_names_for_lifetime_elision_hints: false,
7024 binding_mode_hints: false,
7025 max_length: Some(25),
7026 closure_capture_hints: false,
7027 // ... snip
7028 },
7029 file_id,
7030 None,
7031 )
7032 .unwrap();
7033 // ... snip
7034 }
7035// ... snip
7036 "#
7037 }
7038 },
7039 "rust-analyzer": {
7040 "src": {
7041 "cli": {
7042 "analysis_stats.rs": r#"
7043 // ... snip
7044 for &file_id in &file_ids {
7045 _ = analysis.inlay_hints(
7046 &InlayHintsConfig {
7047 // ... snip
7048 implicit_drop_hints: true,
7049 lifetime_elision_hints: ide::LifetimeElisionHints::Always,
7050 param_names_for_lifetime_elision_hints: true,
7051 hide_named_constructor_hints: false,
7052 hide_closure_initialization_hints: false,
7053 closure_style: hir::ClosureStyle::ImplFn,
7054 max_length: Some(25),
7055 closing_brace_hints_min_lines: Some(20),
7056 fields_to_resolve: InlayFieldsToResolve::empty(),
7057 range_exclusive_hints: true,
7058 },
7059 file_id.into(),
7060 None,
7061 );
7062 }
7063 // ... snip
7064 "#,
7065 },
7066 "config.rs": r#"
7067 config_data! {
7068 /// Configs that only make sense when they are set by a client. As such they can only be defined
7069 /// by setting them using client's settings (e.g `settings.json` on VS Code).
7070 client: struct ClientDefaultConfigData <- ClientConfigInput -> {
7071 // ... snip
7072 /// Maximum length for inlay hints. Set to null to have an unlimited length.
7073 inlayHints_maxLength: Option<usize> = Some(25),
7074 // ... snip
7075 /// Whether to prefer using parameter names as the name for elided lifetime hints if possible.
7076 inlayHints_lifetimeElisionHints_useParameterNames: bool = false,
7077 // ... snip
7078 }
7079 }
7080
7081 impl Config {
7082 // ... snip
7083 pub fn inlay_hints(&self) -> InlayHintsConfig {
7084 InlayHintsConfig {
7085 // ... snip
7086 param_names_for_lifetime_elision_hints: self
7087 .inlayHints_lifetimeElisionHints_useParameterNames()
7088 .to_owned(),
7089 max_length: self.inlayHints_maxLength().to_owned(),
7090 // ... snip
7091 }
7092 }
7093 // ... snip
7094 }
7095 "#
7096 }
7097 }
7098 }
7099 }),
7100 )
7101 .await;
7102 }
7103
7104 fn rust_lang() -> Language {
7105 Language::new(
7106 LanguageConfig {
7107 name: "Rust".into(),
7108 matcher: LanguageMatcher {
7109 path_suffixes: vec!["rs".to_string()],
7110 ..Default::default()
7111 },
7112 ..Default::default()
7113 },
7114 Some(tree_sitter_rust::LANGUAGE.into()),
7115 )
7116 .with_highlights_query(
7117 r#"
7118 (field_identifier) @field
7119 (struct_expression) @struct
7120 "#,
7121 )
7122 .unwrap()
7123 .with_injection_query(
7124 r#"
7125 (macro_invocation
7126 (token_tree) @injection.content
7127 (#set! injection.language "rust"))
7128 "#,
7129 )
7130 .unwrap()
7131 }
7132
7133 fn snapshot(outline_panel: &OutlinePanel, cx: &App) -> MultiBufferSnapshot {
7134 outline_panel
7135 .active_editor()
7136 .unwrap()
7137 .read(cx)
7138 .buffer()
7139 .read(cx)
7140 .snapshot(cx)
7141 }
7142
7143 fn selected_row_text(editor: &Entity<Editor>, cx: &mut App) -> String {
7144 editor.update(cx, |editor, cx| {
7145 let selections = editor.selections.all::<language::Point>(cx);
7146 assert_eq!(selections.len(), 1, "Active editor should have exactly one selection after any outline panel interactions");
7147 let selection = selections.first().unwrap();
7148 let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
7149 let line_start = language::Point::new(selection.start.row, 0);
7150 let line_end = multi_buffer_snapshot.clip_point(language::Point::new(selection.end.row, u32::MAX), language::Bias::Right);
7151 multi_buffer_snapshot.text_for_range(line_start..line_end).collect::<String>().trim().to_owned()
7152 })
7153 }
7154
7155 #[gpui::test]
7156 async fn test_outline_keyboard_expand_collapse(cx: &mut TestAppContext) {
7157 init_test(cx);
7158
7159 let fs = FakeFs::new(cx.background_executor.clone());
7160 fs.insert_tree(
7161 "/test",
7162 json!({
7163 "src": {
7164 "lib.rs": indoc!("
7165 mod outer {
7166 pub struct OuterStruct {
7167 field: String,
7168 }
7169 impl OuterStruct {
7170 pub fn new() -> Self {
7171 Self { field: String::new() }
7172 }
7173 pub fn method(&self) {
7174 println!(\"{}\", self.field);
7175 }
7176 }
7177 mod inner {
7178 pub fn inner_function() {
7179 let x = 42;
7180 println!(\"{}\", x);
7181 }
7182 pub struct InnerStruct {
7183 value: i32,
7184 }
7185 }
7186 }
7187 fn main() {
7188 let s = outer::OuterStruct::new();
7189 s.method();
7190 }
7191 "),
7192 }
7193 }),
7194 )
7195 .await;
7196
7197 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7198 project.read_with(cx, |project, _| {
7199 project.languages().add(Arc::new(
7200 rust_lang()
7201 .with_outline_query(
7202 r#"
7203 (struct_item
7204 (visibility_modifier)? @context
7205 "struct" @context
7206 name: (_) @name) @item
7207 (impl_item
7208 "impl" @context
7209 trait: (_)? @context
7210 "for"? @context
7211 type: (_) @context
7212 body: (_)) @item
7213 (function_item
7214 (visibility_modifier)? @context
7215 "fn" @context
7216 name: (_) @name
7217 parameters: (_) @context) @item
7218 (mod_item
7219 (visibility_modifier)? @context
7220 "mod" @context
7221 name: (_) @name) @item
7222 (enum_item
7223 (visibility_modifier)? @context
7224 "enum" @context
7225 name: (_) @name) @item
7226 (field_declaration
7227 (visibility_modifier)? @context
7228 name: (_) @name
7229 ":" @context
7230 type: (_) @context) @item
7231 "#,
7232 )
7233 .unwrap(),
7234 ))
7235 });
7236 let workspace = add_outline_panel(&project, cx).await;
7237 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7238 let outline_panel = outline_panel(&workspace, cx);
7239
7240 outline_panel.update_in(cx, |outline_panel, window, cx| {
7241 outline_panel.set_active(true, window, cx)
7242 });
7243
7244 workspace
7245 .update(cx, |workspace, window, cx| {
7246 workspace.open_abs_path(
7247 PathBuf::from("/test/src/lib.rs"),
7248 OpenOptions {
7249 visible: Some(OpenVisible::All),
7250 ..Default::default()
7251 },
7252 window,
7253 cx,
7254 )
7255 })
7256 .unwrap()
7257 .await
7258 .unwrap();
7259
7260 cx.executor()
7261 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7262 cx.run_until_parked();
7263
7264 // Force another update cycle to ensure outlines are fetched
7265 outline_panel.update_in(cx, |panel, window, cx| {
7266 panel.update_non_fs_items(window, cx);
7267 panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
7268 });
7269 cx.executor()
7270 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
7271 cx.run_until_parked();
7272
7273 outline_panel.update(cx, |outline_panel, cx| {
7274 assert_eq!(
7275 display_entries(
7276 &project,
7277 &snapshot(outline_panel, cx),
7278 &outline_panel.cached_entries,
7279 outline_panel.selected_entry(),
7280 cx,
7281 ),
7282 indoc!(
7283 "
7284outline: mod outer <==== selected
7285 outline: pub struct OuterStruct
7286 outline: field: String
7287 outline: impl OuterStruct
7288 outline: pub fn new()
7289 outline: pub fn method(&self)
7290 outline: mod inner
7291 outline: pub fn inner_function()
7292 outline: pub struct InnerStruct
7293 outline: value: i32
7294outline: fn main()"
7295 )
7296 );
7297 });
7298
7299 let parent_outline = outline_panel
7300 .read_with(cx, |panel, _cx| {
7301 panel
7302 .cached_entries
7303 .iter()
7304 .find_map(|entry| match &entry.entry {
7305 PanelEntry::Outline(OutlineEntry::Outline(outline))
7306 if panel
7307 .outline_children_cache
7308 .get(&outline.buffer_id)
7309 .and_then(|children_map| {
7310 let key =
7311 (outline.outline.range.clone(), outline.outline.depth);
7312 children_map.get(&key)
7313 })
7314 .copied()
7315 .unwrap_or(false) =>
7316 {
7317 Some(entry.entry.clone())
7318 }
7319 _ => None,
7320 })
7321 })
7322 .expect("Should find an outline with children");
7323
7324 outline_panel.update_in(cx, |panel, window, cx| {
7325 panel.select_entry(parent_outline.clone(), true, window, cx);
7326 panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
7327 });
7328 cx.executor()
7329 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7330 cx.run_until_parked();
7331
7332 outline_panel.update(cx, |outline_panel, cx| {
7333 assert_eq!(
7334 display_entries(
7335 &project,
7336 &snapshot(outline_panel, cx),
7337 &outline_panel.cached_entries,
7338 outline_panel.selected_entry(),
7339 cx,
7340 ),
7341 indoc!(
7342 "
7343outline: mod outer <==== selected
7344outline: fn main()"
7345 )
7346 );
7347 });
7348
7349 outline_panel.update_in(cx, |panel, window, cx| {
7350 panel.expand_selected_entry(&ExpandSelectedEntry, 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(cx, |outline_panel, cx| {
7357 assert_eq!(
7358 display_entries(
7359 &project,
7360 &snapshot(outline_panel, cx),
7361 &outline_panel.cached_entries,
7362 outline_panel.selected_entry(),
7363 cx,
7364 ),
7365 indoc!(
7366 "
7367outline: mod outer <==== selected
7368 outline: pub struct OuterStruct
7369 outline: field: String
7370 outline: impl OuterStruct
7371 outline: pub fn new()
7372 outline: pub fn method(&self)
7373 outline: mod inner
7374 outline: pub fn inner_function()
7375 outline: pub struct InnerStruct
7376 outline: value: i32
7377outline: fn main()"
7378 )
7379 );
7380 });
7381
7382 outline_panel.update_in(cx, |panel, window, cx| {
7383 panel.collapsed_entries.clear();
7384 panel.update_cached_entries(None, window, cx);
7385 });
7386 cx.executor()
7387 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7388 cx.run_until_parked();
7389
7390 outline_panel.update_in(cx, |panel, window, cx| {
7391 let outlines_with_children: Vec<_> = panel
7392 .cached_entries
7393 .iter()
7394 .filter_map(|entry| match &entry.entry {
7395 PanelEntry::Outline(OutlineEntry::Outline(outline))
7396 if panel
7397 .outline_children_cache
7398 .get(&outline.buffer_id)
7399 .and_then(|children_map| {
7400 let key = (outline.outline.range.clone(), outline.outline.depth);
7401 children_map.get(&key)
7402 })
7403 .copied()
7404 .unwrap_or(false) =>
7405 {
7406 Some(entry.entry.clone())
7407 }
7408 _ => None,
7409 })
7410 .collect();
7411
7412 for outline in outlines_with_children {
7413 panel.select_entry(outline, false, window, cx);
7414 panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
7415 }
7416 });
7417 cx.executor()
7418 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7419 cx.run_until_parked();
7420
7421 outline_panel.update(cx, |outline_panel, cx| {
7422 assert_eq!(
7423 display_entries(
7424 &project,
7425 &snapshot(outline_panel, cx),
7426 &outline_panel.cached_entries,
7427 outline_panel.selected_entry(),
7428 cx,
7429 ),
7430 indoc!(
7431 "
7432outline: mod outer
7433outline: fn main()"
7434 )
7435 );
7436 });
7437
7438 let collapsed_entries_count =
7439 outline_panel.read_with(cx, |panel, _| panel.collapsed_entries.len());
7440 assert!(
7441 collapsed_entries_count > 0,
7442 "Should have collapsed entries tracked"
7443 );
7444 }
7445
7446 #[gpui::test]
7447 async fn test_outline_click_toggle_behavior(cx: &mut TestAppContext) {
7448 init_test(cx);
7449
7450 let fs = FakeFs::new(cx.background_executor.clone());
7451 fs.insert_tree(
7452 "/test",
7453 json!({
7454 "src": {
7455 "main.rs": indoc!("
7456 struct Config {
7457 name: String,
7458 value: i32,
7459 }
7460 impl Config {
7461 fn new(name: String) -> Self {
7462 Self { name, value: 0 }
7463 }
7464 fn get_value(&self) -> i32 {
7465 self.value
7466 }
7467 }
7468 enum Status {
7469 Active,
7470 Inactive,
7471 }
7472 fn process_config(config: Config) -> Status {
7473 if config.get_value() > 0 {
7474 Status::Active
7475 } else {
7476 Status::Inactive
7477 }
7478 }
7479 fn main() {
7480 let config = Config::new(\"test\".to_string());
7481 let status = process_config(config);
7482 }
7483 "),
7484 }
7485 }),
7486 )
7487 .await;
7488
7489 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
7490 project.read_with(cx, |project, _| {
7491 project.languages().add(Arc::new(
7492 rust_lang()
7493 .with_outline_query(
7494 r#"
7495 (struct_item
7496 (visibility_modifier)? @context
7497 "struct" @context
7498 name: (_) @name) @item
7499 (impl_item
7500 "impl" @context
7501 trait: (_)? @context
7502 "for"? @context
7503 type: (_) @context
7504 body: (_)) @item
7505 (function_item
7506 (visibility_modifier)? @context
7507 "fn" @context
7508 name: (_) @name
7509 parameters: (_) @context) @item
7510 (mod_item
7511 (visibility_modifier)? @context
7512 "mod" @context
7513 name: (_) @name) @item
7514 (enum_item
7515 (visibility_modifier)? @context
7516 "enum" @context
7517 name: (_) @name) @item
7518 (field_declaration
7519 (visibility_modifier)? @context
7520 name: (_) @name
7521 ":" @context
7522 type: (_) @context) @item
7523 "#,
7524 )
7525 .unwrap(),
7526 ))
7527 });
7528
7529 let workspace = add_outline_panel(&project, cx).await;
7530 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7531 let outline_panel = outline_panel(&workspace, cx);
7532
7533 outline_panel.update_in(cx, |outline_panel, window, cx| {
7534 outline_panel.set_active(true, window, cx)
7535 });
7536
7537 let _editor = workspace
7538 .update(cx, |workspace, window, cx| {
7539 workspace.open_abs_path(
7540 PathBuf::from("/test/src/main.rs"),
7541 OpenOptions {
7542 visible: Some(OpenVisible::All),
7543 ..Default::default()
7544 },
7545 window,
7546 cx,
7547 )
7548 })
7549 .unwrap()
7550 .await
7551 .unwrap();
7552
7553 cx.executor()
7554 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7555 cx.run_until_parked();
7556
7557 outline_panel.update(cx, |outline_panel, _cx| {
7558 outline_panel.selected_entry = SelectedEntry::None;
7559 });
7560
7561 // Check initial state - all entries should be expanded by default
7562 outline_panel.update(cx, |outline_panel, cx| {
7563 assert_eq!(
7564 display_entries(
7565 &project,
7566 &snapshot(outline_panel, cx),
7567 &outline_panel.cached_entries,
7568 outline_panel.selected_entry(),
7569 cx,
7570 ),
7571 indoc!(
7572 "
7573outline: struct Config
7574 outline: name: String
7575 outline: value: i32
7576outline: impl Config
7577 outline: fn new(name: String)
7578 outline: fn get_value(&self)
7579outline: enum Status
7580outline: fn process_config(config: Config)
7581outline: fn main()"
7582 )
7583 );
7584 });
7585
7586 outline_panel.update(cx, |outline_panel, _cx| {
7587 outline_panel.selected_entry = SelectedEntry::None;
7588 });
7589
7590 cx.update(|window, cx| {
7591 outline_panel.update(cx, |outline_panel, cx| {
7592 outline_panel.select_first(&SelectFirst, 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
7612 outline: name: String
7613 outline: value: i32
7614outline: impl Config
7615 outline: fn new(name: String)
7616 outline: fn get_value(&self)
7617outline: enum Status
7618outline: fn process_config(config: Config)
7619outline: fn main()"
7620 )
7621 );
7622 });
7623
7624 cx.update(|window, cx| {
7625 outline_panel.update(cx, |outline_panel, cx| {
7626 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
7627 });
7628 });
7629
7630 cx.executor()
7631 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7632 cx.run_until_parked();
7633
7634 outline_panel.update(cx, |outline_panel, cx| {
7635 assert_eq!(
7636 display_entries(
7637 &project,
7638 &snapshot(outline_panel, cx),
7639 &outline_panel.cached_entries,
7640 outline_panel.selected_entry(),
7641 cx,
7642 ),
7643 indoc!(
7644 "
7645outline: struct Config <==== selected
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 cx.update(|window, cx| {
7657 outline_panel.update(cx, |outline_panel, cx| {
7658 outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx);
7659 });
7660 });
7661
7662 cx.executor()
7663 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
7664 cx.run_until_parked();
7665
7666 outline_panel.update(cx, |outline_panel, cx| {
7667 assert_eq!(
7668 display_entries(
7669 &project,
7670 &snapshot(outline_panel, cx),
7671 &outline_panel.cached_entries,
7672 outline_panel.selected_entry(),
7673 cx,
7674 ),
7675 indoc!(
7676 "
7677outline: struct Config <==== selected
7678 outline: name: String
7679 outline: value: i32
7680outline: impl Config
7681 outline: fn new(name: String)
7682 outline: fn get_value(&self)
7683outline: enum Status
7684outline: fn process_config(config: Config)
7685outline: fn main()"
7686 )
7687 );
7688 });
7689 }
7690}