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