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