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