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