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