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