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