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