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