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