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