1mod outline_panel_settings;
2
3use std::{
4 cmp,
5 hash::Hash,
6 ops::Range,
7 path::{Path, PathBuf, MAIN_SEPARATOR_STR},
8 sync::{atomic::AtomicBool, Arc, OnceLock},
9 time::Duration,
10 u32,
11};
12
13use anyhow::Context;
14use collections::{hash_map, BTreeSet, HashMap, HashSet};
15use db::kvp::KEY_VALUE_STORE;
16use editor::{
17 display_map::ToDisplayPoint,
18 items::{entry_git_aware_label_color, entry_label_color},
19 scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor, ScrollbarAutoHide},
20 AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, EditorMode, EditorSettings, ExcerptId,
21 ExcerptRange, MultiBufferSnapshot, RangeToAnchorExt, ShowScrollbar,
22};
23use file_icons::FileIcons;
24use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
25use gpui::{
26 actions, anchored, deferred, div, point, px, size, uniform_list, Action, AnyElement,
27 AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div,
28 ElementId, EventEmitter, FocusHandle, FocusableView, HighlightStyle, InteractiveElement,
29 IntoElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton,
30 MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollStrategy, SharedString, Stateful,
31 StatefulInteractiveElement as _, Styled, Subscription, Task, UniformListScrollHandle, View,
32 ViewContext, VisualContext, WeakView, WindowContext,
33};
34use itertools::Itertools;
35use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
36use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrev};
37
38use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides};
39use project::{File, Fs, Project, ProjectItem};
40use search::{BufferSearchBar, ProjectSearchView};
41use serde::{Deserialize, Serialize};
42use settings::{Settings, SettingsStore};
43use smol::channel;
44use theme::{SyntaxTheme, ThemeSettings};
45use ui::{DynamicSpacing, IndentGuideColors, IndentGuideLayout};
46use util::{debug_panic, RangeExt, ResultExt, TryFutureExt};
47use workspace::{
48 dock::{DockPosition, Panel, PanelEvent},
49 item::ItemHandle,
50 searchable::{SearchEvent, SearchableItem},
51 ui::{
52 h_flex, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, FluentBuilder,
53 HighlightedLabel, Icon, IconButton, IconButtonShape, IconName, IconSize, Label,
54 LabelCommon, ListItem, Scrollbar, ScrollbarState, StyledExt, StyledTypography, Toggleable,
55 Tooltip,
56 },
57 OpenInTerminal, WeakItemHandle, Workspace,
58};
59use worktree::{Entry, ProjectEntryId, WorktreeId};
60
61actions!(
62 outline_panel,
63 [
64 CollapseAllEntries,
65 CollapseSelectedEntry,
66 CopyPath,
67 CopyRelativePath,
68 ExpandAllEntries,
69 ExpandSelectedEntry,
70 FoldDirectory,
71 Open,
72 RevealInFileManager,
73 SelectParent,
74 ToggleActiveEditorPin,
75 ToggleFocus,
76 UnfoldDirectory,
77 ]
78);
79
80const OUTLINE_PANEL_KEY: &str = "OutlinePanel";
81const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
82
83type Outline = OutlineItem<language::Anchor>;
84type HighlightStyleData = Arc<OnceLock<Vec<(Range<usize>, HighlightStyle)>>>;
85
86pub struct OutlinePanel {
87 fs: Arc<dyn Fs>,
88 width: Option<Pixels>,
89 project: Model<Project>,
90 workspace: WeakView<Workspace>,
91 active: bool,
92 pinned: bool,
93 scroll_handle: UniformListScrollHandle,
94 context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
95 focus_handle: FocusHandle,
96 pending_serialization: Task<Option<()>>,
97 fs_entries_depth: HashMap<(WorktreeId, ProjectEntryId), usize>,
98 fs_entries: Vec<FsEntry>,
99 fs_children_count: HashMap<WorktreeId, HashMap<Arc<Path>, FsChildren>>,
100 collapsed_entries: HashSet<CollapsedEntry>,
101 unfolded_dirs: HashMap<WorktreeId, BTreeSet<ProjectEntryId>>,
102 selected_entry: SelectedEntry,
103 active_item: Option<ActiveItem>,
104 _subscriptions: Vec<Subscription>,
105 updating_fs_entries: bool,
106 new_entries_for_fs_update: HashSet<ExcerptId>,
107 fs_entries_update_task: Task<()>,
108 cached_entries_update_task: Task<()>,
109 reveal_selection_task: Task<anyhow::Result<()>>,
110 outline_fetch_tasks: HashMap<(BufferId, ExcerptId), Task<()>>,
111 excerpts: HashMap<BufferId, HashMap<ExcerptId, Excerpt>>,
112 cached_entries: Vec<CachedEntry>,
113 filter_editor: View<Editor>,
114 mode: ItemsDisplayMode,
115 show_scrollbar: bool,
116 vertical_scrollbar_state: ScrollbarState,
117 horizontal_scrollbar_state: ScrollbarState,
118 hide_scrollbar_task: Option<Task<()>>,
119 max_width_item_index: Option<usize>,
120 preserve_selection_on_buffer_fold_toggles: HashSet<BufferId>,
121}
122
123#[derive(Debug)]
124enum ItemsDisplayMode {
125 Search(SearchState),
126 Outline,
127}
128
129#[derive(Debug)]
130struct SearchState {
131 kind: SearchKind,
132 query: String,
133 matches: Vec<(Range<editor::Anchor>, Arc<OnceLock<SearchData>>)>,
134 highlight_search_match_tx: channel::Sender<HighlightArguments>,
135 _search_match_highlighter: Task<()>,
136 _search_match_notify: Task<()>,
137}
138
139struct HighlightArguments {
140 multi_buffer_snapshot: MultiBufferSnapshot,
141 match_range: Range<editor::Anchor>,
142 search_data: Arc<OnceLock<SearchData>>,
143}
144
145impl SearchState {
146 fn new(
147 kind: SearchKind,
148 query: String,
149 previous_matches: HashMap<Range<editor::Anchor>, Arc<OnceLock<SearchData>>>,
150 new_matches: Vec<Range<editor::Anchor>>,
151 theme: Arc<SyntaxTheme>,
152 cx: &mut ViewContext<'_, OutlinePanel>,
153 ) -> Self {
154 let (highlight_search_match_tx, highlight_search_match_rx) = channel::unbounded();
155 let (notify_tx, notify_rx) = channel::unbounded::<()>();
156 Self {
157 kind,
158 query,
159 matches: new_matches
160 .into_iter()
161 .map(|range| {
162 let search_data = previous_matches
163 .get(&range)
164 .map(Arc::clone)
165 .unwrap_or_default();
166 (range, search_data)
167 })
168 .collect(),
169 highlight_search_match_tx,
170 _search_match_highlighter: cx.background_executor().spawn(async move {
171 while let Ok(highlight_arguments) = highlight_search_match_rx.recv().await {
172 let needs_init = highlight_arguments.search_data.get().is_none();
173 let search_data = highlight_arguments.search_data.get_or_init(|| {
174 SearchData::new(
175 &highlight_arguments.match_range,
176 &highlight_arguments.multi_buffer_snapshot,
177 )
178 });
179 if needs_init {
180 notify_tx.try_send(()).ok();
181 }
182
183 let highlight_data = &search_data.highlights_data;
184 if highlight_data.get().is_some() {
185 continue;
186 }
187 let mut left_whitespaces_count = 0;
188 let mut non_whitespace_symbol_occurred = false;
189 let context_offset_range = search_data
190 .context_range
191 .to_offset(&highlight_arguments.multi_buffer_snapshot);
192 let mut offset = context_offset_range.start;
193 let mut context_text = String::new();
194 let mut highlight_ranges = Vec::new();
195 for mut chunk in highlight_arguments
196 .multi_buffer_snapshot
197 .chunks(context_offset_range.start..context_offset_range.end, true)
198 {
199 if !non_whitespace_symbol_occurred {
200 for c in chunk.text.chars() {
201 if c.is_whitespace() {
202 left_whitespaces_count += c.len_utf8();
203 } else {
204 non_whitespace_symbol_occurred = true;
205 break;
206 }
207 }
208 }
209
210 if chunk.text.len() > context_offset_range.end - offset {
211 chunk.text = &chunk.text[0..(context_offset_range.end - offset)];
212 offset = context_offset_range.end;
213 } else {
214 offset += chunk.text.len();
215 }
216 let style = chunk
217 .syntax_highlight_id
218 .and_then(|highlight| highlight.style(&theme));
219 if let Some(style) = style {
220 let start = context_text.len();
221 let end = start + chunk.text.len();
222 highlight_ranges.push((start..end, style));
223 }
224 context_text.push_str(chunk.text);
225 if offset >= context_offset_range.end {
226 break;
227 }
228 }
229
230 highlight_ranges.iter_mut().for_each(|(range, _)| {
231 range.start = range.start.saturating_sub(left_whitespaces_count);
232 range.end = range.end.saturating_sub(left_whitespaces_count);
233 });
234 if highlight_data.set(highlight_ranges).ok().is_some() {
235 notify_tx.try_send(()).ok();
236 }
237
238 let trimmed_text = context_text[left_whitespaces_count..].to_owned();
239 debug_assert_eq!(
240 trimmed_text, search_data.context_text,
241 "Highlighted text that does not match the buffer text"
242 );
243 }
244 }),
245 _search_match_notify: cx.spawn(|outline_panel, mut cx| async move {
246 loop {
247 match notify_rx.recv().await {
248 Ok(()) => {}
249 Err(_) => break,
250 };
251 while let Ok(()) = notify_rx.try_recv() {
252 //
253 }
254 let update_result = outline_panel.update(&mut cx, |_, cx| {
255 cx.notify();
256 });
257 if update_result.is_err() {
258 break;
259 }
260 }
261 }),
262 }
263 }
264}
265
266#[derive(Debug)]
267enum SelectedEntry {
268 Invalidated(Option<PanelEntry>),
269 Valid(PanelEntry, usize),
270 None,
271}
272
273impl SelectedEntry {
274 fn invalidate(&mut self) {
275 match std::mem::replace(self, SelectedEntry::None) {
276 Self::Valid(entry, _) => *self = Self::Invalidated(Some(entry)),
277 Self::None => *self = Self::Invalidated(None),
278 other => *self = other,
279 }
280 }
281
282 fn is_invalidated(&self) -> bool {
283 matches!(self, Self::Invalidated(_))
284 }
285}
286
287#[derive(Debug, Clone, Copy, Default)]
288struct FsChildren {
289 files: usize,
290 dirs: usize,
291}
292
293impl FsChildren {
294 fn may_be_fold_part(&self) -> bool {
295 self.dirs == 0 || (self.dirs == 1 && self.files == 0)
296 }
297}
298
299#[derive(Clone, Debug)]
300struct CachedEntry {
301 depth: usize,
302 string_match: Option<StringMatch>,
303 entry: PanelEntry,
304}
305
306#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
307enum CollapsedEntry {
308 Dir(WorktreeId, ProjectEntryId),
309 File(WorktreeId, BufferId),
310 ExternalFile(BufferId),
311 Excerpt(BufferId, ExcerptId),
312}
313
314#[derive(Debug)]
315struct Excerpt {
316 range: ExcerptRange<language::Anchor>,
317 outlines: ExcerptOutlines,
318}
319
320impl Excerpt {
321 fn invalidate_outlines(&mut self) {
322 if let ExcerptOutlines::Outlines(valid_outlines) = &mut self.outlines {
323 self.outlines = ExcerptOutlines::Invalidated(std::mem::take(valid_outlines));
324 }
325 }
326
327 fn iter_outlines(&self) -> impl Iterator<Item = &Outline> {
328 match &self.outlines {
329 ExcerptOutlines::Outlines(outlines) => outlines.iter(),
330 ExcerptOutlines::Invalidated(outlines) => outlines.iter(),
331 ExcerptOutlines::NotFetched => [].iter(),
332 }
333 }
334
335 fn should_fetch_outlines(&self) -> bool {
336 match &self.outlines {
337 ExcerptOutlines::Outlines(_) => false,
338 ExcerptOutlines::Invalidated(_) => true,
339 ExcerptOutlines::NotFetched => true,
340 }
341 }
342}
343
344#[derive(Debug)]
345enum ExcerptOutlines {
346 Outlines(Vec<Outline>),
347 Invalidated(Vec<Outline>),
348 NotFetched,
349}
350
351#[derive(Clone, Debug)]
352enum PanelEntry {
353 Fs(FsEntry),
354 FoldedDirs(WorktreeId, Vec<Entry>),
355 Outline(OutlineEntry),
356 Search(SearchEntry),
357}
358
359#[derive(Clone, Debug)]
360struct SearchEntry {
361 match_range: Range<editor::Anchor>,
362 kind: SearchKind,
363 render_data: Arc<OnceLock<SearchData>>,
364}
365
366#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
367enum SearchKind {
368 Project,
369 Buffer,
370}
371
372#[derive(Clone, Debug)]
373struct SearchData {
374 context_range: Range<editor::Anchor>,
375 context_text: String,
376 truncated_left: bool,
377 truncated_right: bool,
378 search_match_indices: Vec<Range<usize>>,
379 highlights_data: HighlightStyleData,
380}
381
382impl PartialEq for PanelEntry {
383 fn eq(&self, other: &Self) -> bool {
384 match (self, other) {
385 (Self::Fs(a), Self::Fs(b)) => a == b,
386 (Self::FoldedDirs(a1, a2), Self::FoldedDirs(b1, b2)) => a1 == b1 && a2 == b2,
387 (Self::Outline(a), Self::Outline(b)) => a == b,
388 (
389 Self::Search(SearchEntry {
390 match_range: match_range_a,
391 kind: kind_a,
392 ..
393 }),
394 Self::Search(SearchEntry {
395 match_range: match_range_b,
396 kind: kind_b,
397 ..
398 }),
399 ) => match_range_a == match_range_b && kind_a == kind_b,
400 _ => false,
401 }
402 }
403}
404
405impl Eq for PanelEntry {}
406
407const SEARCH_MATCH_CONTEXT_SIZE: u32 = 40;
408const TRUNCATED_CONTEXT_MARK: &str = "…";
409
410impl SearchData {
411 fn new(
412 match_range: &Range<editor::Anchor>,
413 multi_buffer_snapshot: &MultiBufferSnapshot,
414 ) -> Self {
415 let match_point_range = match_range.to_point(multi_buffer_snapshot);
416 let context_left_border = multi_buffer_snapshot.clip_point(
417 language::Point::new(
418 match_point_range.start.row,
419 match_point_range
420 .start
421 .column
422 .saturating_sub(SEARCH_MATCH_CONTEXT_SIZE),
423 ),
424 Bias::Left,
425 );
426 let context_right_border = multi_buffer_snapshot.clip_point(
427 language::Point::new(
428 match_point_range.end.row,
429 match_point_range.end.column + SEARCH_MATCH_CONTEXT_SIZE,
430 ),
431 Bias::Right,
432 );
433
434 let context_anchor_range =
435 (context_left_border..context_right_border).to_anchors(multi_buffer_snapshot);
436 let context_offset_range = context_anchor_range.to_offset(multi_buffer_snapshot);
437 let match_offset_range = match_range.to_offset(multi_buffer_snapshot);
438
439 let mut search_match_indices = vec![
440 multi_buffer_snapshot.clip_offset(
441 match_offset_range.start - context_offset_range.start,
442 Bias::Left,
443 )
444 ..multi_buffer_snapshot.clip_offset(
445 match_offset_range.end - context_offset_range.start,
446 Bias::Right,
447 ),
448 ];
449
450 let entire_context_text = multi_buffer_snapshot
451 .text_for_range(context_offset_range.clone())
452 .collect::<String>();
453 let left_whitespaces_offset = entire_context_text
454 .chars()
455 .take_while(|c| c.is_whitespace())
456 .map(|c| c.len_utf8())
457 .sum::<usize>();
458
459 let mut extended_context_left_border = context_left_border;
460 extended_context_left_border.column = extended_context_left_border.column.saturating_sub(1);
461 let extended_context_left_border =
462 multi_buffer_snapshot.clip_point(extended_context_left_border, Bias::Left);
463 let mut extended_context_right_border = context_right_border;
464 extended_context_right_border.column += 1;
465 let extended_context_right_border =
466 multi_buffer_snapshot.clip_point(extended_context_right_border, Bias::Right);
467
468 let truncated_left = left_whitespaces_offset == 0
469 && extended_context_left_border < context_left_border
470 && multi_buffer_snapshot
471 .chars_at(extended_context_left_border)
472 .last()
473 .map_or(false, |c| !c.is_whitespace());
474 let truncated_right = entire_context_text
475 .chars()
476 .last()
477 .map_or(true, |c| !c.is_whitespace())
478 && extended_context_right_border > context_right_border
479 && multi_buffer_snapshot
480 .chars_at(extended_context_right_border)
481 .next()
482 .map_or(false, |c| !c.is_whitespace());
483 search_match_indices.iter_mut().for_each(|range| {
484 range.start = multi_buffer_snapshot.clip_offset(
485 range.start.saturating_sub(left_whitespaces_offset),
486 Bias::Left,
487 );
488 range.end = multi_buffer_snapshot.clip_offset(
489 range.end.saturating_sub(left_whitespaces_offset),
490 Bias::Right,
491 );
492 });
493
494 let trimmed_row_offset_range =
495 context_offset_range.start + left_whitespaces_offset..context_offset_range.end;
496 let trimmed_text = entire_context_text[left_whitespaces_offset..].to_owned();
497 Self {
498 highlights_data: Arc::default(),
499 search_match_indices,
500 context_range: trimmed_row_offset_range.to_anchors(multi_buffer_snapshot),
501 context_text: trimmed_text,
502 truncated_left,
503 truncated_right,
504 }
505 }
506}
507
508#[derive(Clone, Debug, PartialEq, Eq)]
509enum OutlineEntry {
510 Excerpt(BufferId, ExcerptId, ExcerptRange<language::Anchor>),
511 Outline(BufferId, ExcerptId, Outline),
512}
513
514#[derive(Clone, Debug, Eq)]
515enum FsEntry {
516 ExternalFile(BufferId, Vec<ExcerptId>),
517 Directory(WorktreeId, Entry),
518 File(WorktreeId, Entry, BufferId, Vec<ExcerptId>),
519}
520
521impl PartialEq for FsEntry {
522 fn eq(&self, other: &Self) -> bool {
523 match (self, other) {
524 (Self::ExternalFile(id_a, _), Self::ExternalFile(id_b, _)) => id_a == id_b,
525 (Self::Directory(id_a, entry_a), Self::Directory(id_b, entry_b)) => {
526 id_a == id_b && entry_a.id == entry_b.id
527 }
528 (
529 Self::File(worktree_a, entry_a, id_a, ..),
530 Self::File(worktree_b, entry_b, id_b, ..),
531 ) => worktree_a == worktree_b && entry_a.id == entry_b.id && id_a == id_b,
532 _ => false,
533 }
534 }
535}
536
537impl Hash for FsEntry {
538 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
539 match self {
540 Self::ExternalFile(buffer_id, _) => {
541 buffer_id.hash(state);
542 }
543 Self::Directory(worktree_id, entry) => {
544 worktree_id.hash(state);
545 entry.id.hash(state);
546 }
547 Self::File(worktree_id, entry, buffer_id, _) => {
548 worktree_id.hash(state);
549 entry.id.hash(state);
550 buffer_id.hash(state);
551 }
552 }
553 }
554}
555
556struct ActiveItem {
557 item_handle: Box<dyn WeakItemHandle>,
558 active_editor: WeakView<Editor>,
559 _buffer_search_subscription: Subscription,
560 _editor_subscrpiption: Subscription,
561}
562
563#[derive(Debug)]
564pub enum Event {
565 Focus,
566}
567
568#[derive(Serialize, Deserialize)]
569struct SerializedOutlinePanel {
570 width: Option<Pixels>,
571 active: Option<bool>,
572}
573
574pub fn init_settings(cx: &mut AppContext) {
575 OutlinePanelSettings::register(cx);
576}
577
578pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
579 init_settings(cx);
580 file_icons::init(assets, cx);
581
582 cx.observe_new_views(|workspace: &mut Workspace, _| {
583 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
584 workspace.toggle_panel_focus::<OutlinePanel>(cx);
585 });
586 })
587 .detach();
588}
589
590impl OutlinePanel {
591 pub async fn load(
592 workspace: WeakView<Workspace>,
593 mut cx: AsyncWindowContext,
594 ) -> anyhow::Result<View<Self>> {
595 let serialized_panel = cx
596 .background_executor()
597 .spawn(async move { KEY_VALUE_STORE.read_kvp(OUTLINE_PANEL_KEY) })
598 .await
599 .context("loading outline panel")
600 .log_err()
601 .flatten()
602 .map(|panel| serde_json::from_str::<SerializedOutlinePanel>(&panel))
603 .transpose()
604 .log_err()
605 .flatten();
606
607 workspace.update(&mut cx, |workspace, cx| {
608 let panel = Self::new(workspace, cx);
609 if let Some(serialized_panel) = serialized_panel {
610 panel.update(cx, |panel, cx| {
611 panel.width = serialized_panel.width.map(|px| px.round());
612 panel.active = serialized_panel.active.unwrap_or(false);
613 cx.notify();
614 });
615 }
616 panel
617 })
618 }
619
620 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
621 let project = workspace.project().clone();
622 let workspace_handle = cx.view().downgrade();
623 let outline_panel = cx.new_view(|cx| {
624 let filter_editor = cx.new_view(|cx| {
625 let mut editor = Editor::single_line(cx);
626 editor.set_placeholder_text("Filter...", cx);
627 editor
628 });
629 let filter_update_subscription =
630 cx.subscribe(&filter_editor, |outline_panel: &mut Self, _, event, cx| {
631 if let editor::EditorEvent::BufferEdited = event {
632 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), cx);
633 }
634 });
635
636 let focus_handle = cx.focus_handle();
637 let focus_subscription = cx.on_focus(&focus_handle, Self::focus_in);
638 let focus_out_subscription = cx.on_focus_out(&focus_handle, |outline_panel, _, cx| {
639 outline_panel.hide_scrollbar(cx);
640 });
641 let workspace_subscription = cx.subscribe(
642 &workspace
643 .weak_handle()
644 .upgrade()
645 .expect("have a &mut Workspace"),
646 move |outline_panel, workspace, event, cx| {
647 if let workspace::Event::ActiveItemChanged = event {
648 if let Some((new_active_item, new_active_editor)) =
649 workspace_active_editor(workspace.read(cx), cx)
650 {
651 if outline_panel.should_replace_active_item(new_active_item.as_ref()) {
652 outline_panel.replace_active_editor(
653 new_active_item,
654 new_active_editor,
655 cx,
656 );
657 }
658 } else {
659 outline_panel.clear_previous(cx);
660 cx.notify();
661 }
662 }
663 },
664 );
665
666 let icons_subscription = cx.observe_global::<FileIcons>(|_, cx| {
667 cx.notify();
668 });
669
670 let mut outline_panel_settings = *OutlinePanelSettings::get_global(cx);
671 let mut current_theme = ThemeSettings::get_global(cx).clone();
672 let settings_subscription =
673 cx.observe_global::<SettingsStore>(move |outline_panel, cx| {
674 let new_settings = OutlinePanelSettings::get_global(cx);
675 let new_theme = ThemeSettings::get_global(cx);
676 if ¤t_theme != new_theme {
677 outline_panel_settings = *new_settings;
678 current_theme = new_theme.clone();
679 for excerpts in outline_panel.excerpts.values_mut() {
680 for excerpt in excerpts.values_mut() {
681 excerpt.invalidate_outlines();
682 }
683 }
684 outline_panel.update_non_fs_items(cx);
685 } else if &outline_panel_settings != new_settings {
686 outline_panel_settings = *new_settings;
687 cx.notify();
688 }
689 });
690
691 let scroll_handle = UniformListScrollHandle::new();
692
693 let mut outline_panel = Self {
694 mode: ItemsDisplayMode::Outline,
695 active: false,
696 pinned: false,
697 workspace: workspace_handle,
698 project,
699 fs: workspace.app_state().fs.clone(),
700 show_scrollbar: !Self::should_autohide_scrollbar(cx),
701 hide_scrollbar_task: None,
702 vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
703 .parent_view(cx.view()),
704 horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
705 .parent_view(cx.view()),
706 max_width_item_index: None,
707 scroll_handle,
708 focus_handle,
709 filter_editor,
710 fs_entries: Vec::new(),
711 fs_entries_depth: HashMap::default(),
712 fs_children_count: HashMap::default(),
713 collapsed_entries: HashSet::default(),
714 unfolded_dirs: HashMap::default(),
715 selected_entry: SelectedEntry::None,
716 context_menu: None,
717 width: None,
718 active_item: None,
719 pending_serialization: Task::ready(None),
720 updating_fs_entries: false,
721 new_entries_for_fs_update: HashSet::default(),
722 preserve_selection_on_buffer_fold_toggles: HashSet::default(),
723 fs_entries_update_task: Task::ready(()),
724 cached_entries_update_task: Task::ready(()),
725 reveal_selection_task: Task::ready(Ok(())),
726 outline_fetch_tasks: HashMap::default(),
727 excerpts: HashMap::default(),
728 cached_entries: Vec::new(),
729 _subscriptions: vec![
730 settings_subscription,
731 icons_subscription,
732 focus_subscription,
733 focus_out_subscription,
734 workspace_subscription,
735 filter_update_subscription,
736 ],
737 };
738 if let Some((item, editor)) = workspace_active_editor(workspace, cx) {
739 outline_panel.replace_active_editor(item, editor, cx);
740 }
741 outline_panel
742 });
743
744 outline_panel
745 }
746
747 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
748 let width = self.width;
749 let active = Some(self.active);
750 self.pending_serialization = cx.background_executor().spawn(
751 async move {
752 KEY_VALUE_STORE
753 .write_kvp(
754 OUTLINE_PANEL_KEY.into(),
755 serde_json::to_string(&SerializedOutlinePanel { width, active })?,
756 )
757 .await?;
758 anyhow::Ok(())
759 }
760 .log_err(),
761 );
762 }
763
764 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
765 let mut dispatch_context = KeyContext::new_with_defaults();
766 dispatch_context.add("OutlinePanel");
767 dispatch_context.add("menu");
768 let identifier = if self.filter_editor.focus_handle(cx).is_focused(cx) {
769 "editing"
770 } else {
771 "not_editing"
772 };
773 dispatch_context.add(identifier);
774 dispatch_context
775 }
776
777 fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
778 if let Some(PanelEntry::FoldedDirs(worktree_id, entries)) = self.selected_entry().cloned() {
779 self.unfolded_dirs
780 .entry(worktree_id)
781 .or_default()
782 .extend(entries.iter().map(|entry| entry.id));
783 self.update_cached_entries(None, cx);
784 }
785 }
786
787 fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext<Self>) {
788 let (worktree_id, entry) = match self.selected_entry().cloned() {
789 Some(PanelEntry::Fs(FsEntry::Directory(worktree_id, entry))) => {
790 (worktree_id, Some(entry))
791 }
792 Some(PanelEntry::FoldedDirs(worktree_id, entries)) => {
793 (worktree_id, entries.last().cloned())
794 }
795 _ => return,
796 };
797 let Some(entry) = entry else {
798 return;
799 };
800 let unfolded_dirs = self.unfolded_dirs.get_mut(&worktree_id);
801 let worktree = self
802 .project
803 .read(cx)
804 .worktree_for_id(worktree_id, cx)
805 .map(|w| w.read(cx).snapshot());
806 let Some((_, unfolded_dirs)) = worktree.zip(unfolded_dirs) else {
807 return;
808 };
809
810 unfolded_dirs.remove(&entry.id);
811 self.update_cached_entries(None, cx);
812 }
813
814 fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
815 if self.filter_editor.focus_handle(cx).is_focused(cx) {
816 cx.propagate()
817 } else if let Some(selected_entry) = self.selected_entry().cloned() {
818 self.toggle_expanded(&selected_entry, cx);
819 self.scroll_editor_to_entry(&selected_entry, true, false, cx);
820 }
821 }
822
823 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
824 if self.filter_editor.focus_handle(cx).is_focused(cx) {
825 self.focus_handle.focus(cx);
826 } else {
827 self.filter_editor.focus_handle(cx).focus(cx);
828 }
829
830 if self.context_menu.is_some() {
831 self.context_menu.take();
832 cx.notify();
833 }
834 }
835
836 fn open_excerpts(&mut self, action: &editor::OpenExcerpts, cx: &mut ViewContext<Self>) {
837 if self.filter_editor.focus_handle(cx).is_focused(cx) {
838 cx.propagate()
839 } else if let Some((active_editor, selected_entry)) =
840 self.active_editor().zip(self.selected_entry().cloned())
841 {
842 self.scroll_editor_to_entry(&selected_entry, true, true, cx);
843 active_editor.update(cx, |editor, cx| editor.open_excerpts(action, cx));
844 }
845 }
846
847 fn open_excerpts_split(
848 &mut self,
849 action: &editor::OpenExcerptsSplit,
850 cx: &mut ViewContext<Self>,
851 ) {
852 if self.filter_editor.focus_handle(cx).is_focused(cx) {
853 cx.propagate()
854 } else if let Some((active_editor, selected_entry)) =
855 self.active_editor().zip(self.selected_entry().cloned())
856 {
857 self.scroll_editor_to_entry(&selected_entry, true, true, cx);
858 active_editor.update(cx, |editor, cx| editor.open_excerpts_in_split(action, cx));
859 }
860 }
861
862 fn scroll_editor_to_entry(
863 &mut self,
864 entry: &PanelEntry,
865 prefer_selection_change: bool,
866 change_focus: bool,
867 cx: &mut ViewContext<OutlinePanel>,
868 ) {
869 let Some(active_editor) = self.active_editor() else {
870 return;
871 };
872 let active_multi_buffer = active_editor.read(cx).buffer().clone();
873 let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx);
874 let mut change_selection = prefer_selection_change;
875 let mut scroll_to_buffer = None;
876 let scroll_target = match entry {
877 PanelEntry::FoldedDirs(..) | PanelEntry::Fs(FsEntry::Directory(..)) => None,
878 PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => {
879 change_selection = false;
880 scroll_to_buffer = Some(*buffer_id);
881 multi_buffer_snapshot.excerpts().find_map(
882 |(excerpt_id, buffer_snapshot, excerpt_range)| {
883 if &buffer_snapshot.remote_id() == buffer_id {
884 multi_buffer_snapshot
885 .anchor_in_excerpt(excerpt_id, excerpt_range.context.start)
886 } else {
887 None
888 }
889 },
890 )
891 }
892 PanelEntry::Fs(FsEntry::File(_, file_entry, buffer_id, _)) => {
893 change_selection = false;
894 scroll_to_buffer = Some(*buffer_id);
895 self.project
896 .update(cx, |project, cx| {
897 project
898 .path_for_entry(file_entry.id, cx)
899 .and_then(|path| project.get_open_buffer(&path, cx))
900 })
901 .map(|buffer| {
902 active_multi_buffer
903 .read(cx)
904 .excerpts_for_buffer(&buffer, cx)
905 })
906 .and_then(|excerpts| {
907 let (excerpt_id, excerpt_range) = excerpts.first()?;
908 multi_buffer_snapshot
909 .anchor_in_excerpt(*excerpt_id, excerpt_range.context.start)
910 })
911 }
912 PanelEntry::Outline(OutlineEntry::Outline(_, excerpt_id, outline)) => {
913 multi_buffer_snapshot
914 .anchor_in_excerpt(*excerpt_id, outline.range.start)
915 .or_else(|| {
916 multi_buffer_snapshot.anchor_in_excerpt(*excerpt_id, outline.range.end)
917 })
918 }
919 PanelEntry::Outline(OutlineEntry::Excerpt(_, excerpt_id, excerpt_range)) => {
920 change_selection = false;
921 multi_buffer_snapshot.anchor_in_excerpt(*excerpt_id, excerpt_range.context.start)
922 }
923 PanelEntry::Search(SearchEntry { match_range, .. }) => Some(match_range.start),
924 };
925
926 if let Some(anchor) = scroll_target {
927 let activate = self
928 .workspace
929 .update(cx, |workspace, cx| match self.active_item() {
930 Some(active_item) => {
931 workspace.activate_item(active_item.as_ref(), true, change_focus, cx)
932 }
933 None => workspace.activate_item(&active_editor, true, change_focus, cx),
934 });
935
936 if activate.is_ok() {
937 self.select_entry(entry.clone(), true, cx);
938 if change_selection {
939 active_editor.update(cx, |editor, cx| {
940 editor.change_selections(
941 Some(Autoscroll::Strategy(AutoscrollStrategy::Center)),
942 cx,
943 |s| s.select_ranges(Some(anchor..anchor)),
944 );
945 });
946 } else {
947 let mut offset = Point::default();
948 let show_excerpt_controls = active_editor
949 .read(cx)
950 .display_map
951 .read(cx)
952 .show_excerpt_controls();
953 let expand_excerpt_control_height = 1.0;
954 if let Some(buffer_id) = scroll_to_buffer {
955 let current_folded = active_editor.read(cx).buffer_folded(buffer_id, cx);
956 if current_folded {
957 if show_excerpt_controls {
958 let previous_buffer_id = self
959 .fs_entries
960 .iter()
961 .rev()
962 .filter_map(|entry| match entry {
963 FsEntry::File(_, _, buffer_id, _)
964 | FsEntry::ExternalFile(buffer_id, _) => Some(*buffer_id),
965 FsEntry::Directory(..) => None,
966 })
967 .skip_while(|id| *id != buffer_id)
968 .skip(1)
969 .next();
970 if let Some(previous_buffer_id) = previous_buffer_id {
971 if !active_editor.read(cx).buffer_folded(previous_buffer_id, cx)
972 {
973 offset.y += expand_excerpt_control_height;
974 }
975 }
976 }
977 } else {
978 offset.y = -(active_editor.read(cx).file_header_size() as f32);
979 if show_excerpt_controls {
980 offset.y -= expand_excerpt_control_height;
981 }
982 }
983 }
984 active_editor.update(cx, |editor, cx| {
985 editor.set_scroll_anchor(ScrollAnchor { offset, anchor }, cx);
986 });
987 }
988
989 if change_focus {
990 active_editor.focus_handle(cx).focus(cx);
991 } else {
992 self.focus_handle.focus(cx);
993 }
994 }
995 }
996 }
997
998 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
999 if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| {
1000 self.cached_entries
1001 .iter()
1002 .map(|cached_entry| &cached_entry.entry)
1003 .skip_while(|entry| entry != &selected_entry)
1004 .nth(1)
1005 .cloned()
1006 }) {
1007 self.select_entry(entry_to_select, true, cx);
1008 } else {
1009 self.select_first(&SelectFirst {}, cx)
1010 }
1011 if let Some(selected_entry) = self.selected_entry().cloned() {
1012 self.scroll_editor_to_entry(&selected_entry, true, false, cx);
1013 }
1014 }
1015
1016 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
1017 if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| {
1018 self.cached_entries
1019 .iter()
1020 .rev()
1021 .map(|cached_entry| &cached_entry.entry)
1022 .skip_while(|entry| entry != &selected_entry)
1023 .nth(1)
1024 .cloned()
1025 }) {
1026 self.select_entry(entry_to_select, true, cx);
1027 } else {
1028 self.select_last(&SelectLast, cx)
1029 }
1030 if let Some(selected_entry) = self.selected_entry().cloned() {
1031 self.scroll_editor_to_entry(&selected_entry, true, false, cx);
1032 }
1033 }
1034
1035 fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
1036 if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| {
1037 let mut previous_entries = self
1038 .cached_entries
1039 .iter()
1040 .rev()
1041 .map(|cached_entry| &cached_entry.entry)
1042 .skip_while(|entry| entry != &selected_entry)
1043 .skip(1);
1044 match &selected_entry {
1045 PanelEntry::Fs(fs_entry) => match fs_entry {
1046 FsEntry::ExternalFile(..) => None,
1047 FsEntry::File(worktree_id, entry, ..)
1048 | FsEntry::Directory(worktree_id, entry) => {
1049 entry.path.parent().and_then(|parent_path| {
1050 previous_entries.find(|entry| match entry {
1051 PanelEntry::Fs(FsEntry::Directory(dir_worktree_id, dir_entry)) => {
1052 dir_worktree_id == worktree_id
1053 && dir_entry.path.as_ref() == parent_path
1054 }
1055 PanelEntry::FoldedDirs(dirs_worktree_id, dirs) => {
1056 dirs_worktree_id == worktree_id
1057 && dirs
1058 .last()
1059 .map_or(false, |dir| dir.path.as_ref() == parent_path)
1060 }
1061 _ => false,
1062 })
1063 })
1064 }
1065 },
1066 PanelEntry::FoldedDirs(worktree_id, entries) => entries
1067 .first()
1068 .and_then(|entry| entry.path.parent())
1069 .and_then(|parent_path| {
1070 previous_entries.find(|entry| {
1071 if let PanelEntry::Fs(FsEntry::Directory(dir_worktree_id, dir_entry)) =
1072 entry
1073 {
1074 dir_worktree_id == worktree_id
1075 && dir_entry.path.as_ref() == parent_path
1076 } else {
1077 false
1078 }
1079 })
1080 }),
1081 PanelEntry::Outline(OutlineEntry::Excerpt(excerpt_buffer_id, excerpt_id, _)) => {
1082 previous_entries.find(|entry| match entry {
1083 PanelEntry::Fs(FsEntry::File(_, _, file_buffer_id, file_excerpts)) => {
1084 file_buffer_id == excerpt_buffer_id
1085 && file_excerpts.contains(excerpt_id)
1086 }
1087 PanelEntry::Fs(FsEntry::ExternalFile(file_buffer_id, file_excerpts)) => {
1088 file_buffer_id == excerpt_buffer_id
1089 && file_excerpts.contains(excerpt_id)
1090 }
1091 _ => false,
1092 })
1093 }
1094 PanelEntry::Outline(OutlineEntry::Outline(
1095 outline_buffer_id,
1096 outline_excerpt_id,
1097 _,
1098 )) => previous_entries.find(|entry| {
1099 if let PanelEntry::Outline(OutlineEntry::Excerpt(
1100 excerpt_buffer_id,
1101 excerpt_id,
1102 _,
1103 )) = entry
1104 {
1105 outline_buffer_id == excerpt_buffer_id && outline_excerpt_id == excerpt_id
1106 } else {
1107 false
1108 }
1109 }),
1110 PanelEntry::Search(_) => {
1111 previous_entries.find(|entry| !matches!(entry, PanelEntry::Search(_)))
1112 }
1113 }
1114 }) {
1115 self.select_entry(entry_to_select.clone(), true, cx);
1116 } else {
1117 self.select_first(&SelectFirst {}, cx);
1118 }
1119 }
1120
1121 fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
1122 if let Some(first_entry) = self.cached_entries.first() {
1123 self.select_entry(first_entry.entry.clone(), true, cx);
1124 }
1125 }
1126
1127 fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
1128 if let Some(new_selection) = self
1129 .cached_entries
1130 .iter()
1131 .rev()
1132 .map(|cached_entry| &cached_entry.entry)
1133 .next()
1134 {
1135 self.select_entry(new_selection.clone(), true, cx);
1136 }
1137 }
1138
1139 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
1140 if let Some(selected_entry) = self.selected_entry() {
1141 let index = self
1142 .cached_entries
1143 .iter()
1144 .position(|cached_entry| &cached_entry.entry == selected_entry);
1145 if let Some(index) = index {
1146 self.scroll_handle
1147 .scroll_to_item(index, ScrollStrategy::Center);
1148 cx.notify();
1149 }
1150 }
1151 }
1152
1153 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
1154 if !self.focus_handle.contains_focused(cx) {
1155 cx.emit(Event::Focus);
1156 }
1157 }
1158
1159 fn deploy_context_menu(
1160 &mut self,
1161 position: Point<Pixels>,
1162 entry: PanelEntry,
1163 cx: &mut ViewContext<Self>,
1164 ) {
1165 self.select_entry(entry.clone(), true, cx);
1166 let is_root = match &entry {
1167 PanelEntry::Fs(FsEntry::File(worktree_id, entry, ..))
1168 | PanelEntry::Fs(FsEntry::Directory(worktree_id, entry)) => self
1169 .project
1170 .read(cx)
1171 .worktree_for_id(*worktree_id, cx)
1172 .map(|worktree| {
1173 worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id)
1174 })
1175 .unwrap_or(false),
1176 PanelEntry::FoldedDirs(worktree_id, entries) => entries
1177 .first()
1178 .and_then(|entry| {
1179 self.project
1180 .read(cx)
1181 .worktree_for_id(*worktree_id, cx)
1182 .map(|worktree| {
1183 worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id)
1184 })
1185 })
1186 .unwrap_or(false),
1187 PanelEntry::Fs(FsEntry::ExternalFile(..)) => false,
1188 PanelEntry::Outline(..) => {
1189 cx.notify();
1190 return;
1191 }
1192 PanelEntry::Search(_) => {
1193 cx.notify();
1194 return;
1195 }
1196 };
1197 let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
1198 let is_foldable = auto_fold_dirs && !is_root && self.is_foldable(&entry);
1199 let is_unfoldable = auto_fold_dirs && !is_root && self.is_unfoldable(&entry);
1200
1201 let context_menu = ContextMenu::build(cx, |menu, _| {
1202 menu.context(self.focus_handle.clone())
1203 .when(cfg!(target_os = "macos"), |menu| {
1204 menu.action("Reveal in Finder", Box::new(RevealInFileManager))
1205 })
1206 .when(cfg!(not(target_os = "macos")), |menu| {
1207 menu.action("Reveal in File Manager", Box::new(RevealInFileManager))
1208 })
1209 .action("Open in Terminal", Box::new(OpenInTerminal))
1210 .when(is_unfoldable, |menu| {
1211 menu.action("Unfold Directory", Box::new(UnfoldDirectory))
1212 })
1213 .when(is_foldable, |menu| {
1214 menu.action("Fold Directory", Box::new(FoldDirectory))
1215 })
1216 .separator()
1217 .action("Copy Path", Box::new(CopyPath))
1218 .action("Copy Relative Path", Box::new(CopyRelativePath))
1219 });
1220 cx.focus_view(&context_menu);
1221 let subscription = cx.subscribe(&context_menu, |outline_panel, _, _: &DismissEvent, cx| {
1222 outline_panel.context_menu.take();
1223 cx.notify();
1224 });
1225 self.context_menu = Some((context_menu, position, subscription));
1226 cx.notify();
1227 }
1228
1229 fn is_unfoldable(&self, entry: &PanelEntry) -> bool {
1230 matches!(entry, PanelEntry::FoldedDirs(..))
1231 }
1232
1233 fn is_foldable(&self, entry: &PanelEntry) -> bool {
1234 let (directory_worktree, directory_entry) = match entry {
1235 PanelEntry::Fs(FsEntry::Directory(directory_worktree, directory_entry)) => {
1236 (*directory_worktree, Some(directory_entry))
1237 }
1238 _ => return false,
1239 };
1240 let Some(directory_entry) = directory_entry else {
1241 return false;
1242 };
1243
1244 if self
1245 .unfolded_dirs
1246 .get(&directory_worktree)
1247 .map_or(true, |unfolded_dirs| {
1248 !unfolded_dirs.contains(&directory_entry.id)
1249 })
1250 {
1251 return false;
1252 }
1253
1254 let children = self
1255 .fs_children_count
1256 .get(&directory_worktree)
1257 .and_then(|entries| entries.get(&directory_entry.path))
1258 .copied()
1259 .unwrap_or_default();
1260
1261 children.may_be_fold_part() && children.dirs > 0
1262 }
1263
1264 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
1265 let Some(active_editor) = self.active_editor() else {
1266 return;
1267 };
1268 let Some(selected_entry) = self.selected_entry().cloned() else {
1269 return;
1270 };
1271 let mut buffers_to_unfold = HashSet::default();
1272 let entry_to_expand = match &selected_entry {
1273 PanelEntry::FoldedDirs(worktree_id, dir_entries) => dir_entries.last().map(|entry| {
1274 buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry));
1275 CollapsedEntry::Dir(*worktree_id, entry.id)
1276 }),
1277 PanelEntry::Fs(FsEntry::Directory(worktree_id, dir_entry)) => {
1278 buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, dir_entry));
1279 Some(CollapsedEntry::Dir(*worktree_id, dir_entry.id))
1280 }
1281 PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _)) => {
1282 buffers_to_unfold.insert(*buffer_id);
1283 Some(CollapsedEntry::File(*worktree_id, *buffer_id))
1284 }
1285 PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => {
1286 buffers_to_unfold.insert(*buffer_id);
1287 Some(CollapsedEntry::ExternalFile(*buffer_id))
1288 }
1289 PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, _)) => {
1290 Some(CollapsedEntry::Excerpt(*buffer_id, *excerpt_id))
1291 }
1292 PanelEntry::Search(_) | PanelEntry::Outline(..) => return,
1293 };
1294 let Some(collapsed_entry) = entry_to_expand else {
1295 return;
1296 };
1297 let expanded = self.collapsed_entries.remove(&collapsed_entry);
1298 if expanded {
1299 if let CollapsedEntry::Dir(worktree_id, dir_entry_id) = collapsed_entry {
1300 let task = self.project.update(cx, |project, cx| {
1301 project.expand_entry(worktree_id, dir_entry_id, cx)
1302 });
1303 if let Some(task) = task {
1304 task.detach_and_log_err(cx);
1305 }
1306 };
1307
1308 active_editor.update(cx, |editor, cx| {
1309 buffers_to_unfold.retain(|buffer_id| editor.buffer_folded(*buffer_id, cx));
1310 });
1311 self.select_entry(selected_entry, true, cx);
1312 if buffers_to_unfold.is_empty() {
1313 self.update_cached_entries(None, cx);
1314 } else {
1315 self.toggle_buffers_fold(buffers_to_unfold, false, cx)
1316 .detach();
1317 }
1318 } else {
1319 self.select_next(&SelectNext, cx)
1320 }
1321 }
1322
1323 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
1324 let Some(active_editor) = self.active_editor() else {
1325 return;
1326 };
1327 let Some(selected_entry) = self.selected_entry().cloned() else {
1328 return;
1329 };
1330
1331 let mut buffers_to_fold = HashSet::default();
1332 let collapsed = match &selected_entry {
1333 PanelEntry::Fs(FsEntry::Directory(worktree_id, selected_dir_entry)) => {
1334 if self
1335 .collapsed_entries
1336 .insert(CollapsedEntry::Dir(*worktree_id, selected_dir_entry.id))
1337 {
1338 buffers_to_fold
1339 .extend(self.buffers_inside_directory(*worktree_id, selected_dir_entry));
1340 true
1341 } else {
1342 false
1343 }
1344 }
1345 PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _)) => {
1346 if self
1347 .collapsed_entries
1348 .insert(CollapsedEntry::File(*worktree_id, *buffer_id))
1349 {
1350 buffers_to_fold.insert(*buffer_id);
1351 true
1352 } else {
1353 false
1354 }
1355 }
1356 PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => {
1357 if self
1358 .collapsed_entries
1359 .insert(CollapsedEntry::ExternalFile(*buffer_id))
1360 {
1361 buffers_to_fold.insert(*buffer_id);
1362 true
1363 } else {
1364 false
1365 }
1366 }
1367 PanelEntry::FoldedDirs(worktree_id, dir_entries) => {
1368 let mut folded = false;
1369 if let Some(dir_entry) = dir_entries.last() {
1370 if self
1371 .collapsed_entries
1372 .insert(CollapsedEntry::Dir(*worktree_id, dir_entry.id))
1373 {
1374 folded = true;
1375 buffers_to_fold
1376 .extend(self.buffers_inside_directory(*worktree_id, dir_entry));
1377 }
1378 }
1379 folded
1380 }
1381 PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, _)) => self
1382 .collapsed_entries
1383 .insert(CollapsedEntry::Excerpt(*buffer_id, *excerpt_id)),
1384 PanelEntry::Search(_) | PanelEntry::Outline(..) => false,
1385 };
1386
1387 if collapsed {
1388 active_editor.update(cx, |editor, cx| {
1389 buffers_to_fold.retain(|buffer_id| !editor.buffer_folded(*buffer_id, cx));
1390 });
1391 self.select_entry(selected_entry, true, cx);
1392 if buffers_to_fold.is_empty() {
1393 self.update_cached_entries(None, cx);
1394 } else {
1395 self.toggle_buffers_fold(buffers_to_fold, true, cx).detach();
1396 }
1397 } else {
1398 self.select_parent(&SelectParent, cx);
1399 }
1400 }
1401
1402 pub fn expand_all_entries(&mut self, _: &ExpandAllEntries, cx: &mut ViewContext<Self>) {
1403 let Some(active_editor) = self.active_editor() else {
1404 return;
1405 };
1406 let mut buffers_to_unfold = HashSet::default();
1407 let expanded_entries =
1408 self.fs_entries
1409 .iter()
1410 .fold(HashSet::default(), |mut entries, fs_entry| {
1411 match fs_entry {
1412 FsEntry::ExternalFile(buffer_id, _) => {
1413 buffers_to_unfold.insert(*buffer_id);
1414 entries.insert(CollapsedEntry::ExternalFile(*buffer_id));
1415 entries.extend(self.excerpts.get(buffer_id).into_iter().flat_map(
1416 |excerpts| {
1417 excerpts.iter().map(|(excerpt_id, _)| {
1418 CollapsedEntry::Excerpt(*buffer_id, *excerpt_id)
1419 })
1420 },
1421 ));
1422 }
1423 FsEntry::Directory(worktree_id, entry) => {
1424 entries.insert(CollapsedEntry::Dir(*worktree_id, entry.id));
1425 }
1426 FsEntry::File(worktree_id, _, buffer_id, _) => {
1427 buffers_to_unfold.insert(*buffer_id);
1428 entries.insert(CollapsedEntry::File(*worktree_id, *buffer_id));
1429 entries.extend(self.excerpts.get(buffer_id).into_iter().flat_map(
1430 |excerpts| {
1431 excerpts.iter().map(|(excerpt_id, _)| {
1432 CollapsedEntry::Excerpt(*buffer_id, *excerpt_id)
1433 })
1434 },
1435 ));
1436 }
1437 };
1438 entries
1439 });
1440 self.collapsed_entries
1441 .retain(|entry| !expanded_entries.contains(entry));
1442 active_editor.update(cx, |editor, cx| {
1443 buffers_to_unfold.retain(|buffer_id| editor.buffer_folded(*buffer_id, cx));
1444 });
1445 if buffers_to_unfold.is_empty() {
1446 self.update_cached_entries(None, cx);
1447 } else {
1448 self.toggle_buffers_fold(buffers_to_unfold, false, cx)
1449 .detach();
1450 }
1451 }
1452
1453 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
1454 let Some(active_editor) = self.active_editor() else {
1455 return;
1456 };
1457 let mut buffers_to_fold = HashSet::default();
1458 let new_entries = self
1459 .cached_entries
1460 .iter()
1461 .flat_map(|cached_entry| match &cached_entry.entry {
1462 PanelEntry::Fs(FsEntry::Directory(worktree_id, entry)) => {
1463 Some(CollapsedEntry::Dir(*worktree_id, entry.id))
1464 }
1465 PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _)) => {
1466 buffers_to_fold.insert(*buffer_id);
1467 Some(CollapsedEntry::File(*worktree_id, *buffer_id))
1468 }
1469 PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => {
1470 buffers_to_fold.insert(*buffer_id);
1471 Some(CollapsedEntry::ExternalFile(*buffer_id))
1472 }
1473 PanelEntry::FoldedDirs(worktree_id, entries) => {
1474 Some(CollapsedEntry::Dir(*worktree_id, entries.last()?.id))
1475 }
1476 PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, _)) => {
1477 Some(CollapsedEntry::Excerpt(*buffer_id, *excerpt_id))
1478 }
1479 PanelEntry::Search(_) | PanelEntry::Outline(..) => None,
1480 })
1481 .collect::<Vec<_>>();
1482 self.collapsed_entries.extend(new_entries);
1483
1484 active_editor.update(cx, |editor, cx| {
1485 buffers_to_fold.retain(|buffer_id| !editor.buffer_folded(*buffer_id, cx));
1486 });
1487 if buffers_to_fold.is_empty() {
1488 self.update_cached_entries(None, cx);
1489 } else {
1490 self.toggle_buffers_fold(buffers_to_fold, true, cx).detach();
1491 }
1492 }
1493
1494 fn toggle_expanded(&mut self, entry: &PanelEntry, cx: &mut ViewContext<Self>) {
1495 let Some(active_editor) = self.active_editor() else {
1496 return;
1497 };
1498 let mut fold = false;
1499 let mut buffers_to_toggle = HashSet::default();
1500 match entry {
1501 PanelEntry::Fs(FsEntry::Directory(worktree_id, dir_entry)) => {
1502 let entry_id = dir_entry.id;
1503 let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id);
1504 buffers_to_toggle.extend(self.buffers_inside_directory(*worktree_id, dir_entry));
1505 if self.collapsed_entries.remove(&collapsed_entry) {
1506 self.project
1507 .update(cx, |project, cx| {
1508 project.expand_entry(*worktree_id, entry_id, cx)
1509 })
1510 .unwrap_or_else(|| Task::ready(Ok(())))
1511 .detach_and_log_err(cx);
1512 } else {
1513 self.collapsed_entries.insert(collapsed_entry);
1514 fold = true;
1515 }
1516 }
1517 PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _)) => {
1518 let collapsed_entry = CollapsedEntry::File(*worktree_id, *buffer_id);
1519 buffers_to_toggle.insert(*buffer_id);
1520 if !self.collapsed_entries.remove(&collapsed_entry) {
1521 self.collapsed_entries.insert(collapsed_entry);
1522 fold = true;
1523 }
1524 }
1525 PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => {
1526 let collapsed_entry = CollapsedEntry::ExternalFile(*buffer_id);
1527 buffers_to_toggle.insert(*buffer_id);
1528 if !self.collapsed_entries.remove(&collapsed_entry) {
1529 self.collapsed_entries.insert(collapsed_entry);
1530 fold = true;
1531 }
1532 }
1533 PanelEntry::FoldedDirs(worktree_id, dir_entries) => {
1534 if let Some(dir_entry) = dir_entries.first() {
1535 let entry_id = dir_entry.id;
1536 let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id);
1537 buffers_to_toggle
1538 .extend(self.buffers_inside_directory(*worktree_id, dir_entry));
1539 if self.collapsed_entries.remove(&collapsed_entry) {
1540 self.project
1541 .update(cx, |project, cx| {
1542 project.expand_entry(*worktree_id, entry_id, cx)
1543 })
1544 .unwrap_or_else(|| Task::ready(Ok(())))
1545 .detach_and_log_err(cx);
1546 } else {
1547 self.collapsed_entries.insert(collapsed_entry);
1548 fold = true;
1549 }
1550 }
1551 }
1552 PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, _)) => {
1553 let collapsed_entry = CollapsedEntry::Excerpt(*buffer_id, *excerpt_id);
1554 if !self.collapsed_entries.remove(&collapsed_entry) {
1555 self.collapsed_entries.insert(collapsed_entry);
1556 }
1557 }
1558 PanelEntry::Search(_) | PanelEntry::Outline(..) => return,
1559 }
1560
1561 active_editor.update(cx, |editor, cx| {
1562 buffers_to_toggle.retain(|buffer_id| {
1563 let folded = editor.buffer_folded(*buffer_id, cx);
1564 if fold {
1565 !folded
1566 } else {
1567 folded
1568 }
1569 });
1570 });
1571
1572 self.select_entry(entry.clone(), true, cx);
1573 if buffers_to_toggle.is_empty() {
1574 self.update_cached_entries(None, cx);
1575 } else {
1576 self.toggle_buffers_fold(buffers_to_toggle, fold, cx)
1577 .detach();
1578 }
1579 }
1580
1581 fn toggle_buffers_fold(
1582 &self,
1583 buffers: HashSet<BufferId>,
1584 fold: bool,
1585 cx: &mut ViewContext<Self>,
1586 ) -> Task<()> {
1587 let Some(active_editor) = self.active_editor() else {
1588 return Task::ready(());
1589 };
1590 cx.spawn(|outline_panel, mut cx| async move {
1591 outline_panel
1592 .update(&mut cx, |outline_panel, cx| {
1593 active_editor.update(cx, |editor, cx| {
1594 for buffer_id in buffers {
1595 outline_panel
1596 .preserve_selection_on_buffer_fold_toggles
1597 .insert(buffer_id);
1598 if fold {
1599 editor.fold_buffer(buffer_id, cx);
1600 } else {
1601 editor.unfold_buffer(buffer_id, cx);
1602 }
1603 }
1604 });
1605 if let Some(selection) = outline_panel.selected_entry().cloned() {
1606 outline_panel.scroll_editor_to_entry(&selection, false, false, cx);
1607 }
1608 })
1609 .ok();
1610 })
1611 }
1612
1613 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
1614 if let Some(clipboard_text) = self
1615 .selected_entry()
1616 .and_then(|entry| self.abs_path(entry, cx))
1617 .map(|p| p.to_string_lossy().to_string())
1618 {
1619 cx.write_to_clipboard(ClipboardItem::new_string(clipboard_text));
1620 }
1621 }
1622
1623 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
1624 if let Some(clipboard_text) = self
1625 .selected_entry()
1626 .and_then(|entry| match entry {
1627 PanelEntry::Fs(entry) => self.relative_path(entry, cx),
1628 PanelEntry::FoldedDirs(_, dirs) => dirs.last().map(|entry| entry.path.clone()),
1629 PanelEntry::Search(_) | PanelEntry::Outline(..) => None,
1630 })
1631 .map(|p| p.to_string_lossy().to_string())
1632 {
1633 cx.write_to_clipboard(ClipboardItem::new_string(clipboard_text));
1634 }
1635 }
1636
1637 fn reveal_in_finder(&mut self, _: &RevealInFileManager, cx: &mut ViewContext<Self>) {
1638 if let Some(abs_path) = self
1639 .selected_entry()
1640 .and_then(|entry| self.abs_path(entry, cx))
1641 {
1642 cx.reveal_path(&abs_path);
1643 }
1644 }
1645
1646 fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
1647 let selected_entry = self.selected_entry();
1648 let abs_path = selected_entry.and_then(|entry| self.abs_path(entry, cx));
1649 let working_directory = if let (
1650 Some(abs_path),
1651 Some(PanelEntry::Fs(FsEntry::File(..) | FsEntry::ExternalFile(..))),
1652 ) = (&abs_path, selected_entry)
1653 {
1654 abs_path.parent().map(|p| p.to_owned())
1655 } else {
1656 abs_path
1657 };
1658
1659 if let Some(working_directory) = working_directory {
1660 cx.dispatch_action(workspace::OpenTerminal { working_directory }.boxed_clone())
1661 }
1662 }
1663
1664 fn reveal_entry_for_selection(&mut self, editor: View<Editor>, cx: &mut ViewContext<'_, Self>) {
1665 if !self.active || !OutlinePanelSettings::get_global(cx).auto_reveal_entries {
1666 return;
1667 }
1668 let project = self.project.clone();
1669 self.reveal_selection_task = cx.spawn(|outline_panel, mut cx| async move {
1670 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1671 let entry_with_selection = outline_panel.update(&mut cx, |outline_panel, cx| {
1672 outline_panel.location_for_editor_selection(&editor, cx)
1673 })?;
1674 let Some(entry_with_selection) = entry_with_selection else {
1675 outline_panel.update(&mut cx, |outline_panel, cx| {
1676 outline_panel.selected_entry = SelectedEntry::None;
1677 cx.notify();
1678 })?;
1679 return Ok(());
1680 };
1681 let related_buffer_entry = match &entry_with_selection {
1682 PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _)) => {
1683 project.update(&mut cx, |project, cx| {
1684 let entry_id = project
1685 .buffer_for_id(*buffer_id, cx)
1686 .and_then(|buffer| buffer.read(cx).entry_id(cx));
1687 project
1688 .worktree_for_id(*worktree_id, cx)
1689 .zip(entry_id)
1690 .and_then(|(worktree, entry_id)| {
1691 let entry = worktree.read(cx).entry_for_id(entry_id)?.clone();
1692 Some((worktree, entry))
1693 })
1694 })?
1695 }
1696 PanelEntry::Outline(outline_entry) => {
1697 let &(OutlineEntry::Outline(buffer_id, excerpt_id, _)
1698 | OutlineEntry::Excerpt(buffer_id, excerpt_id, _)) = outline_entry;
1699 outline_panel.update(&mut cx, |outline_panel, cx| {
1700 outline_panel
1701 .collapsed_entries
1702 .remove(&CollapsedEntry::ExternalFile(buffer_id));
1703 outline_panel
1704 .collapsed_entries
1705 .remove(&CollapsedEntry::Excerpt(buffer_id, excerpt_id));
1706 let project = outline_panel.project.read(cx);
1707 let entry_id = project
1708 .buffer_for_id(buffer_id, cx)
1709 .and_then(|buffer| buffer.read(cx).entry_id(cx));
1710
1711 entry_id.and_then(|entry_id| {
1712 project
1713 .worktree_for_entry(entry_id, cx)
1714 .and_then(|worktree| {
1715 let worktree_id = worktree.read(cx).id();
1716 outline_panel
1717 .collapsed_entries
1718 .remove(&CollapsedEntry::File(worktree_id, buffer_id));
1719 let entry = worktree.read(cx).entry_for_id(entry_id)?.clone();
1720 Some((worktree, entry))
1721 })
1722 })
1723 })?
1724 }
1725 PanelEntry::Fs(FsEntry::ExternalFile(..)) => None,
1726 PanelEntry::Search(SearchEntry { match_range, .. }) => match_range
1727 .start
1728 .buffer_id
1729 .or(match_range.end.buffer_id)
1730 .map(|buffer_id| {
1731 outline_panel.update(&mut cx, |outline_panel, cx| {
1732 outline_panel
1733 .collapsed_entries
1734 .remove(&CollapsedEntry::ExternalFile(buffer_id));
1735 let project = project.read(cx);
1736 let entry_id = project
1737 .buffer_for_id(buffer_id, cx)
1738 .and_then(|buffer| buffer.read(cx).entry_id(cx));
1739
1740 entry_id.and_then(|entry_id| {
1741 project
1742 .worktree_for_entry(entry_id, cx)
1743 .and_then(|worktree| {
1744 let worktree_id = worktree.read(cx).id();
1745 outline_panel
1746 .collapsed_entries
1747 .remove(&CollapsedEntry::File(worktree_id, buffer_id));
1748 let entry =
1749 worktree.read(cx).entry_for_id(entry_id)?.clone();
1750 Some((worktree, entry))
1751 })
1752 })
1753 })
1754 })
1755 .transpose()?
1756 .flatten(),
1757 _ => return anyhow::Ok(()),
1758 };
1759 if let Some((worktree, buffer_entry)) = related_buffer_entry {
1760 outline_panel.update(&mut cx, |outline_panel, cx| {
1761 let worktree_id = worktree.read(cx).id();
1762 let mut dirs_to_expand = Vec::new();
1763 {
1764 let mut traversal = worktree.read(cx).traverse_from_path(
1765 true,
1766 true,
1767 true,
1768 buffer_entry.path.as_ref(),
1769 );
1770 let mut current_entry = buffer_entry;
1771 loop {
1772 if current_entry.is_dir()
1773 && outline_panel
1774 .collapsed_entries
1775 .remove(&CollapsedEntry::Dir(worktree_id, current_entry.id))
1776 {
1777 dirs_to_expand.push(current_entry.id);
1778 }
1779
1780 if traversal.back_to_parent() {
1781 if let Some(parent_entry) = traversal.entry() {
1782 current_entry = parent_entry.clone();
1783 continue;
1784 }
1785 }
1786 break;
1787 }
1788 }
1789 for dir_to_expand in dirs_to_expand {
1790 project
1791 .update(cx, |project, cx| {
1792 project.expand_entry(worktree_id, dir_to_expand, cx)
1793 })
1794 .unwrap_or_else(|| Task::ready(Ok(())))
1795 .detach_and_log_err(cx)
1796 }
1797 })?
1798 }
1799
1800 outline_panel.update(&mut cx, |outline_panel, cx| {
1801 outline_panel.select_entry(entry_with_selection, false, cx);
1802 outline_panel.update_cached_entries(None, cx);
1803 })?;
1804
1805 anyhow::Ok(())
1806 });
1807 }
1808
1809 fn render_excerpt(
1810 &self,
1811 buffer_id: BufferId,
1812 excerpt_id: ExcerptId,
1813 range: &ExcerptRange<language::Anchor>,
1814 depth: usize,
1815 cx: &mut ViewContext<OutlinePanel>,
1816 ) -> Option<Stateful<Div>> {
1817 let item_id = ElementId::from(excerpt_id.to_proto() as usize);
1818 let is_active = match self.selected_entry() {
1819 Some(PanelEntry::Outline(OutlineEntry::Excerpt(
1820 selected_buffer_id,
1821 selected_excerpt_id,
1822 _,
1823 ))) => selected_buffer_id == &buffer_id && selected_excerpt_id == &excerpt_id,
1824 _ => false,
1825 };
1826 let has_outlines = self
1827 .excerpts
1828 .get(&buffer_id)
1829 .and_then(|excerpts| match &excerpts.get(&excerpt_id)?.outlines {
1830 ExcerptOutlines::Outlines(outlines) => Some(outlines),
1831 ExcerptOutlines::Invalidated(outlines) => Some(outlines),
1832 ExcerptOutlines::NotFetched => None,
1833 })
1834 .map_or(false, |outlines| !outlines.is_empty());
1835 let is_expanded = !self
1836 .collapsed_entries
1837 .contains(&CollapsedEntry::Excerpt(buffer_id, excerpt_id));
1838 let color = entry_git_aware_label_color(None, false, is_active);
1839 let icon = if has_outlines {
1840 FileIcons::get_chevron_icon(is_expanded, cx)
1841 .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element())
1842 } else {
1843 None
1844 }
1845 .unwrap_or_else(empty_icon);
1846
1847 let label = self.excerpt_label(buffer_id, range, cx)?;
1848 let label_element = Label::new(label)
1849 .single_line()
1850 .color(color)
1851 .into_any_element();
1852
1853 Some(self.entry_element(
1854 PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, range.clone())),
1855 item_id,
1856 depth,
1857 Some(icon),
1858 is_active,
1859 label_element,
1860 cx,
1861 ))
1862 }
1863
1864 fn excerpt_label(
1865 &self,
1866 buffer_id: BufferId,
1867 range: &ExcerptRange<language::Anchor>,
1868 cx: &AppContext,
1869 ) -> Option<String> {
1870 let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx)?;
1871 let excerpt_range = range.context.to_point(&buffer_snapshot);
1872 Some(format!(
1873 "Lines {}- {}",
1874 excerpt_range.start.row + 1,
1875 excerpt_range.end.row + 1,
1876 ))
1877 }
1878
1879 fn render_outline(
1880 &self,
1881 buffer_id: BufferId,
1882 excerpt_id: ExcerptId,
1883 rendered_outline: &Outline,
1884 depth: usize,
1885 string_match: Option<&StringMatch>,
1886 cx: &mut ViewContext<Self>,
1887 ) -> Stateful<Div> {
1888 let (item_id, label_element) = (
1889 ElementId::from(SharedString::from(format!(
1890 "{buffer_id:?}|{excerpt_id:?}{:?}|{:?}",
1891 rendered_outline.range, &rendered_outline.text,
1892 ))),
1893 outline::render_item(
1894 rendered_outline,
1895 string_match
1896 .map(|string_match| string_match.ranges().collect::<Vec<_>>())
1897 .unwrap_or_default(),
1898 cx,
1899 )
1900 .into_any_element(),
1901 );
1902 let is_active = match self.selected_entry() {
1903 Some(PanelEntry::Outline(OutlineEntry::Outline(
1904 selected_buffer_id,
1905 selected_excerpt_id,
1906 selected_entry,
1907 ))) => {
1908 selected_buffer_id == &buffer_id
1909 && selected_excerpt_id == &excerpt_id
1910 && selected_entry == rendered_outline
1911 }
1912 _ => false,
1913 };
1914 let icon = if self.is_singleton_active(cx) {
1915 None
1916 } else {
1917 Some(empty_icon())
1918 };
1919 self.entry_element(
1920 PanelEntry::Outline(OutlineEntry::Outline(
1921 buffer_id,
1922 excerpt_id,
1923 rendered_outline.clone(),
1924 )),
1925 item_id,
1926 depth,
1927 icon,
1928 is_active,
1929 label_element,
1930 cx,
1931 )
1932 }
1933
1934 fn render_entry(
1935 &self,
1936 rendered_entry: &FsEntry,
1937 depth: usize,
1938 string_match: Option<&StringMatch>,
1939 cx: &mut ViewContext<Self>,
1940 ) -> Stateful<Div> {
1941 let settings = OutlinePanelSettings::get_global(cx);
1942 let is_active = match self.selected_entry() {
1943 Some(PanelEntry::Fs(selected_entry)) => selected_entry == rendered_entry,
1944 _ => false,
1945 };
1946 let (item_id, label_element, icon) = match rendered_entry {
1947 FsEntry::File(worktree_id, entry, ..) => {
1948 let name = self.entry_name(worktree_id, entry, cx);
1949 let color =
1950 entry_git_aware_label_color(entry.git_status, entry.is_ignored, is_active);
1951 let icon = if settings.file_icons {
1952 FileIcons::get_icon(&entry.path, cx)
1953 .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element())
1954 } else {
1955 None
1956 };
1957 (
1958 ElementId::from(entry.id.to_proto() as usize),
1959 HighlightedLabel::new(
1960 name,
1961 string_match
1962 .map(|string_match| string_match.positions.clone())
1963 .unwrap_or_default(),
1964 )
1965 .color(color)
1966 .into_any_element(),
1967 icon.unwrap_or_else(empty_icon),
1968 )
1969 }
1970 FsEntry::Directory(worktree_id, entry) => {
1971 let name = self.entry_name(worktree_id, entry, cx);
1972
1973 let is_expanded = !self
1974 .collapsed_entries
1975 .contains(&CollapsedEntry::Dir(*worktree_id, entry.id));
1976 let color =
1977 entry_git_aware_label_color(entry.git_status, entry.is_ignored, is_active);
1978 let icon = if settings.folder_icons {
1979 FileIcons::get_folder_icon(is_expanded, cx)
1980 } else {
1981 FileIcons::get_chevron_icon(is_expanded, cx)
1982 }
1983 .map(Icon::from_path)
1984 .map(|icon| icon.color(color).into_any_element());
1985 (
1986 ElementId::from(entry.id.to_proto() as usize),
1987 HighlightedLabel::new(
1988 name,
1989 string_match
1990 .map(|string_match| string_match.positions.clone())
1991 .unwrap_or_default(),
1992 )
1993 .color(color)
1994 .into_any_element(),
1995 icon.unwrap_or_else(empty_icon),
1996 )
1997 }
1998 FsEntry::ExternalFile(buffer_id, _) => {
1999 let color = entry_label_color(is_active);
2000 let (icon, name) = match self.buffer_snapshot_for_id(*buffer_id, cx) {
2001 Some(buffer_snapshot) => match buffer_snapshot.file() {
2002 Some(file) => {
2003 let path = file.path();
2004 let icon = if settings.file_icons {
2005 FileIcons::get_icon(path.as_ref(), cx)
2006 } else {
2007 None
2008 }
2009 .map(Icon::from_path)
2010 .map(|icon| icon.color(color).into_any_element());
2011 (icon, file_name(path.as_ref()))
2012 }
2013 None => (None, "Untitled".to_string()),
2014 },
2015 None => (None, "Unknown buffer".to_string()),
2016 };
2017 (
2018 ElementId::from(buffer_id.to_proto() as usize),
2019 HighlightedLabel::new(
2020 name,
2021 string_match
2022 .map(|string_match| string_match.positions.clone())
2023 .unwrap_or_default(),
2024 )
2025 .color(color)
2026 .into_any_element(),
2027 icon.unwrap_or_else(empty_icon),
2028 )
2029 }
2030 };
2031
2032 self.entry_element(
2033 PanelEntry::Fs(rendered_entry.clone()),
2034 item_id,
2035 depth,
2036 Some(icon),
2037 is_active,
2038 label_element,
2039 cx,
2040 )
2041 }
2042
2043 fn render_folded_dirs(
2044 &self,
2045 worktree_id: WorktreeId,
2046 dir_entries: &[Entry],
2047 depth: usize,
2048 string_match: Option<&StringMatch>,
2049 cx: &mut ViewContext<OutlinePanel>,
2050 ) -> Stateful<Div> {
2051 let settings = OutlinePanelSettings::get_global(cx);
2052 let is_active = match self.selected_entry() {
2053 Some(PanelEntry::FoldedDirs(selected_worktree_id, selected_entries)) => {
2054 selected_worktree_id == &worktree_id && selected_entries == dir_entries
2055 }
2056 _ => false,
2057 };
2058 let (item_id, label_element, icon) = {
2059 let name = self.dir_names_string(dir_entries, worktree_id, cx);
2060
2061 let is_expanded = dir_entries.iter().all(|dir| {
2062 !self
2063 .collapsed_entries
2064 .contains(&CollapsedEntry::Dir(worktree_id, dir.id))
2065 });
2066 let is_ignored = dir_entries.iter().any(|entry| entry.is_ignored);
2067 let git_status = dir_entries.first().and_then(|entry| entry.git_status);
2068 let color = entry_git_aware_label_color(git_status, is_ignored, is_active);
2069 let icon = if settings.folder_icons {
2070 FileIcons::get_folder_icon(is_expanded, cx)
2071 } else {
2072 FileIcons::get_chevron_icon(is_expanded, cx)
2073 }
2074 .map(Icon::from_path)
2075 .map(|icon| icon.color(color).into_any_element());
2076 (
2077 ElementId::from(
2078 dir_entries
2079 .last()
2080 .map(|entry| entry.id.to_proto())
2081 .unwrap_or_else(|| worktree_id.to_proto()) as usize,
2082 ),
2083 HighlightedLabel::new(
2084 name,
2085 string_match
2086 .map(|string_match| string_match.positions.clone())
2087 .unwrap_or_default(),
2088 )
2089 .color(color)
2090 .into_any_element(),
2091 icon.unwrap_or_else(empty_icon),
2092 )
2093 };
2094
2095 self.entry_element(
2096 PanelEntry::FoldedDirs(worktree_id, dir_entries.to_vec()),
2097 item_id,
2098 depth,
2099 Some(icon),
2100 is_active,
2101 label_element,
2102 cx,
2103 )
2104 }
2105
2106 #[allow(clippy::too_many_arguments)]
2107 fn render_search_match(
2108 &mut self,
2109 multi_buffer_snapshot: Option<&MultiBufferSnapshot>,
2110 match_range: &Range<editor::Anchor>,
2111 render_data: &Arc<OnceLock<SearchData>>,
2112 kind: SearchKind,
2113 depth: usize,
2114 string_match: Option<&StringMatch>,
2115 cx: &mut ViewContext<Self>,
2116 ) -> Option<Stateful<Div>> {
2117 let search_data = match render_data.get() {
2118 Some(search_data) => search_data,
2119 None => {
2120 if let ItemsDisplayMode::Search(search_state) = &mut self.mode {
2121 if let Some(multi_buffer_snapshot) = multi_buffer_snapshot {
2122 search_state
2123 .highlight_search_match_tx
2124 .try_send(HighlightArguments {
2125 multi_buffer_snapshot: multi_buffer_snapshot.clone(),
2126 match_range: match_range.clone(),
2127 search_data: Arc::clone(render_data),
2128 })
2129 .ok();
2130 }
2131 }
2132 return None;
2133 }
2134 };
2135 let search_matches = string_match
2136 .iter()
2137 .flat_map(|string_match| string_match.ranges())
2138 .collect::<Vec<_>>();
2139 let match_ranges = if search_matches.is_empty() {
2140 &search_data.search_match_indices
2141 } else {
2142 &search_matches
2143 };
2144 let label_element = outline::render_item(
2145 &OutlineItem {
2146 depth,
2147 annotation_range: None,
2148 range: search_data.context_range.clone(),
2149 text: search_data.context_text.clone(),
2150 highlight_ranges: search_data
2151 .highlights_data
2152 .get()
2153 .cloned()
2154 .unwrap_or_default(),
2155 name_ranges: search_data.search_match_indices.clone(),
2156 body_range: Some(search_data.context_range.clone()),
2157 },
2158 match_ranges.iter().cloned(),
2159 cx,
2160 );
2161 let truncated_contents_label = || Label::new(TRUNCATED_CONTEXT_MARK);
2162 let entire_label = h_flex()
2163 .justify_center()
2164 .p_0()
2165 .when(search_data.truncated_left, |parent| {
2166 parent.child(truncated_contents_label())
2167 })
2168 .child(label_element)
2169 .when(search_data.truncated_right, |parent| {
2170 parent.child(truncated_contents_label())
2171 })
2172 .into_any_element();
2173
2174 let is_active = match self.selected_entry() {
2175 Some(PanelEntry::Search(SearchEntry {
2176 match_range: selected_match_range,
2177 ..
2178 })) => match_range == selected_match_range,
2179 _ => false,
2180 };
2181 Some(self.entry_element(
2182 PanelEntry::Search(SearchEntry {
2183 kind,
2184 match_range: match_range.clone(),
2185 render_data: render_data.clone(),
2186 }),
2187 ElementId::from(SharedString::from(format!("search-{match_range:?}"))),
2188 depth,
2189 None,
2190 is_active,
2191 entire_label,
2192 cx,
2193 ))
2194 }
2195
2196 #[allow(clippy::too_many_arguments)]
2197 fn entry_element(
2198 &self,
2199 rendered_entry: PanelEntry,
2200 item_id: ElementId,
2201 depth: usize,
2202 icon_element: Option<AnyElement>,
2203 is_active: bool,
2204 label_element: gpui::AnyElement,
2205 cx: &mut ViewContext<OutlinePanel>,
2206 ) -> Stateful<Div> {
2207 let settings = OutlinePanelSettings::get_global(cx);
2208 div()
2209 .text_ui(cx)
2210 .id(item_id.clone())
2211 .on_click({
2212 let clicked_entry = rendered_entry.clone();
2213 cx.listener(move |outline_panel, event: &gpui::ClickEvent, cx| {
2214 if event.down.button == MouseButton::Right || event.down.first_mouse {
2215 return;
2216 }
2217 let change_focus = event.down.click_count > 1;
2218 outline_panel.toggle_expanded(&clicked_entry, cx);
2219 outline_panel.scroll_editor_to_entry(&clicked_entry, true, change_focus, cx);
2220 })
2221 })
2222 .cursor_pointer()
2223 .child(
2224 ListItem::new(item_id)
2225 .indent_level(depth)
2226 .indent_step_size(px(settings.indent_size))
2227 .toggle_state(is_active)
2228 .when_some(icon_element, |list_item, icon_element| {
2229 list_item.child(h_flex().child(icon_element))
2230 })
2231 .child(h_flex().h_6().child(label_element).ml_1())
2232 .on_secondary_mouse_down(cx.listener(
2233 move |outline_panel, event: &MouseDownEvent, cx| {
2234 // Stop propagation to prevent the catch-all context menu for the project
2235 // panel from being deployed.
2236 cx.stop_propagation();
2237 outline_panel.deploy_context_menu(
2238 event.position,
2239 rendered_entry.clone(),
2240 cx,
2241 )
2242 },
2243 )),
2244 )
2245 .border_1()
2246 .border_r_2()
2247 .rounded_none()
2248 .hover(|style| {
2249 if is_active {
2250 style
2251 } else {
2252 let hover_color = cx.theme().colors().ghost_element_hover;
2253 style.bg(hover_color).border_color(hover_color)
2254 }
2255 })
2256 .when(is_active && self.focus_handle.contains_focused(cx), |div| {
2257 div.border_color(Color::Selected.color(cx))
2258 })
2259 }
2260
2261 fn entry_name(&self, worktree_id: &WorktreeId, entry: &Entry, cx: &AppContext) -> String {
2262 let name = match self.project.read(cx).worktree_for_id(*worktree_id, cx) {
2263 Some(worktree) => {
2264 let worktree = worktree.read(cx);
2265 match worktree.snapshot().root_entry() {
2266 Some(root_entry) => {
2267 if root_entry.id == entry.id {
2268 file_name(worktree.abs_path().as_ref())
2269 } else {
2270 let path = worktree.absolutize(entry.path.as_ref()).ok();
2271 let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref());
2272 file_name(path)
2273 }
2274 }
2275 None => {
2276 let path = worktree.absolutize(entry.path.as_ref()).ok();
2277 let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref());
2278 file_name(path)
2279 }
2280 }
2281 }
2282 None => file_name(entry.path.as_ref()),
2283 };
2284 name
2285 }
2286
2287 fn update_fs_entries(
2288 &mut self,
2289 active_editor: View<Editor>,
2290 debounce: Option<Duration>,
2291 cx: &mut ViewContext<Self>,
2292 ) {
2293 if !self.active {
2294 return;
2295 }
2296
2297 let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
2298 let active_multi_buffer = active_editor.read(cx).buffer().clone();
2299 let new_entries = self.new_entries_for_fs_update.clone();
2300 self.updating_fs_entries = true;
2301 self.fs_entries_update_task = cx.spawn(|outline_panel, mut cx| async move {
2302 if let Some(debounce) = debounce {
2303 cx.background_executor().timer(debounce).await;
2304 }
2305
2306 let mut new_collapsed_entries = HashSet::default();
2307 let mut new_unfolded_dirs = HashMap::default();
2308 let mut root_entries = HashSet::default();
2309 let mut new_excerpts = HashMap::<BufferId, HashMap<ExcerptId, Excerpt>>::default();
2310 let Ok(buffer_excerpts) = outline_panel.update(&mut cx, |outline_panel, cx| {
2311 new_collapsed_entries = outline_panel.collapsed_entries.clone();
2312 new_unfolded_dirs = outline_panel.unfolded_dirs.clone();
2313 let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx);
2314 let buffer_excerpts = multi_buffer_snapshot.excerpts().fold(
2315 HashMap::default(),
2316 |mut buffer_excerpts, (excerpt_id, buffer_snapshot, excerpt_range)| {
2317 let buffer_id = buffer_snapshot.remote_id();
2318 let file = File::from_dyn(buffer_snapshot.file());
2319 let entry_id = file.and_then(|file| file.project_entry_id(cx));
2320 let worktree = file.map(|file| file.worktree.read(cx).snapshot());
2321 let is_new = new_entries.contains(&excerpt_id)
2322 || !outline_panel.excerpts.contains_key(&buffer_id);
2323 let is_folded = active_editor.read(cx).buffer_folded(buffer_id, cx);
2324 buffer_excerpts
2325 .entry(buffer_id)
2326 .or_insert_with(|| (is_new, is_folded, Vec::new(), entry_id, worktree))
2327 .2
2328 .push(excerpt_id);
2329
2330 let outlines = match outline_panel
2331 .excerpts
2332 .get(&buffer_id)
2333 .and_then(|excerpts| excerpts.get(&excerpt_id))
2334 {
2335 Some(old_excerpt) => match &old_excerpt.outlines {
2336 ExcerptOutlines::Outlines(outlines) => {
2337 ExcerptOutlines::Outlines(outlines.clone())
2338 }
2339 ExcerptOutlines::Invalidated(_) => ExcerptOutlines::NotFetched,
2340 ExcerptOutlines::NotFetched => ExcerptOutlines::NotFetched,
2341 },
2342 None => ExcerptOutlines::NotFetched,
2343 };
2344 new_excerpts.entry(buffer_id).or_default().insert(
2345 excerpt_id,
2346 Excerpt {
2347 range: excerpt_range,
2348 outlines,
2349 },
2350 );
2351 buffer_excerpts
2352 },
2353 );
2354 buffer_excerpts
2355 }) else {
2356 return;
2357 };
2358
2359 let Some((
2360 new_collapsed_entries,
2361 new_unfolded_dirs,
2362 new_fs_entries,
2363 new_depth_map,
2364 new_children_count,
2365 )) = cx
2366 .background_executor()
2367 .spawn(async move {
2368 let mut processed_external_buffers = HashSet::default();
2369 let mut new_worktree_entries = HashMap::<
2370 WorktreeId,
2371 (worktree::Snapshot, HashMap<ProjectEntryId, Entry>),
2372 >::default();
2373 let mut worktree_excerpts = HashMap::<
2374 WorktreeId,
2375 HashMap<ProjectEntryId, (BufferId, Vec<ExcerptId>)>,
2376 >::default();
2377 let mut external_excerpts = HashMap::default();
2378
2379 for (buffer_id, (is_new, is_folded, excerpts, entry_id, worktree)) in
2380 buffer_excerpts
2381 {
2382 if is_folded {
2383 match &worktree {
2384 Some(worktree) => {
2385 new_collapsed_entries
2386 .insert(CollapsedEntry::File(worktree.id(), buffer_id));
2387 }
2388 None => {
2389 new_collapsed_entries
2390 .insert(CollapsedEntry::ExternalFile(buffer_id));
2391 }
2392 }
2393 } else if is_new {
2394 match &worktree {
2395 Some(worktree) => {
2396 new_collapsed_entries
2397 .remove(&CollapsedEntry::File(worktree.id(), buffer_id));
2398 }
2399 None => {
2400 new_collapsed_entries
2401 .remove(&CollapsedEntry::ExternalFile(buffer_id));
2402 }
2403 }
2404 }
2405
2406 if let Some(worktree) = worktree {
2407 let worktree_id = worktree.id();
2408 let unfolded_dirs = new_unfolded_dirs.entry(worktree_id).or_default();
2409
2410 match entry_id.and_then(|id| worktree.entry_for_id(id)).cloned() {
2411 Some(entry) => {
2412 let mut traversal = worktree.traverse_from_path(
2413 true,
2414 true,
2415 true,
2416 entry.path.as_ref(),
2417 );
2418
2419 let mut entries_to_add = HashMap::default();
2420 worktree_excerpts
2421 .entry(worktree_id)
2422 .or_default()
2423 .insert(entry.id, (buffer_id, excerpts));
2424 let mut current_entry = entry;
2425 loop {
2426 if current_entry.is_dir() {
2427 let is_root =
2428 worktree.root_entry().map(|entry| entry.id)
2429 == Some(current_entry.id);
2430 if is_root {
2431 root_entries.insert(current_entry.id);
2432 if auto_fold_dirs {
2433 unfolded_dirs.insert(current_entry.id);
2434 }
2435 }
2436 if is_new {
2437 new_collapsed_entries.remove(&CollapsedEntry::Dir(
2438 worktree_id,
2439 current_entry.id,
2440 ));
2441 }
2442 }
2443
2444 let new_entry_added = entries_to_add
2445 .insert(current_entry.id, current_entry)
2446 .is_none();
2447 if new_entry_added && traversal.back_to_parent() {
2448 if let Some(parent_entry) = traversal.entry() {
2449 current_entry = parent_entry.clone();
2450 continue;
2451 }
2452 }
2453 break;
2454 }
2455 new_worktree_entries
2456 .entry(worktree_id)
2457 .or_insert_with(|| (worktree.clone(), HashMap::default()))
2458 .1
2459 .extend(entries_to_add);
2460 }
2461 None => {
2462 if processed_external_buffers.insert(buffer_id) {
2463 external_excerpts
2464 .entry(buffer_id)
2465 .or_insert_with(Vec::new)
2466 .extend(excerpts);
2467 }
2468 }
2469 }
2470 } else if processed_external_buffers.insert(buffer_id) {
2471 external_excerpts
2472 .entry(buffer_id)
2473 .or_insert_with(Vec::new)
2474 .extend(excerpts);
2475 }
2476 }
2477
2478 let mut new_children_count =
2479 HashMap::<WorktreeId, HashMap<Arc<Path>, FsChildren>>::default();
2480
2481 let worktree_entries = new_worktree_entries
2482 .into_iter()
2483 .map(|(worktree_id, (worktree_snapshot, entries))| {
2484 let mut entries = entries.into_values().collect::<Vec<_>>();
2485 // For a proper git status propagation, we have to keep the entries sorted lexicographically.
2486 entries.sort_by(|a, b| a.path.as_ref().cmp(b.path.as_ref()));
2487 worktree_snapshot.propagate_git_statuses(&mut entries);
2488 (worktree_id, entries)
2489 })
2490 .flat_map(|(worktree_id, entries)| {
2491 {
2492 entries
2493 .into_iter()
2494 .filter_map(|entry| {
2495 if auto_fold_dirs {
2496 if let Some(parent) = entry.path.parent() {
2497 let children = new_children_count
2498 .entry(worktree_id)
2499 .or_default()
2500 .entry(Arc::from(parent))
2501 .or_default();
2502 if entry.is_dir() {
2503 children.dirs += 1;
2504 } else {
2505 children.files += 1;
2506 }
2507 }
2508 }
2509
2510 if entry.is_dir() {
2511 Some(FsEntry::Directory(worktree_id, entry))
2512 } else {
2513 let (buffer_id, excerpts) = worktree_excerpts
2514 .get_mut(&worktree_id)
2515 .and_then(|worktree_excerpts| {
2516 worktree_excerpts.remove(&entry.id)
2517 })?;
2518 Some(FsEntry::File(
2519 worktree_id,
2520 entry,
2521 buffer_id,
2522 excerpts,
2523 ))
2524 }
2525 })
2526 .collect::<Vec<_>>()
2527 }
2528 })
2529 .collect::<Vec<_>>();
2530
2531 let mut visited_dirs = Vec::new();
2532 let mut new_depth_map = HashMap::default();
2533 let new_visible_entries = external_excerpts
2534 .into_iter()
2535 .sorted_by_key(|(id, _)| *id)
2536 .map(|(buffer_id, excerpts)| FsEntry::ExternalFile(buffer_id, excerpts))
2537 .chain(worktree_entries)
2538 .filter(|visible_item| {
2539 match visible_item {
2540 FsEntry::Directory(worktree_id, dir_entry) => {
2541 let parent_id = back_to_common_visited_parent(
2542 &mut visited_dirs,
2543 worktree_id,
2544 dir_entry,
2545 );
2546
2547 let depth = if root_entries.contains(&dir_entry.id) {
2548 0
2549 } else {
2550 if auto_fold_dirs {
2551 let children = new_children_count
2552 .get(worktree_id)
2553 .and_then(|children_count| {
2554 children_count.get(&dir_entry.path)
2555 })
2556 .copied()
2557 .unwrap_or_default();
2558
2559 if !children.may_be_fold_part()
2560 || (children.dirs == 0
2561 && visited_dirs
2562 .last()
2563 .map(|(parent_dir_id, _)| {
2564 new_unfolded_dirs
2565 .get(worktree_id)
2566 .map_or(true, |unfolded_dirs| {
2567 unfolded_dirs
2568 .contains(parent_dir_id)
2569 })
2570 })
2571 .unwrap_or(true))
2572 {
2573 new_unfolded_dirs
2574 .entry(*worktree_id)
2575 .or_default()
2576 .insert(dir_entry.id);
2577 }
2578 }
2579
2580 parent_id
2581 .and_then(|(worktree_id, id)| {
2582 new_depth_map.get(&(worktree_id, id)).copied()
2583 })
2584 .unwrap_or(0)
2585 + 1
2586 };
2587 visited_dirs.push((dir_entry.id, dir_entry.path.clone()));
2588 new_depth_map.insert((*worktree_id, dir_entry.id), depth);
2589 }
2590 FsEntry::File(worktree_id, file_entry, ..) => {
2591 let parent_id = back_to_common_visited_parent(
2592 &mut visited_dirs,
2593 worktree_id,
2594 file_entry,
2595 );
2596 let depth = if root_entries.contains(&file_entry.id) {
2597 0
2598 } else {
2599 parent_id
2600 .and_then(|(worktree_id, id)| {
2601 new_depth_map.get(&(worktree_id, id)).copied()
2602 })
2603 .unwrap_or(0)
2604 + 1
2605 };
2606 new_depth_map.insert((*worktree_id, file_entry.id), depth);
2607 }
2608 FsEntry::ExternalFile(..) => {
2609 visited_dirs.clear();
2610 }
2611 }
2612
2613 true
2614 })
2615 .collect::<Vec<_>>();
2616
2617 anyhow::Ok((
2618 new_collapsed_entries,
2619 new_unfolded_dirs,
2620 new_visible_entries,
2621 new_depth_map,
2622 new_children_count,
2623 ))
2624 })
2625 .await
2626 .log_err()
2627 else {
2628 return;
2629 };
2630
2631 outline_panel
2632 .update(&mut cx, |outline_panel, cx| {
2633 outline_panel.updating_fs_entries = false;
2634 outline_panel.new_entries_for_fs_update.clear();
2635 outline_panel.excerpts = new_excerpts;
2636 outline_panel.collapsed_entries = new_collapsed_entries;
2637 outline_panel.unfolded_dirs = new_unfolded_dirs;
2638 outline_panel.fs_entries = new_fs_entries;
2639 outline_panel.fs_entries_depth = new_depth_map;
2640 outline_panel.fs_children_count = new_children_count;
2641 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), cx);
2642 outline_panel.update_non_fs_items(cx);
2643
2644 cx.notify();
2645 })
2646 .ok();
2647 });
2648 }
2649
2650 fn replace_active_editor(
2651 &mut self,
2652 new_active_item: Box<dyn ItemHandle>,
2653 new_active_editor: View<Editor>,
2654 cx: &mut ViewContext<Self>,
2655 ) {
2656 self.clear_previous(cx);
2657 let buffer_search_subscription = cx.subscribe(
2658 &new_active_editor,
2659 |outline_panel: &mut Self, _, e: &SearchEvent, cx: &mut ViewContext<'_, Self>| {
2660 if matches!(e, SearchEvent::MatchesInvalidated) {
2661 outline_panel.update_search_matches(cx);
2662 };
2663 outline_panel.autoscroll(cx);
2664 },
2665 );
2666 self.active_item = Some(ActiveItem {
2667 _buffer_search_subscription: buffer_search_subscription,
2668 _editor_subscrpiption: subscribe_for_editor_events(&new_active_editor, cx),
2669 item_handle: new_active_item.downgrade_item(),
2670 active_editor: new_active_editor.downgrade(),
2671 });
2672 self.new_entries_for_fs_update
2673 .extend(new_active_editor.read(cx).buffer().read(cx).excerpt_ids());
2674 self.selected_entry.invalidate();
2675 self.update_fs_entries(new_active_editor, None, cx);
2676 }
2677
2678 fn clear_previous(&mut self, cx: &mut WindowContext<'_>) {
2679 self.fs_entries_update_task = Task::ready(());
2680 self.outline_fetch_tasks.clear();
2681 self.cached_entries_update_task = Task::ready(());
2682 self.reveal_selection_task = Task::ready(Ok(()));
2683 self.filter_editor.update(cx, |editor, cx| editor.clear(cx));
2684 self.collapsed_entries.clear();
2685 self.unfolded_dirs.clear();
2686 self.active_item = None;
2687 self.fs_entries.clear();
2688 self.fs_entries_depth.clear();
2689 self.fs_children_count.clear();
2690 self.excerpts.clear();
2691 self.cached_entries = Vec::new();
2692 self.selected_entry = SelectedEntry::None;
2693 self.pinned = false;
2694 self.mode = ItemsDisplayMode::Outline;
2695 }
2696
2697 fn location_for_editor_selection(
2698 &self,
2699 editor: &View<Editor>,
2700 cx: &mut ViewContext<Self>,
2701 ) -> Option<PanelEntry> {
2702 let selection = editor.update(cx, |editor, cx| {
2703 editor.selections.newest::<language::Point>(cx).head()
2704 });
2705 let editor_snapshot = editor.update(cx, |editor, cx| editor.snapshot(cx));
2706 let multi_buffer = editor.read(cx).buffer();
2707 let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
2708 let (excerpt_id, buffer, _) = editor
2709 .read(cx)
2710 .buffer()
2711 .read(cx)
2712 .excerpt_containing(selection, cx)?;
2713 let buffer_id = buffer.read(cx).remote_id();
2714
2715 if editor.read(cx).buffer_folded(buffer_id, cx) {
2716 return self
2717 .fs_entries
2718 .iter()
2719 .find(|fs_entry| match fs_entry {
2720 FsEntry::Directory(..) => false,
2721 FsEntry::File(_, _, file_buffer_id, _)
2722 | FsEntry::ExternalFile(file_buffer_id, _) => *file_buffer_id == buffer_id,
2723 })
2724 .cloned()
2725 .map(PanelEntry::Fs);
2726 }
2727
2728 let selection_display_point = selection.to_display_point(&editor_snapshot);
2729
2730 match &self.mode {
2731 ItemsDisplayMode::Search(search_state) => search_state
2732 .matches
2733 .iter()
2734 .rev()
2735 .min_by_key(|&(match_range, _)| {
2736 let match_display_range =
2737 match_range.clone().to_display_points(&editor_snapshot);
2738 let start_distance = if selection_display_point < match_display_range.start {
2739 match_display_range.start - selection_display_point
2740 } else {
2741 selection_display_point - match_display_range.start
2742 };
2743 let end_distance = if selection_display_point < match_display_range.end {
2744 match_display_range.end - selection_display_point
2745 } else {
2746 selection_display_point - match_display_range.end
2747 };
2748 start_distance + end_distance
2749 })
2750 .and_then(|(closest_range, _)| {
2751 self.cached_entries.iter().find_map(|cached_entry| {
2752 if let PanelEntry::Search(SearchEntry { match_range, .. }) =
2753 &cached_entry.entry
2754 {
2755 if match_range == closest_range {
2756 Some(cached_entry.entry.clone())
2757 } else {
2758 None
2759 }
2760 } else {
2761 None
2762 }
2763 })
2764 }),
2765 ItemsDisplayMode::Outline => self.outline_location(
2766 buffer_id,
2767 excerpt_id,
2768 multi_buffer_snapshot,
2769 editor_snapshot,
2770 selection_display_point,
2771 ),
2772 }
2773 }
2774
2775 fn outline_location(
2776 &self,
2777 buffer_id: BufferId,
2778 excerpt_id: ExcerptId,
2779 multi_buffer_snapshot: editor::MultiBufferSnapshot,
2780 editor_snapshot: editor::EditorSnapshot,
2781 selection_display_point: DisplayPoint,
2782 ) -> Option<PanelEntry> {
2783 let excerpt_outlines = self
2784 .excerpts
2785 .get(&buffer_id)
2786 .and_then(|excerpts| excerpts.get(&excerpt_id))
2787 .into_iter()
2788 .flat_map(|excerpt| excerpt.iter_outlines())
2789 .flat_map(|outline| {
2790 let start = multi_buffer_snapshot
2791 .anchor_in_excerpt(excerpt_id, outline.range.start)?
2792 .to_display_point(&editor_snapshot);
2793 let end = multi_buffer_snapshot
2794 .anchor_in_excerpt(excerpt_id, outline.range.end)?
2795 .to_display_point(&editor_snapshot);
2796 Some((start..end, outline))
2797 })
2798 .collect::<Vec<_>>();
2799
2800 let mut matching_outline_indices = Vec::new();
2801 let mut children = HashMap::default();
2802 let mut parents_stack = Vec::<(&Range<DisplayPoint>, &&Outline, usize)>::new();
2803
2804 for (i, (outline_range, outline)) in excerpt_outlines.iter().enumerate() {
2805 if outline_range
2806 .to_inclusive()
2807 .contains(&selection_display_point)
2808 {
2809 matching_outline_indices.push(i);
2810 } else if (outline_range.start.row()..outline_range.end.row())
2811 .to_inclusive()
2812 .contains(&selection_display_point.row())
2813 {
2814 matching_outline_indices.push(i);
2815 }
2816
2817 while let Some((parent_range, parent_outline, _)) = parents_stack.last() {
2818 if parent_outline.depth >= outline.depth
2819 || !parent_range.contains(&outline_range.start)
2820 {
2821 parents_stack.pop();
2822 } else {
2823 break;
2824 }
2825 }
2826 if let Some((_, _, parent_index)) = parents_stack.last_mut() {
2827 children
2828 .entry(*parent_index)
2829 .or_insert_with(Vec::new)
2830 .push(i);
2831 }
2832 parents_stack.push((outline_range, outline, i));
2833 }
2834
2835 let outline_item = matching_outline_indices
2836 .into_iter()
2837 .flat_map(|i| Some((i, excerpt_outlines.get(i)?)))
2838 .filter(|(i, _)| {
2839 children
2840 .get(i)
2841 .map(|children| {
2842 children.iter().all(|child_index| {
2843 excerpt_outlines
2844 .get(*child_index)
2845 .map(|(child_range, _)| child_range.start > selection_display_point)
2846 .unwrap_or(false)
2847 })
2848 })
2849 .unwrap_or(true)
2850 })
2851 .min_by_key(|(_, (outline_range, outline))| {
2852 let distance_from_start = if outline_range.start > selection_display_point {
2853 outline_range.start - selection_display_point
2854 } else {
2855 selection_display_point - outline_range.start
2856 };
2857 let distance_from_end = if outline_range.end > selection_display_point {
2858 outline_range.end - selection_display_point
2859 } else {
2860 selection_display_point - outline_range.end
2861 };
2862
2863 (
2864 cmp::Reverse(outline.depth),
2865 distance_from_start + distance_from_end,
2866 )
2867 })
2868 .map(|(_, (_, outline))| *outline)
2869 .cloned();
2870
2871 let closest_container = match outline_item {
2872 Some(outline) => {
2873 PanelEntry::Outline(OutlineEntry::Outline(buffer_id, excerpt_id, outline))
2874 }
2875 None => {
2876 self.cached_entries.iter().rev().find_map(|cached_entry| {
2877 match &cached_entry.entry {
2878 PanelEntry::Outline(OutlineEntry::Excerpt(
2879 entry_buffer_id,
2880 entry_excerpt_id,
2881 _,
2882 )) => {
2883 if entry_buffer_id == &buffer_id && entry_excerpt_id == &excerpt_id {
2884 Some(cached_entry.entry.clone())
2885 } else {
2886 None
2887 }
2888 }
2889 PanelEntry::Fs(
2890 FsEntry::ExternalFile(file_buffer_id, file_excerpts)
2891 | FsEntry::File(_, _, file_buffer_id, file_excerpts),
2892 ) => {
2893 if file_buffer_id == &buffer_id && file_excerpts.contains(&excerpt_id) {
2894 Some(cached_entry.entry.clone())
2895 } else {
2896 None
2897 }
2898 }
2899 _ => None,
2900 }
2901 })?
2902 }
2903 };
2904 Some(closest_container)
2905 }
2906
2907 fn fetch_outdated_outlines(&mut self, cx: &mut ViewContext<Self>) {
2908 let excerpt_fetch_ranges = self.excerpt_fetch_ranges(cx);
2909 if excerpt_fetch_ranges.is_empty() {
2910 return;
2911 }
2912
2913 let syntax_theme = cx.theme().syntax().clone();
2914 for (buffer_id, (buffer_snapshot, excerpt_ranges)) in excerpt_fetch_ranges {
2915 for (excerpt_id, excerpt_range) in excerpt_ranges {
2916 let syntax_theme = syntax_theme.clone();
2917 let buffer_snapshot = buffer_snapshot.clone();
2918 self.outline_fetch_tasks.insert(
2919 (buffer_id, excerpt_id),
2920 cx.spawn(|outline_panel, mut cx| async move {
2921 let fetched_outlines = cx
2922 .background_executor()
2923 .spawn(async move {
2924 buffer_snapshot
2925 .outline_items_containing(
2926 excerpt_range.context,
2927 false,
2928 Some(&syntax_theme),
2929 )
2930 .unwrap_or_default()
2931 })
2932 .await;
2933 outline_panel
2934 .update(&mut cx, |outline_panel, cx| {
2935 if let Some(excerpt) = outline_panel
2936 .excerpts
2937 .entry(buffer_id)
2938 .or_default()
2939 .get_mut(&excerpt_id)
2940 {
2941 excerpt.outlines = ExcerptOutlines::Outlines(fetched_outlines);
2942 }
2943 outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), cx);
2944 })
2945 .ok();
2946 }),
2947 );
2948 }
2949 }
2950 }
2951
2952 fn is_singleton_active(&self, cx: &AppContext) -> bool {
2953 self.active_editor().map_or(false, |active_editor| {
2954 active_editor.read(cx).buffer().read(cx).is_singleton()
2955 })
2956 }
2957
2958 fn invalidate_outlines(&mut self, ids: &[ExcerptId]) {
2959 self.outline_fetch_tasks.clear();
2960 let mut ids = ids.iter().collect::<HashSet<_>>();
2961 for excerpts in self.excerpts.values_mut() {
2962 ids.retain(|id| {
2963 if let Some(excerpt) = excerpts.get_mut(id) {
2964 excerpt.invalidate_outlines();
2965 false
2966 } else {
2967 true
2968 }
2969 });
2970 if ids.is_empty() {
2971 break;
2972 }
2973 }
2974 }
2975
2976 fn excerpt_fetch_ranges(
2977 &self,
2978 cx: &AppContext,
2979 ) -> HashMap<
2980 BufferId,
2981 (
2982 BufferSnapshot,
2983 HashMap<ExcerptId, ExcerptRange<language::Anchor>>,
2984 ),
2985 > {
2986 self.fs_entries
2987 .iter()
2988 .fold(HashMap::default(), |mut excerpts_to_fetch, fs_entry| {
2989 match fs_entry {
2990 FsEntry::File(_, _, buffer_id, file_excerpts)
2991 | FsEntry::ExternalFile(buffer_id, file_excerpts) => {
2992 let excerpts = self.excerpts.get(buffer_id);
2993 for &file_excerpt in file_excerpts {
2994 if let Some(excerpt) = excerpts
2995 .and_then(|excerpts| excerpts.get(&file_excerpt))
2996 .filter(|excerpt| excerpt.should_fetch_outlines())
2997 {
2998 match excerpts_to_fetch.entry(*buffer_id) {
2999 hash_map::Entry::Occupied(mut o) => {
3000 o.get_mut().1.insert(file_excerpt, excerpt.range.clone());
3001 }
3002 hash_map::Entry::Vacant(v) => {
3003 if let Some(buffer_snapshot) =
3004 self.buffer_snapshot_for_id(*buffer_id, cx)
3005 {
3006 v.insert((buffer_snapshot, HashMap::default()))
3007 .1
3008 .insert(file_excerpt, excerpt.range.clone());
3009 }
3010 }
3011 }
3012 }
3013 }
3014 }
3015 FsEntry::Directory(..) => {}
3016 }
3017 excerpts_to_fetch
3018 })
3019 }
3020
3021 fn buffer_snapshot_for_id(
3022 &self,
3023 buffer_id: BufferId,
3024 cx: &AppContext,
3025 ) -> Option<BufferSnapshot> {
3026 let editor = self.active_editor()?;
3027 Some(
3028 editor
3029 .read(cx)
3030 .buffer()
3031 .read(cx)
3032 .buffer(buffer_id)?
3033 .read(cx)
3034 .snapshot(),
3035 )
3036 }
3037
3038 fn abs_path(&self, entry: &PanelEntry, cx: &AppContext) -> Option<PathBuf> {
3039 match entry {
3040 PanelEntry::Fs(
3041 FsEntry::File(_, _, buffer_id, _) | FsEntry::ExternalFile(buffer_id, _),
3042 ) => self
3043 .buffer_snapshot_for_id(*buffer_id, cx)
3044 .and_then(|buffer_snapshot| {
3045 let file = File::from_dyn(buffer_snapshot.file())?;
3046 file.worktree.read(cx).absolutize(&file.path).ok()
3047 }),
3048 PanelEntry::Fs(FsEntry::Directory(worktree_id, entry)) => self
3049 .project
3050 .read(cx)
3051 .worktree_for_id(*worktree_id, cx)?
3052 .read(cx)
3053 .absolutize(&entry.path)
3054 .ok(),
3055 PanelEntry::FoldedDirs(worktree_id, dirs) => dirs.last().and_then(|entry| {
3056 self.project
3057 .read(cx)
3058 .worktree_for_id(*worktree_id, cx)
3059 .and_then(|worktree| worktree.read(cx).absolutize(&entry.path).ok())
3060 }),
3061 PanelEntry::Search(_) | PanelEntry::Outline(..) => None,
3062 }
3063 }
3064
3065 fn relative_path(&self, entry: &FsEntry, cx: &AppContext) -> Option<Arc<Path>> {
3066 match entry {
3067 FsEntry::ExternalFile(buffer_id, _) => {
3068 let buffer_snapshot = self.buffer_snapshot_for_id(*buffer_id, cx)?;
3069 Some(buffer_snapshot.file()?.path().clone())
3070 }
3071 FsEntry::Directory(_, entry) => Some(entry.path.clone()),
3072 FsEntry::File(_, entry, ..) => Some(entry.path.clone()),
3073 }
3074 }
3075
3076 fn update_cached_entries(
3077 &mut self,
3078 debounce: Option<Duration>,
3079 cx: &mut ViewContext<OutlinePanel>,
3080 ) {
3081 if !self.active {
3082 return;
3083 }
3084
3085 let is_singleton = self.is_singleton_active(cx);
3086 let query = self.query(cx);
3087 self.cached_entries_update_task = cx.spawn(|outline_panel, mut cx| async move {
3088 if let Some(debounce) = debounce {
3089 cx.background_executor().timer(debounce).await;
3090 }
3091 let Some(new_cached_entries) = outline_panel
3092 .update(&mut cx, |outline_panel, cx| {
3093 outline_panel.generate_cached_entries(is_singleton, query, cx)
3094 })
3095 .ok()
3096 else {
3097 return;
3098 };
3099 let (new_cached_entries, max_width_item_index) = new_cached_entries.await;
3100 outline_panel
3101 .update(&mut cx, |outline_panel, cx| {
3102 outline_panel.cached_entries = new_cached_entries;
3103 outline_panel.max_width_item_index = max_width_item_index;
3104 if outline_panel.selected_entry.is_invalidated()
3105 || matches!(outline_panel.selected_entry, SelectedEntry::None)
3106 {
3107 if let Some(new_selected_entry) =
3108 outline_panel.active_editor().and_then(|active_editor| {
3109 outline_panel.location_for_editor_selection(&active_editor, cx)
3110 })
3111 {
3112 outline_panel.select_entry(new_selected_entry, false, cx);
3113 }
3114 }
3115
3116 outline_panel.autoscroll(cx);
3117 cx.notify();
3118 })
3119 .ok();
3120 });
3121 }
3122
3123 fn generate_cached_entries(
3124 &self,
3125 is_singleton: bool,
3126 query: Option<String>,
3127 cx: &mut ViewContext<'_, Self>,
3128 ) -> Task<(Vec<CachedEntry>, Option<usize>)> {
3129 let project = self.project.clone();
3130 let Some(active_editor) = self.active_editor() else {
3131 return Task::ready((Vec::new(), None));
3132 };
3133 cx.spawn(|outline_panel, mut cx| async move {
3134 let mut generation_state = GenerationState::default();
3135
3136 let Ok(()) = outline_panel.update(&mut cx, |outline_panel, cx| {
3137 let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
3138 let mut folded_dirs_entry = None::<(usize, WorktreeId, Vec<Entry>)>;
3139 let track_matches = query.is_some();
3140
3141 #[derive(Debug)]
3142 struct ParentStats {
3143 path: Arc<Path>,
3144 folded: bool,
3145 expanded: bool,
3146 depth: usize,
3147 }
3148 let mut parent_dirs = Vec::<ParentStats>::new();
3149 for entry in outline_panel.fs_entries.clone() {
3150 let is_expanded = outline_panel.is_expanded(&entry);
3151 let (depth, should_add) = match &entry {
3152 FsEntry::Directory(worktree_id, dir_entry) => {
3153 let mut should_add = true;
3154 let is_root = project
3155 .read(cx)
3156 .worktree_for_id(*worktree_id, cx)
3157 .map_or(false, |worktree| {
3158 worktree.read(cx).root_entry() == Some(dir_entry)
3159 });
3160 let folded = auto_fold_dirs
3161 && !is_root
3162 && outline_panel
3163 .unfolded_dirs
3164 .get(worktree_id)
3165 .map_or(true, |unfolded_dirs| {
3166 !unfolded_dirs.contains(&dir_entry.id)
3167 });
3168 let fs_depth = outline_panel
3169 .fs_entries_depth
3170 .get(&(*worktree_id, dir_entry.id))
3171 .copied()
3172 .unwrap_or(0);
3173 while let Some(parent) = parent_dirs.last() {
3174 if dir_entry.path.starts_with(&parent.path) {
3175 break;
3176 }
3177 parent_dirs.pop();
3178 }
3179 let auto_fold = match parent_dirs.last() {
3180 Some(parent) => {
3181 parent.folded
3182 && Some(parent.path.as_ref()) == dir_entry.path.parent()
3183 && outline_panel
3184 .fs_children_count
3185 .get(worktree_id)
3186 .and_then(|entries| entries.get(&dir_entry.path))
3187 .copied()
3188 .unwrap_or_default()
3189 .may_be_fold_part()
3190 }
3191 None => false,
3192 };
3193 let folded = folded || auto_fold;
3194 let (depth, parent_expanded, parent_folded) = match parent_dirs.last() {
3195 Some(parent) => {
3196 let parent_folded = parent.folded;
3197 let parent_expanded = parent.expanded;
3198 let new_depth = if parent_folded {
3199 parent.depth
3200 } else {
3201 parent.depth + 1
3202 };
3203 parent_dirs.push(ParentStats {
3204 path: dir_entry.path.clone(),
3205 folded,
3206 expanded: parent_expanded && is_expanded,
3207 depth: new_depth,
3208 });
3209 (new_depth, parent_expanded, parent_folded)
3210 }
3211 None => {
3212 parent_dirs.push(ParentStats {
3213 path: dir_entry.path.clone(),
3214 folded,
3215 expanded: is_expanded,
3216 depth: fs_depth,
3217 });
3218 (fs_depth, true, false)
3219 }
3220 };
3221
3222 if let Some((folded_depth, folded_worktree_id, mut folded_dirs)) =
3223 folded_dirs_entry.take()
3224 {
3225 if folded
3226 && worktree_id == &folded_worktree_id
3227 && dir_entry.path.parent()
3228 == folded_dirs.last().map(|entry| entry.path.as_ref())
3229 {
3230 folded_dirs.push(dir_entry.clone());
3231 folded_dirs_entry =
3232 Some((folded_depth, folded_worktree_id, folded_dirs))
3233 } else {
3234 if !is_singleton {
3235 let start_of_collapsed_dir_sequence = !parent_expanded
3236 && parent_dirs
3237 .iter()
3238 .rev()
3239 .nth(folded_dirs.len() + 1)
3240 .map_or(true, |parent| parent.expanded);
3241 if start_of_collapsed_dir_sequence
3242 || parent_expanded
3243 || query.is_some()
3244 {
3245 if parent_folded {
3246 folded_dirs.push(dir_entry.clone());
3247 should_add = false;
3248 }
3249 let new_folded_dirs = PanelEntry::FoldedDirs(
3250 folded_worktree_id,
3251 folded_dirs,
3252 );
3253 outline_panel.push_entry(
3254 &mut generation_state,
3255 track_matches,
3256 new_folded_dirs,
3257 folded_depth,
3258 cx,
3259 );
3260 }
3261 }
3262
3263 folded_dirs_entry = if parent_folded {
3264 None
3265 } else {
3266 Some((depth, *worktree_id, vec![dir_entry.clone()]))
3267 };
3268 }
3269 } else if folded {
3270 folded_dirs_entry =
3271 Some((depth, *worktree_id, vec![dir_entry.clone()]));
3272 }
3273
3274 let should_add =
3275 should_add && parent_expanded && folded_dirs_entry.is_none();
3276 (depth, should_add)
3277 }
3278 FsEntry::ExternalFile(..) => {
3279 if let Some((folded_depth, worktree_id, folded_dirs)) =
3280 folded_dirs_entry.take()
3281 {
3282 let parent_expanded = parent_dirs
3283 .iter()
3284 .rev()
3285 .find(|parent| {
3286 folded_dirs.iter().all(|entry| entry.path != parent.path)
3287 })
3288 .map_or(true, |parent| parent.expanded);
3289 if !is_singleton && (parent_expanded || query.is_some()) {
3290 outline_panel.push_entry(
3291 &mut generation_state,
3292 track_matches,
3293 PanelEntry::FoldedDirs(worktree_id, folded_dirs),
3294 folded_depth,
3295 cx,
3296 );
3297 }
3298 }
3299 parent_dirs.clear();
3300 (0, true)
3301 }
3302 FsEntry::File(worktree_id, file_entry, ..) => {
3303 if let Some((folded_depth, worktree_id, folded_dirs)) =
3304 folded_dirs_entry.take()
3305 {
3306 let parent_expanded = parent_dirs
3307 .iter()
3308 .rev()
3309 .find(|parent| {
3310 folded_dirs.iter().all(|entry| entry.path != parent.path)
3311 })
3312 .map_or(true, |parent| parent.expanded);
3313 if !is_singleton && (parent_expanded || query.is_some()) {
3314 outline_panel.push_entry(
3315 &mut generation_state,
3316 track_matches,
3317 PanelEntry::FoldedDirs(worktree_id, folded_dirs),
3318 folded_depth,
3319 cx,
3320 );
3321 }
3322 }
3323
3324 let fs_depth = outline_panel
3325 .fs_entries_depth
3326 .get(&(*worktree_id, file_entry.id))
3327 .copied()
3328 .unwrap_or(0);
3329 while let Some(parent) = parent_dirs.last() {
3330 if file_entry.path.starts_with(&parent.path) {
3331 break;
3332 }
3333 parent_dirs.pop();
3334 }
3335 let (depth, should_add) = match parent_dirs.last() {
3336 Some(parent) => {
3337 let new_depth = parent.depth + 1;
3338 (new_depth, parent.expanded)
3339 }
3340 None => (fs_depth, true),
3341 };
3342 (depth, should_add)
3343 }
3344 };
3345
3346 if !is_singleton
3347 && (should_add || (query.is_some() && folded_dirs_entry.is_none()))
3348 {
3349 outline_panel.push_entry(
3350 &mut generation_state,
3351 track_matches,
3352 PanelEntry::Fs(entry.clone()),
3353 depth,
3354 cx,
3355 );
3356 }
3357
3358 match outline_panel.mode {
3359 ItemsDisplayMode::Search(_) => {
3360 if is_singleton || query.is_some() || (should_add && is_expanded) {
3361 outline_panel.add_search_entries(
3362 &mut generation_state,
3363 &active_editor,
3364 entry.clone(),
3365 depth,
3366 query.clone(),
3367 is_singleton,
3368 cx,
3369 );
3370 }
3371 }
3372 ItemsDisplayMode::Outline => {
3373 let excerpts_to_consider =
3374 if is_singleton || query.is_some() || (should_add && is_expanded) {
3375 match &entry {
3376 FsEntry::File(_, _, buffer_id, entry_excerpts) => {
3377 Some((*buffer_id, entry_excerpts))
3378 }
3379 FsEntry::ExternalFile(buffer_id, entry_excerpts) => {
3380 Some((*buffer_id, entry_excerpts))
3381 }
3382 _ => None,
3383 }
3384 } else {
3385 None
3386 };
3387 if let Some((buffer_id, entry_excerpts)) = excerpts_to_consider {
3388 if !active_editor.read(cx).buffer_folded(buffer_id, cx) {
3389 outline_panel.add_excerpt_entries(
3390 &mut generation_state,
3391 buffer_id,
3392 entry_excerpts,
3393 depth,
3394 track_matches,
3395 is_singleton,
3396 query.as_deref(),
3397 cx,
3398 );
3399 }
3400 }
3401 }
3402 }
3403
3404 if is_singleton
3405 && matches!(entry, FsEntry::File(..) | FsEntry::ExternalFile(..))
3406 && !generation_state.entries.iter().any(|item| {
3407 matches!(item.entry, PanelEntry::Outline(..) | PanelEntry::Search(_))
3408 })
3409 {
3410 outline_panel.push_entry(
3411 &mut generation_state,
3412 track_matches,
3413 PanelEntry::Fs(entry.clone()),
3414 0,
3415 cx,
3416 );
3417 }
3418 }
3419
3420 if let Some((folded_depth, worktree_id, folded_dirs)) = folded_dirs_entry.take() {
3421 let parent_expanded = parent_dirs
3422 .iter()
3423 .rev()
3424 .find(|parent| folded_dirs.iter().all(|entry| entry.path != parent.path))
3425 .map_or(true, |parent| parent.expanded);
3426 if parent_expanded || query.is_some() {
3427 outline_panel.push_entry(
3428 &mut generation_state,
3429 track_matches,
3430 PanelEntry::FoldedDirs(worktree_id, folded_dirs),
3431 folded_depth,
3432 cx,
3433 );
3434 }
3435 }
3436 }) else {
3437 return (Vec::new(), None);
3438 };
3439
3440 let Some(query) = query else {
3441 return (
3442 generation_state.entries,
3443 generation_state
3444 .max_width_estimate_and_index
3445 .map(|(_, index)| index),
3446 );
3447 };
3448
3449 let mut matched_ids = match_strings(
3450 &generation_state.match_candidates,
3451 &query,
3452 true,
3453 usize::MAX,
3454 &AtomicBool::default(),
3455 cx.background_executor().clone(),
3456 )
3457 .await
3458 .into_iter()
3459 .map(|string_match| (string_match.candidate_id, string_match))
3460 .collect::<HashMap<_, _>>();
3461
3462 let mut id = 0;
3463 generation_state.entries.retain_mut(|cached_entry| {
3464 let retain = match matched_ids.remove(&id) {
3465 Some(string_match) => {
3466 cached_entry.string_match = Some(string_match);
3467 true
3468 }
3469 None => false,
3470 };
3471 id += 1;
3472 retain
3473 });
3474
3475 (
3476 generation_state.entries,
3477 generation_state
3478 .max_width_estimate_and_index
3479 .map(|(_, index)| index),
3480 )
3481 })
3482 }
3483
3484 #[allow(clippy::too_many_arguments)]
3485 fn push_entry(
3486 &self,
3487 state: &mut GenerationState,
3488 track_matches: bool,
3489 entry: PanelEntry,
3490 depth: usize,
3491 cx: &mut WindowContext,
3492 ) {
3493 let entry = if let PanelEntry::FoldedDirs(worktree_id, entries) = &entry {
3494 match entries.len() {
3495 0 => {
3496 debug_panic!("Empty folded dirs receiver");
3497 return;
3498 }
3499 1 => PanelEntry::Fs(FsEntry::Directory(*worktree_id, entries[0].clone())),
3500 _ => entry,
3501 }
3502 } else {
3503 entry
3504 };
3505
3506 if track_matches {
3507 let id = state.entries.len();
3508 match &entry {
3509 PanelEntry::Fs(fs_entry) => {
3510 if let Some(file_name) =
3511 self.relative_path(fs_entry, cx).as_deref().map(file_name)
3512 {
3513 state
3514 .match_candidates
3515 .push(StringMatchCandidate::new(id, &file_name));
3516 }
3517 }
3518 PanelEntry::FoldedDirs(worktree_id, entries) => {
3519 let dir_names = self.dir_names_string(entries, *worktree_id, cx);
3520 {
3521 state
3522 .match_candidates
3523 .push(StringMatchCandidate::new(id, &dir_names));
3524 }
3525 }
3526 PanelEntry::Outline(outline_entry) => match outline_entry {
3527 OutlineEntry::Outline(_, _, outline) => {
3528 state
3529 .match_candidates
3530 .push(StringMatchCandidate::new(id, &outline.text));
3531 }
3532 OutlineEntry::Excerpt(..) => {}
3533 },
3534 PanelEntry::Search(new_search_entry) => {
3535 if let Some(search_data) = new_search_entry.render_data.get() {
3536 state
3537 .match_candidates
3538 .push(StringMatchCandidate::new(id, &search_data.context_text));
3539 }
3540 }
3541 }
3542 }
3543
3544 let width_estimate = self.width_estimate(depth, &entry, cx);
3545 if Some(width_estimate)
3546 > state
3547 .max_width_estimate_and_index
3548 .map(|(estimate, _)| estimate)
3549 {
3550 state.max_width_estimate_and_index = Some((width_estimate, state.entries.len()));
3551 }
3552 state.entries.push(CachedEntry {
3553 depth,
3554 entry,
3555 string_match: None,
3556 });
3557 }
3558
3559 fn dir_names_string(
3560 &self,
3561 entries: &[Entry],
3562 worktree_id: WorktreeId,
3563 cx: &AppContext,
3564 ) -> String {
3565 let dir_names_segment = entries
3566 .iter()
3567 .map(|entry| self.entry_name(&worktree_id, entry, cx))
3568 .collect::<PathBuf>();
3569 dir_names_segment.to_string_lossy().to_string()
3570 }
3571
3572 fn query(&self, cx: &AppContext) -> Option<String> {
3573 let query = self.filter_editor.read(cx).text(cx);
3574 if query.trim().is_empty() {
3575 None
3576 } else {
3577 Some(query)
3578 }
3579 }
3580
3581 fn is_expanded(&self, entry: &FsEntry) -> bool {
3582 let entry_to_check = match entry {
3583 FsEntry::ExternalFile(buffer_id, _) => CollapsedEntry::ExternalFile(*buffer_id),
3584 FsEntry::File(worktree_id, _, buffer_id, _) => {
3585 CollapsedEntry::File(*worktree_id, *buffer_id)
3586 }
3587 FsEntry::Directory(worktree_id, entry) => CollapsedEntry::Dir(*worktree_id, entry.id),
3588 };
3589 !self.collapsed_entries.contains(&entry_to_check)
3590 }
3591
3592 fn update_non_fs_items(&mut self, cx: &mut ViewContext<OutlinePanel>) {
3593 if !self.active {
3594 return;
3595 }
3596
3597 self.update_search_matches(cx);
3598 self.fetch_outdated_outlines(cx);
3599 self.autoscroll(cx);
3600 }
3601
3602 fn update_search_matches(&mut self, cx: &mut ViewContext<OutlinePanel>) {
3603 if !self.active {
3604 return;
3605 }
3606
3607 let project_search = self
3608 .active_item()
3609 .and_then(|item| item.downcast::<ProjectSearchView>());
3610 let project_search_matches = project_search
3611 .as_ref()
3612 .map(|project_search| project_search.read(cx).get_matches(cx))
3613 .unwrap_or_default();
3614
3615 let buffer_search = self
3616 .active_item()
3617 .as_deref()
3618 .and_then(|active_item| {
3619 self.workspace
3620 .upgrade()
3621 .and_then(|workspace| workspace.read(cx).pane_for(active_item))
3622 })
3623 .and_then(|pane| {
3624 pane.read(cx)
3625 .toolbar()
3626 .read(cx)
3627 .item_of_type::<BufferSearchBar>()
3628 });
3629 let buffer_search_matches = self
3630 .active_editor()
3631 .map(|active_editor| active_editor.update(cx, |editor, cx| editor.get_matches(cx)))
3632 .unwrap_or_default();
3633
3634 let mut update_cached_entries = false;
3635 if buffer_search_matches.is_empty() && project_search_matches.is_empty() {
3636 if matches!(self.mode, ItemsDisplayMode::Search(_)) {
3637 self.mode = ItemsDisplayMode::Outline;
3638 update_cached_entries = true;
3639 }
3640 } else {
3641 let (kind, new_search_matches, new_search_query) = if buffer_search_matches.is_empty() {
3642 (
3643 SearchKind::Project,
3644 project_search_matches,
3645 project_search
3646 .map(|project_search| project_search.read(cx).search_query_text(cx))
3647 .unwrap_or_default(),
3648 )
3649 } else {
3650 (
3651 SearchKind::Buffer,
3652 buffer_search_matches,
3653 buffer_search
3654 .map(|buffer_search| buffer_search.read(cx).query(cx))
3655 .unwrap_or_default(),
3656 )
3657 };
3658
3659 let mut previous_matches = HashMap::default();
3660 update_cached_entries = match &mut self.mode {
3661 ItemsDisplayMode::Search(current_search_state) => {
3662 let update = current_search_state.query != new_search_query
3663 || current_search_state.kind != kind
3664 || current_search_state.matches.is_empty()
3665 || current_search_state.matches.iter().enumerate().any(
3666 |(i, (match_range, _))| new_search_matches.get(i) != Some(match_range),
3667 );
3668 if current_search_state.kind == kind {
3669 previous_matches.extend(current_search_state.matches.drain(..));
3670 }
3671 update
3672 }
3673 ItemsDisplayMode::Outline => true,
3674 };
3675 self.mode = ItemsDisplayMode::Search(SearchState::new(
3676 kind,
3677 new_search_query,
3678 previous_matches,
3679 new_search_matches,
3680 cx.theme().syntax().clone(),
3681 cx,
3682 ));
3683 }
3684 if update_cached_entries {
3685 self.selected_entry.invalidate();
3686 self.update_cached_entries(Some(UPDATE_DEBOUNCE), cx);
3687 }
3688 }
3689
3690 #[allow(clippy::too_many_arguments)]
3691 fn add_excerpt_entries(
3692 &self,
3693 state: &mut GenerationState,
3694 buffer_id: BufferId,
3695 entries_to_add: &[ExcerptId],
3696 parent_depth: usize,
3697 track_matches: bool,
3698 is_singleton: bool,
3699 query: Option<&str>,
3700 cx: &mut ViewContext<Self>,
3701 ) {
3702 if let Some(excerpts) = self.excerpts.get(&buffer_id) {
3703 for &excerpt_id in entries_to_add {
3704 let Some(excerpt) = excerpts.get(&excerpt_id) else {
3705 continue;
3706 };
3707 let excerpt_depth = parent_depth + 1;
3708 self.push_entry(
3709 state,
3710 track_matches,
3711 PanelEntry::Outline(OutlineEntry::Excerpt(
3712 buffer_id,
3713 excerpt_id,
3714 excerpt.range.clone(),
3715 )),
3716 excerpt_depth,
3717 cx,
3718 );
3719
3720 let mut outline_base_depth = excerpt_depth + 1;
3721 if is_singleton {
3722 outline_base_depth = 0;
3723 state.clear();
3724 } else if query.is_none()
3725 && self
3726 .collapsed_entries
3727 .contains(&CollapsedEntry::Excerpt(buffer_id, excerpt_id))
3728 {
3729 continue;
3730 }
3731
3732 for outline in excerpt.iter_outlines() {
3733 self.push_entry(
3734 state,
3735 track_matches,
3736 PanelEntry::Outline(OutlineEntry::Outline(
3737 buffer_id,
3738 excerpt_id,
3739 outline.clone(),
3740 )),
3741 outline_base_depth + outline.depth,
3742 cx,
3743 );
3744 }
3745 }
3746 }
3747 }
3748
3749 #[allow(clippy::too_many_arguments)]
3750 fn add_search_entries(
3751 &mut self,
3752 state: &mut GenerationState,
3753 active_editor: &View<Editor>,
3754 parent_entry: FsEntry,
3755 parent_depth: usize,
3756 filter_query: Option<String>,
3757 is_singleton: bool,
3758 cx: &mut ViewContext<Self>,
3759 ) {
3760 let ItemsDisplayMode::Search(search_state) = &mut self.mode else {
3761 return;
3762 };
3763
3764 let kind = search_state.kind;
3765 let related_excerpts = match &parent_entry {
3766 FsEntry::Directory(_, _) => return,
3767 FsEntry::ExternalFile(_, excerpts) => excerpts,
3768 FsEntry::File(_, _, _, excerpts) => excerpts,
3769 }
3770 .iter()
3771 .copied()
3772 .collect::<HashSet<_>>();
3773
3774 let depth = if is_singleton { 0 } else { parent_depth + 1 };
3775 let new_search_matches = search_state
3776 .matches
3777 .iter()
3778 .filter(|(match_range, _)| {
3779 related_excerpts.contains(&match_range.start.excerpt_id)
3780 || related_excerpts.contains(&match_range.end.excerpt_id)
3781 })
3782 .filter(|(match_range, _)| {
3783 let editor = active_editor.read(cx);
3784 if let Some(buffer_id) = match_range.start.buffer_id {
3785 if editor.buffer_folded(buffer_id, cx) {
3786 return false;
3787 }
3788 }
3789 if let Some(buffer_id) = match_range.start.buffer_id {
3790 if editor.buffer_folded(buffer_id, cx) {
3791 return false;
3792 }
3793 }
3794 true
3795 });
3796
3797 let new_search_entries = new_search_matches
3798 .map(|(match_range, search_data)| SearchEntry {
3799 match_range: match_range.clone(),
3800 kind,
3801 render_data: Arc::clone(search_data),
3802 })
3803 .collect::<Vec<_>>();
3804 for new_search_entry in new_search_entries {
3805 self.push_entry(
3806 state,
3807 filter_query.is_some(),
3808 PanelEntry::Search(new_search_entry),
3809 depth,
3810 cx,
3811 );
3812 }
3813 }
3814
3815 fn active_editor(&self) -> Option<View<Editor>> {
3816 self.active_item.as_ref()?.active_editor.upgrade()
3817 }
3818
3819 fn active_item(&self) -> Option<Box<dyn ItemHandle>> {
3820 self.active_item.as_ref()?.item_handle.upgrade()
3821 }
3822
3823 fn should_replace_active_item(&self, new_active_item: &dyn ItemHandle) -> bool {
3824 self.active_item().map_or(true, |active_item| {
3825 !self.pinned && active_item.item_id() != new_active_item.item_id()
3826 })
3827 }
3828
3829 pub fn toggle_active_editor_pin(
3830 &mut self,
3831 _: &ToggleActiveEditorPin,
3832 cx: &mut ViewContext<Self>,
3833 ) {
3834 self.pinned = !self.pinned;
3835 if !self.pinned {
3836 if let Some((active_item, active_editor)) = self
3837 .workspace
3838 .upgrade()
3839 .and_then(|workspace| workspace_active_editor(workspace.read(cx), cx))
3840 {
3841 if self.should_replace_active_item(active_item.as_ref()) {
3842 self.replace_active_editor(active_item, active_editor, cx);
3843 }
3844 }
3845 }
3846
3847 cx.notify();
3848 }
3849
3850 fn selected_entry(&self) -> Option<&PanelEntry> {
3851 match &self.selected_entry {
3852 SelectedEntry::Invalidated(entry) => entry.as_ref(),
3853 SelectedEntry::Valid(entry, _) => Some(entry),
3854 SelectedEntry::None => None,
3855 }
3856 }
3857
3858 fn select_entry(&mut self, entry: PanelEntry, focus: bool, cx: &mut ViewContext<Self>) {
3859 if focus {
3860 self.focus_handle.focus(cx);
3861 }
3862 let ix = self
3863 .cached_entries
3864 .iter()
3865 .enumerate()
3866 .find(|(_, cached_entry)| &cached_entry.entry == &entry)
3867 .map(|(i, _)| i)
3868 .unwrap_or_default();
3869
3870 self.selected_entry = SelectedEntry::Valid(entry, ix);
3871
3872 self.autoscroll(cx);
3873 cx.notify();
3874 }
3875
3876 fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3877 if !Self::should_show_scrollbar(cx)
3878 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
3879 {
3880 return None;
3881 }
3882 Some(
3883 div()
3884 .occlude()
3885 .id("project-panel-vertical-scroll")
3886 .on_mouse_move(cx.listener(|_, _, cx| {
3887 cx.notify();
3888 cx.stop_propagation()
3889 }))
3890 .on_hover(|_, cx| {
3891 cx.stop_propagation();
3892 })
3893 .on_any_mouse_down(|_, cx| {
3894 cx.stop_propagation();
3895 })
3896 .on_mouse_up(
3897 MouseButton::Left,
3898 cx.listener(|outline_panel, _, cx| {
3899 if !outline_panel.vertical_scrollbar_state.is_dragging()
3900 && !outline_panel.focus_handle.contains_focused(cx)
3901 {
3902 outline_panel.hide_scrollbar(cx);
3903 cx.notify();
3904 }
3905
3906 cx.stop_propagation();
3907 }),
3908 )
3909 .on_scroll_wheel(cx.listener(|_, _, cx| {
3910 cx.notify();
3911 }))
3912 .h_full()
3913 .absolute()
3914 .right_1()
3915 .top_1()
3916 .bottom_0()
3917 .w(px(12.))
3918 .cursor_default()
3919 .children(Scrollbar::vertical(self.vertical_scrollbar_state.clone())),
3920 )
3921 }
3922
3923 fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3924 if !Self::should_show_scrollbar(cx)
3925 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
3926 {
3927 return None;
3928 }
3929
3930 let scroll_handle = self.scroll_handle.0.borrow();
3931 let longest_item_width = scroll_handle
3932 .last_item_size
3933 .filter(|size| size.contents.width > size.item.width)?
3934 .contents
3935 .width
3936 .0 as f64;
3937 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
3938 return None;
3939 }
3940
3941 Some(
3942 div()
3943 .occlude()
3944 .id("project-panel-horizontal-scroll")
3945 .on_mouse_move(cx.listener(|_, _, cx| {
3946 cx.notify();
3947 cx.stop_propagation()
3948 }))
3949 .on_hover(|_, cx| {
3950 cx.stop_propagation();
3951 })
3952 .on_any_mouse_down(|_, cx| {
3953 cx.stop_propagation();
3954 })
3955 .on_mouse_up(
3956 MouseButton::Left,
3957 cx.listener(|outline_panel, _, cx| {
3958 if !outline_panel.horizontal_scrollbar_state.is_dragging()
3959 && !outline_panel.focus_handle.contains_focused(cx)
3960 {
3961 outline_panel.hide_scrollbar(cx);
3962 cx.notify();
3963 }
3964
3965 cx.stop_propagation();
3966 }),
3967 )
3968 .on_scroll_wheel(cx.listener(|_, _, cx| {
3969 cx.notify();
3970 }))
3971 .w_full()
3972 .absolute()
3973 .right_1()
3974 .left_1()
3975 .bottom_0()
3976 .h(px(12.))
3977 .cursor_default()
3978 .when(self.width.is_some(), |this| {
3979 this.children(Scrollbar::horizontal(
3980 self.horizontal_scrollbar_state.clone(),
3981 ))
3982 }),
3983 )
3984 }
3985
3986 fn should_show_scrollbar(cx: &AppContext) -> bool {
3987 let show = OutlinePanelSettings::get_global(cx)
3988 .scrollbar
3989 .show
3990 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3991 match show {
3992 ShowScrollbar::Auto => true,
3993 ShowScrollbar::System => true,
3994 ShowScrollbar::Always => true,
3995 ShowScrollbar::Never => false,
3996 }
3997 }
3998
3999 fn should_autohide_scrollbar(cx: &AppContext) -> bool {
4000 let show = OutlinePanelSettings::get_global(cx)
4001 .scrollbar
4002 .show
4003 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4004 match show {
4005 ShowScrollbar::Auto => true,
4006 ShowScrollbar::System => cx
4007 .try_global::<ScrollbarAutoHide>()
4008 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
4009 ShowScrollbar::Always => false,
4010 ShowScrollbar::Never => true,
4011 }
4012 }
4013
4014 fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
4015 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
4016 if !Self::should_autohide_scrollbar(cx) {
4017 return;
4018 }
4019 self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
4020 cx.background_executor()
4021 .timer(SCROLLBAR_SHOW_INTERVAL)
4022 .await;
4023 panel
4024 .update(&mut cx, |panel, cx| {
4025 panel.show_scrollbar = false;
4026 cx.notify();
4027 })
4028 .log_err();
4029 }))
4030 }
4031
4032 fn width_estimate(&self, depth: usize, entry: &PanelEntry, cx: &AppContext) -> u64 {
4033 let item_text_chars = match entry {
4034 PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => self
4035 .buffer_snapshot_for_id(*buffer_id, cx)
4036 .and_then(|snapshot| {
4037 Some(snapshot.file()?.path().file_name()?.to_string_lossy().len())
4038 })
4039 .unwrap_or_default(),
4040 PanelEntry::Fs(FsEntry::Directory(_, directory)) => directory
4041 .path
4042 .file_name()
4043 .map(|name| name.to_string_lossy().len())
4044 .unwrap_or_default(),
4045 PanelEntry::Fs(FsEntry::File(_, file, _, _)) => file
4046 .path
4047 .file_name()
4048 .map(|name| name.to_string_lossy().len())
4049 .unwrap_or_default(),
4050 PanelEntry::FoldedDirs(_, dirs) => {
4051 dirs.iter()
4052 .map(|dir| {
4053 dir.path
4054 .file_name()
4055 .map(|name| name.to_string_lossy().len())
4056 .unwrap_or_default()
4057 })
4058 .sum::<usize>()
4059 + dirs.len().saturating_sub(1) * MAIN_SEPARATOR_STR.len()
4060 }
4061 PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, _, range)) => self
4062 .excerpt_label(*buffer_id, range, cx)
4063 .map(|label| label.len())
4064 .unwrap_or_default(),
4065 PanelEntry::Outline(OutlineEntry::Outline(_, _, outline)) => outline.text.len(),
4066 PanelEntry::Search(search) => search
4067 .render_data
4068 .get()
4069 .map(|data| data.context_text.len())
4070 .unwrap_or_default(),
4071 };
4072
4073 (item_text_chars + depth) as u64
4074 }
4075
4076 fn render_main_contents(
4077 &mut self,
4078 query: Option<String>,
4079 show_indent_guides: bool,
4080 indent_size: f32,
4081 cx: &mut ViewContext<'_, Self>,
4082 ) -> Div {
4083 let contents = if self.cached_entries.is_empty() {
4084 let header = if self.updating_fs_entries {
4085 "Loading outlines"
4086 } else if query.is_some() {
4087 "No matches for query"
4088 } else {
4089 "No outlines available"
4090 };
4091
4092 v_flex()
4093 .flex_1()
4094 .justify_center()
4095 .size_full()
4096 .child(h_flex().justify_center().child(Label::new(header)))
4097 .when_some(query.clone(), |panel, query| {
4098 panel.child(h_flex().justify_center().child(Label::new(query)))
4099 })
4100 .child(
4101 h_flex()
4102 .pt(DynamicSpacing::Base04.rems(cx))
4103 .justify_center()
4104 .child({
4105 let keystroke = match self.position(cx) {
4106 DockPosition::Left => {
4107 cx.keystroke_text_for(&workspace::ToggleLeftDock)
4108 }
4109 DockPosition::Bottom => {
4110 cx.keystroke_text_for(&workspace::ToggleBottomDock)
4111 }
4112 DockPosition::Right => {
4113 cx.keystroke_text_for(&workspace::ToggleRightDock)
4114 }
4115 };
4116 Label::new(format!("Toggle this panel with {keystroke}"))
4117 }),
4118 )
4119 } else {
4120 let list_contents = {
4121 let items_len = self.cached_entries.len();
4122 let multi_buffer_snapshot = self
4123 .active_editor()
4124 .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx));
4125 uniform_list(cx.view().clone(), "entries", items_len, {
4126 move |outline_panel, range, cx| {
4127 let entries = outline_panel.cached_entries.get(range);
4128 entries
4129 .map(|entries| entries.to_vec())
4130 .unwrap_or_default()
4131 .into_iter()
4132 .filter_map(|cached_entry| match cached_entry.entry {
4133 PanelEntry::Fs(entry) => Some(outline_panel.render_entry(
4134 &entry,
4135 cached_entry.depth,
4136 cached_entry.string_match.as_ref(),
4137 cx,
4138 )),
4139 PanelEntry::FoldedDirs(worktree_id, entries) => {
4140 Some(outline_panel.render_folded_dirs(
4141 worktree_id,
4142 &entries,
4143 cached_entry.depth,
4144 cached_entry.string_match.as_ref(),
4145 cx,
4146 ))
4147 }
4148 PanelEntry::Outline(OutlineEntry::Excerpt(
4149 buffer_id,
4150 excerpt_id,
4151 excerpt,
4152 )) => outline_panel.render_excerpt(
4153 buffer_id,
4154 excerpt_id,
4155 &excerpt,
4156 cached_entry.depth,
4157 cx,
4158 ),
4159 PanelEntry::Outline(OutlineEntry::Outline(
4160 buffer_id,
4161 excerpt_id,
4162 outline,
4163 )) => Some(outline_panel.render_outline(
4164 buffer_id,
4165 excerpt_id,
4166 &outline,
4167 cached_entry.depth,
4168 cached_entry.string_match.as_ref(),
4169 cx,
4170 )),
4171 PanelEntry::Search(SearchEntry {
4172 match_range,
4173 render_data,
4174 kind,
4175 ..
4176 }) => outline_panel.render_search_match(
4177 multi_buffer_snapshot.as_ref(),
4178 &match_range,
4179 &render_data,
4180 kind,
4181 cached_entry.depth,
4182 cached_entry.string_match.as_ref(),
4183 cx,
4184 ),
4185 })
4186 .collect()
4187 }
4188 })
4189 .with_sizing_behavior(ListSizingBehavior::Infer)
4190 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4191 .with_width_from_item(self.max_width_item_index)
4192 .track_scroll(self.scroll_handle.clone())
4193 .when(show_indent_guides, |list| {
4194 list.with_decoration(
4195 ui::indent_guides(
4196 cx.view().clone(),
4197 px(indent_size),
4198 IndentGuideColors::panel(cx),
4199 |outline_panel, range, _| {
4200 let entries = outline_panel.cached_entries.get(range);
4201 if let Some(entries) = entries {
4202 entries.into_iter().map(|item| item.depth).collect()
4203 } else {
4204 smallvec::SmallVec::new()
4205 }
4206 },
4207 )
4208 .with_render_fn(
4209 cx.view().clone(),
4210 move |outline_panel, params, _| {
4211 const LEFT_OFFSET: f32 = 14.;
4212
4213 let indent_size = params.indent_size;
4214 let item_height = params.item_height;
4215 let active_indent_guide_ix = find_active_indent_guide_ix(
4216 outline_panel,
4217 ¶ms.indent_guides,
4218 );
4219
4220 params
4221 .indent_guides
4222 .into_iter()
4223 .enumerate()
4224 .map(|(ix, layout)| {
4225 let bounds = Bounds::new(
4226 point(
4227 px(layout.offset.x as f32) * indent_size
4228 + px(LEFT_OFFSET),
4229 px(layout.offset.y as f32) * item_height,
4230 ),
4231 size(px(1.), px(layout.length as f32) * item_height),
4232 );
4233 ui::RenderedIndentGuide {
4234 bounds,
4235 layout,
4236 is_active: active_indent_guide_ix == Some(ix),
4237 hitbox: None,
4238 }
4239 })
4240 .collect()
4241 },
4242 ),
4243 )
4244 })
4245 };
4246
4247 v_flex()
4248 .flex_shrink()
4249 .size_full()
4250 .child(list_contents.size_full().flex_shrink())
4251 .children(self.render_vertical_scrollbar(cx))
4252 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4253 this.pb_4().child(scrollbar)
4254 })
4255 }
4256 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4257 deferred(
4258 anchored()
4259 .position(*position)
4260 .anchor(gpui::Corner::TopLeft)
4261 .child(menu.clone()),
4262 )
4263 .with_priority(1)
4264 }));
4265
4266 v_flex().w_full().flex_1().overflow_hidden().child(contents)
4267 }
4268
4269 fn render_filter_footer(&mut self, pinned: bool, cx: &mut ViewContext<'_, Self>) -> Div {
4270 v_flex().flex_none().child(horizontal_separator(cx)).child(
4271 h_flex()
4272 .p_2()
4273 .w_full()
4274 .child(self.filter_editor.clone())
4275 .child(
4276 div().child(
4277 IconButton::new(
4278 "outline-panel-menu",
4279 if pinned {
4280 IconName::Unpin
4281 } else {
4282 IconName::Pin
4283 },
4284 )
4285 .tooltip(move |cx| {
4286 Tooltip::text(
4287 if pinned {
4288 "Unpin Outline"
4289 } else {
4290 "Pin Active Outline"
4291 },
4292 cx,
4293 )
4294 })
4295 .shape(IconButtonShape::Square)
4296 .on_click(cx.listener(|outline_panel, _, cx| {
4297 outline_panel.toggle_active_editor_pin(&ToggleActiveEditorPin, cx);
4298 })),
4299 ),
4300 ),
4301 )
4302 }
4303
4304 fn buffers_inside_directory(
4305 &self,
4306 dir_worktree: WorktreeId,
4307 dir_entry: &Entry,
4308 ) -> HashSet<BufferId> {
4309 if !dir_entry.is_dir() {
4310 debug_panic!("buffers_inside_directory called on a non-directory entry {dir_entry:?}");
4311 return HashSet::default();
4312 }
4313
4314 self.fs_entries
4315 .iter()
4316 .skip_while(|fs_entry| match fs_entry {
4317 FsEntry::Directory(worktree_id, entry) => {
4318 *worktree_id != dir_worktree || entry != dir_entry
4319 }
4320 _ => true,
4321 })
4322 .skip(1)
4323 .take_while(|fs_entry| match fs_entry {
4324 FsEntry::ExternalFile(..) => false,
4325 FsEntry::Directory(worktree_id, entry) => {
4326 *worktree_id == dir_worktree && entry.path.starts_with(&dir_entry.path)
4327 }
4328 FsEntry::File(worktree_id, entry, ..) => {
4329 *worktree_id == dir_worktree && entry.path.starts_with(&dir_entry.path)
4330 }
4331 })
4332 .filter_map(|fs_entry| match fs_entry {
4333 FsEntry::File(_, _, buffer_id, _) => Some(*buffer_id),
4334 _ => None,
4335 })
4336 .collect()
4337 }
4338}
4339
4340fn workspace_active_editor(
4341 workspace: &Workspace,
4342 cx: &AppContext,
4343) -> Option<(Box<dyn ItemHandle>, View<Editor>)> {
4344 let active_item = workspace.active_item(cx)?;
4345 let active_editor = active_item
4346 .act_as::<Editor>(cx)
4347 .filter(|editor| editor.read(cx).mode() == EditorMode::Full)?;
4348 Some((active_item, active_editor))
4349}
4350
4351fn back_to_common_visited_parent(
4352 visited_dirs: &mut Vec<(ProjectEntryId, Arc<Path>)>,
4353 worktree_id: &WorktreeId,
4354 new_entry: &Entry,
4355) -> Option<(WorktreeId, ProjectEntryId)> {
4356 while let Some((visited_dir_id, visited_path)) = visited_dirs.last() {
4357 match new_entry.path.parent() {
4358 Some(parent_path) => {
4359 if parent_path == visited_path.as_ref() {
4360 return Some((*worktree_id, *visited_dir_id));
4361 }
4362 }
4363 None => {
4364 break;
4365 }
4366 }
4367 visited_dirs.pop();
4368 }
4369 None
4370}
4371
4372fn file_name(path: &Path) -> String {
4373 let mut current_path = path;
4374 loop {
4375 if let Some(file_name) = current_path.file_name() {
4376 return file_name.to_string_lossy().into_owned();
4377 }
4378 match current_path.parent() {
4379 Some(parent) => current_path = parent,
4380 None => return path.to_string_lossy().into_owned(),
4381 }
4382 }
4383}
4384
4385impl Panel for OutlinePanel {
4386 fn persistent_name() -> &'static str {
4387 "Outline Panel"
4388 }
4389
4390 fn position(&self, cx: &WindowContext) -> DockPosition {
4391 match OutlinePanelSettings::get_global(cx).dock {
4392 OutlinePanelDockPosition::Left => DockPosition::Left,
4393 OutlinePanelDockPosition::Right => DockPosition::Right,
4394 }
4395 }
4396
4397 fn position_is_valid(&self, position: DockPosition) -> bool {
4398 matches!(position, DockPosition::Left | DockPosition::Right)
4399 }
4400
4401 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
4402 settings::update_settings_file::<OutlinePanelSettings>(
4403 self.fs.clone(),
4404 cx,
4405 move |settings, _| {
4406 let dock = match position {
4407 DockPosition::Left | DockPosition::Bottom => OutlinePanelDockPosition::Left,
4408 DockPosition::Right => OutlinePanelDockPosition::Right,
4409 };
4410 settings.dock = Some(dock);
4411 },
4412 );
4413 }
4414
4415 fn size(&self, cx: &WindowContext) -> Pixels {
4416 self.width
4417 .unwrap_or_else(|| OutlinePanelSettings::get_global(cx).default_width)
4418 }
4419
4420 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
4421 self.width = size;
4422 self.serialize(cx);
4423 cx.notify();
4424 }
4425
4426 fn icon(&self, cx: &WindowContext) -> Option<IconName> {
4427 OutlinePanelSettings::get_global(cx)
4428 .button
4429 .then_some(IconName::ListTree)
4430 }
4431
4432 fn icon_tooltip(&self, _: &WindowContext) -> Option<&'static str> {
4433 Some("Outline Panel")
4434 }
4435
4436 fn toggle_action(&self) -> Box<dyn Action> {
4437 Box::new(ToggleFocus)
4438 }
4439
4440 fn starts_open(&self, _: &WindowContext) -> bool {
4441 self.active
4442 }
4443
4444 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
4445 cx.spawn(|outline_panel, mut cx| async move {
4446 outline_panel
4447 .update(&mut cx, |outline_panel, cx| {
4448 let old_active = outline_panel.active;
4449 outline_panel.active = active;
4450 if active && old_active != active {
4451 if let Some((active_item, active_editor)) = outline_panel
4452 .workspace
4453 .upgrade()
4454 .and_then(|workspace| workspace_active_editor(workspace.read(cx), cx))
4455 {
4456 if outline_panel.should_replace_active_item(active_item.as_ref()) {
4457 outline_panel.replace_active_editor(active_item, active_editor, cx);
4458 } else {
4459 outline_panel.update_fs_entries(active_editor, None, cx)
4460 }
4461 } else if !outline_panel.pinned {
4462 outline_panel.clear_previous(cx);
4463 }
4464 }
4465 outline_panel.serialize(cx);
4466 })
4467 .ok();
4468 })
4469 .detach()
4470 }
4471}
4472
4473impl FocusableView for OutlinePanel {
4474 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
4475 self.filter_editor.focus_handle(cx).clone()
4476 }
4477}
4478
4479impl EventEmitter<Event> for OutlinePanel {}
4480
4481impl EventEmitter<PanelEvent> for OutlinePanel {}
4482
4483impl Render for OutlinePanel {
4484 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
4485 let (is_local, is_via_ssh) = self
4486 .project
4487 .read_with(cx, |project, _| (project.is_local(), project.is_via_ssh()));
4488 let query = self.query(cx);
4489 let pinned = self.pinned;
4490 let settings = OutlinePanelSettings::get_global(cx);
4491 let indent_size = settings.indent_size;
4492 let show_indent_guides = settings.indent_guides.show == ShowIndentGuides::Always;
4493
4494 let search_query = match &self.mode {
4495 ItemsDisplayMode::Search(search_query) => Some(search_query),
4496 _ => None,
4497 };
4498
4499 v_flex()
4500 .id("outline-panel")
4501 .size_full()
4502 .overflow_hidden()
4503 .relative()
4504 .on_hover(cx.listener(|this, hovered, cx| {
4505 if *hovered {
4506 this.show_scrollbar = true;
4507 this.hide_scrollbar_task.take();
4508 cx.notify();
4509 } else if !this.focus_handle.contains_focused(cx) {
4510 this.hide_scrollbar(cx);
4511 }
4512 }))
4513 .key_context(self.dispatch_context(cx))
4514 .on_action(cx.listener(Self::open))
4515 .on_action(cx.listener(Self::cancel))
4516 .on_action(cx.listener(Self::select_next))
4517 .on_action(cx.listener(Self::select_prev))
4518 .on_action(cx.listener(Self::select_first))
4519 .on_action(cx.listener(Self::select_last))
4520 .on_action(cx.listener(Self::select_parent))
4521 .on_action(cx.listener(Self::expand_selected_entry))
4522 .on_action(cx.listener(Self::collapse_selected_entry))
4523 .on_action(cx.listener(Self::expand_all_entries))
4524 .on_action(cx.listener(Self::collapse_all_entries))
4525 .on_action(cx.listener(Self::copy_path))
4526 .on_action(cx.listener(Self::copy_relative_path))
4527 .on_action(cx.listener(Self::toggle_active_editor_pin))
4528 .on_action(cx.listener(Self::unfold_directory))
4529 .on_action(cx.listener(Self::fold_directory))
4530 .on_action(cx.listener(Self::open_excerpts))
4531 .on_action(cx.listener(Self::open_excerpts_split))
4532 .when(is_local, |el| {
4533 el.on_action(cx.listener(Self::reveal_in_finder))
4534 })
4535 .when(is_local || is_via_ssh, |el| {
4536 el.on_action(cx.listener(Self::open_in_terminal))
4537 })
4538 .on_mouse_down(
4539 MouseButton::Right,
4540 cx.listener(move |outline_panel, event: &MouseDownEvent, cx| {
4541 if let Some(entry) = outline_panel.selected_entry().cloned() {
4542 outline_panel.deploy_context_menu(event.position, entry, cx)
4543 } else if let Some(entry) = outline_panel.fs_entries.first().cloned() {
4544 outline_panel.deploy_context_menu(event.position, PanelEntry::Fs(entry), cx)
4545 }
4546 }),
4547 )
4548 .track_focus(&self.focus_handle)
4549 .when_some(search_query, |outline_panel, search_state| {
4550 outline_panel.child(
4551 v_flex()
4552 .child(
4553 Label::new(format!("Searching: '{}'", search_state.query))
4554 .color(Color::Muted)
4555 .mx_2(),
4556 )
4557 .child(horizontal_separator(cx)),
4558 )
4559 })
4560 .child(self.render_main_contents(query, show_indent_guides, indent_size, cx))
4561 .child(self.render_filter_footer(pinned, cx))
4562 }
4563}
4564
4565fn find_active_indent_guide_ix(
4566 outline_panel: &OutlinePanel,
4567 candidates: &[IndentGuideLayout],
4568) -> Option<usize> {
4569 let SelectedEntry::Valid(_, target_ix) = &outline_panel.selected_entry else {
4570 return None;
4571 };
4572 let target_depth = outline_panel
4573 .cached_entries
4574 .get(*target_ix)
4575 .map(|cached_entry| cached_entry.depth)?;
4576
4577 let (target_ix, target_depth) = if let Some(target_depth) = outline_panel
4578 .cached_entries
4579 .get(target_ix + 1)
4580 .filter(|cached_entry| cached_entry.depth > target_depth)
4581 .map(|entry| entry.depth)
4582 {
4583 (target_ix + 1, target_depth.saturating_sub(1))
4584 } else {
4585 (*target_ix, target_depth.saturating_sub(1))
4586 };
4587
4588 candidates
4589 .iter()
4590 .enumerate()
4591 .find(|(_, guide)| {
4592 guide.offset.y <= target_ix
4593 && target_ix < guide.offset.y + guide.length
4594 && guide.offset.x == target_depth
4595 })
4596 .map(|(ix, _)| ix)
4597}
4598
4599fn subscribe_for_editor_events(
4600 editor: &View<Editor>,
4601 cx: &mut ViewContext<OutlinePanel>,
4602) -> Subscription {
4603 let debounce = Some(UPDATE_DEBOUNCE);
4604 cx.subscribe(
4605 editor,
4606 move |outline_panel, editor, e: &EditorEvent, cx| match e {
4607 EditorEvent::SelectionsChanged { local: true } => {
4608 outline_panel.reveal_entry_for_selection(editor, cx);
4609 cx.notify();
4610 }
4611 EditorEvent::ExcerptsAdded { excerpts, .. } => {
4612 outline_panel
4613 .new_entries_for_fs_update
4614 .extend(excerpts.iter().map(|&(excerpt_id, _)| excerpt_id));
4615 outline_panel.update_fs_entries(editor, debounce, cx);
4616 }
4617 EditorEvent::ExcerptsRemoved { ids } => {
4618 let mut ids = ids.iter().collect::<HashSet<_>>();
4619 for excerpts in outline_panel.excerpts.values_mut() {
4620 excerpts.retain(|excerpt_id, _| !ids.remove(excerpt_id));
4621 if ids.is_empty() {
4622 break;
4623 }
4624 }
4625 outline_panel.update_fs_entries(editor, debounce, cx);
4626 }
4627 EditorEvent::ExcerptsExpanded { ids } => {
4628 outline_panel.invalidate_outlines(ids);
4629 outline_panel.update_non_fs_items(cx);
4630 }
4631 EditorEvent::ExcerptsEdited { ids } => {
4632 outline_panel.invalidate_outlines(ids);
4633 outline_panel.update_non_fs_items(cx);
4634 }
4635 EditorEvent::BufferFoldToggled { ids, .. } => {
4636 outline_panel.invalidate_outlines(ids);
4637 let mut latest_unfolded_buffer_id = None;
4638 let mut latest_folded_buffer_id = None;
4639 let mut ignore_selections_change = false;
4640 outline_panel.new_entries_for_fs_update.extend(
4641 ids.iter()
4642 .filter(|id| {
4643 outline_panel
4644 .excerpts
4645 .iter()
4646 .find_map(|(buffer_id, excerpts)| {
4647 if excerpts.contains_key(id) {
4648 ignore_selections_change |= outline_panel
4649 .preserve_selection_on_buffer_fold_toggles
4650 .remove(buffer_id);
4651 Some(buffer_id)
4652 } else {
4653 None
4654 }
4655 })
4656 .map(|buffer_id| {
4657 if editor.read(cx).buffer_folded(*buffer_id, cx) {
4658 latest_folded_buffer_id = Some(*buffer_id);
4659 false
4660 } else {
4661 latest_unfolded_buffer_id = Some(*buffer_id);
4662 true
4663 }
4664 })
4665 .unwrap_or(true)
4666 })
4667 .copied(),
4668 );
4669 if !ignore_selections_change {
4670 if let Some(entry_to_select) = latest_unfolded_buffer_id
4671 .or(latest_folded_buffer_id)
4672 .and_then(|toggled_buffer_id| {
4673 outline_panel
4674 .fs_entries
4675 .iter()
4676 .find_map(|fs_entry| match fs_entry {
4677 FsEntry::ExternalFile(buffer_id, _) => {
4678 if *buffer_id == toggled_buffer_id {
4679 Some(fs_entry.clone())
4680 } else {
4681 None
4682 }
4683 }
4684 FsEntry::File(_, _, buffer_id, _) => {
4685 if *buffer_id == toggled_buffer_id {
4686 Some(fs_entry.clone())
4687 } else {
4688 None
4689 }
4690 }
4691 FsEntry::Directory(..) => None,
4692 })
4693 })
4694 .map(PanelEntry::Fs)
4695 {
4696 outline_panel.select_entry(entry_to_select, true, cx);
4697 }
4698 }
4699
4700 outline_panel.update_fs_entries(editor, debounce, cx);
4701 }
4702 EditorEvent::Reparsed(buffer_id) => {
4703 if let Some(excerpts) = outline_panel.excerpts.get_mut(buffer_id) {
4704 for (_, excerpt) in excerpts {
4705 excerpt.invalidate_outlines();
4706 }
4707 }
4708 outline_panel.update_non_fs_items(cx);
4709 }
4710 _ => {}
4711 },
4712 )
4713}
4714
4715fn empty_icon() -> AnyElement {
4716 h_flex()
4717 .size(IconSize::default().rems())
4718 .invisible()
4719 .flex_none()
4720 .into_any_element()
4721}
4722
4723fn horizontal_separator(cx: &mut WindowContext) -> Div {
4724 div().mx_2().border_primary(cx).border_t_1()
4725}
4726
4727#[derive(Debug, Default)]
4728struct GenerationState {
4729 entries: Vec<CachedEntry>,
4730 match_candidates: Vec<StringMatchCandidate>,
4731 max_width_estimate_and_index: Option<(u64, usize)>,
4732}
4733
4734impl GenerationState {
4735 fn clear(&mut self) {
4736 self.entries.clear();
4737 self.match_candidates.clear();
4738 self.max_width_estimate_and_index = None;
4739 }
4740}
4741
4742#[cfg(test)]
4743mod tests {
4744 use gpui::{TestAppContext, VisualTestContext, WindowHandle};
4745 use language::{tree_sitter_rust, Language, LanguageConfig, LanguageMatcher};
4746 use pretty_assertions::assert_eq;
4747 use project::FakeFs;
4748 use search::project_search::{self, perform_project_search};
4749 use serde_json::json;
4750
4751 use super::*;
4752
4753 const SELECTED_MARKER: &str = " <==== selected";
4754
4755 #[gpui::test(iterations = 10)]
4756 async fn test_project_search_results_toggling(cx: &mut TestAppContext) {
4757 init_test(cx);
4758
4759 let fs = FakeFs::new(cx.background_executor.clone());
4760 populate_with_test_ra_project(&fs, "/rust-analyzer").await;
4761 let project = Project::test(fs.clone(), ["/rust-analyzer".as_ref()], cx).await;
4762 project.read_with(cx, |project, _| {
4763 project.languages().add(Arc::new(rust_lang()))
4764 });
4765 let workspace = add_outline_panel(&project, cx).await;
4766 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4767 let outline_panel = outline_panel(&workspace, cx);
4768 outline_panel.update(cx, |outline_panel, cx| outline_panel.set_active(true, cx));
4769
4770 workspace
4771 .update(cx, |workspace, cx| {
4772 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::default(), cx)
4773 })
4774 .unwrap();
4775 let search_view = workspace
4776 .update(cx, |workspace, cx| {
4777 workspace
4778 .active_pane()
4779 .read(cx)
4780 .items()
4781 .find_map(|item| item.downcast::<ProjectSearchView>())
4782 .expect("Project search view expected to appear after new search event trigger")
4783 })
4784 .unwrap();
4785
4786 let query = "param_names_for_lifetime_elision_hints";
4787 perform_project_search(&search_view, query, cx);
4788 search_view.update(cx, |search_view, cx| {
4789 search_view
4790 .results_editor()
4791 .update(cx, |results_editor, cx| {
4792 assert_eq!(
4793 results_editor.display_text(cx).match_indices(query).count(),
4794 9
4795 );
4796 });
4797 });
4798
4799 let all_matches = r#"/
4800 crates/
4801 ide/src/
4802 inlay_hints/
4803 fn_lifetime_fn.rs
4804 search: match config.param_names_for_lifetime_elision_hints {
4805 search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
4806 search: Some(it) if config.param_names_for_lifetime_elision_hints => {
4807 search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
4808 inlay_hints.rs
4809 search: pub param_names_for_lifetime_elision_hints: bool,
4810 search: param_names_for_lifetime_elision_hints: self
4811 static_index.rs
4812 search: param_names_for_lifetime_elision_hints: false,
4813 rust-analyzer/src/
4814 cli/
4815 analysis_stats.rs
4816 search: param_names_for_lifetime_elision_hints: true,
4817 config.rs
4818 search: param_names_for_lifetime_elision_hints: self"#;
4819 let select_first_in_all_matches = |line_to_select: &str| {
4820 assert!(all_matches.contains(line_to_select));
4821 all_matches.replacen(
4822 line_to_select,
4823 &format!("{line_to_select}{SELECTED_MARKER}"),
4824 1,
4825 )
4826 };
4827
4828 cx.executor()
4829 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
4830 cx.run_until_parked();
4831 outline_panel.update(cx, |outline_panel, cx| {
4832 assert_eq!(
4833 display_entries(
4834 &snapshot(&outline_panel, cx),
4835 &outline_panel.cached_entries,
4836 outline_panel.selected_entry()
4837 ),
4838 select_first_in_all_matches(
4839 "search: match config.param_names_for_lifetime_elision_hints {"
4840 )
4841 );
4842 });
4843
4844 outline_panel.update(cx, |outline_panel, cx| {
4845 outline_panel.select_parent(&SelectParent, cx);
4846 assert_eq!(
4847 display_entries(
4848 &snapshot(&outline_panel, cx),
4849 &outline_panel.cached_entries,
4850 outline_panel.selected_entry()
4851 ),
4852 select_first_in_all_matches("fn_lifetime_fn.rs")
4853 );
4854 });
4855 outline_panel.update(cx, |outline_panel, cx| {
4856 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, cx);
4857 });
4858 cx.executor()
4859 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
4860 cx.run_until_parked();
4861 outline_panel.update(cx, |outline_panel, cx| {
4862 assert_eq!(
4863 display_entries(
4864 &snapshot(&outline_panel, cx),
4865 &outline_panel.cached_entries,
4866 outline_panel.selected_entry()
4867 ),
4868 format!(
4869 r#"/
4870 crates/
4871 ide/src/
4872 inlay_hints/
4873 fn_lifetime_fn.rs{SELECTED_MARKER}
4874 inlay_hints.rs
4875 search: pub param_names_for_lifetime_elision_hints: bool,
4876 search: param_names_for_lifetime_elision_hints: self
4877 static_index.rs
4878 search: param_names_for_lifetime_elision_hints: false,
4879 rust-analyzer/src/
4880 cli/
4881 analysis_stats.rs
4882 search: param_names_for_lifetime_elision_hints: true,
4883 config.rs
4884 search: param_names_for_lifetime_elision_hints: self"#,
4885 )
4886 );
4887 });
4888
4889 outline_panel.update(cx, |outline_panel, cx| {
4890 outline_panel.expand_all_entries(&ExpandAllEntries, cx);
4891 });
4892 cx.executor()
4893 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
4894 cx.run_until_parked();
4895 outline_panel.update(cx, |outline_panel, cx| {
4896 outline_panel.select_parent(&SelectParent, cx);
4897 assert_eq!(
4898 display_entries(
4899 &snapshot(&outline_panel, cx),
4900 &outline_panel.cached_entries,
4901 outline_panel.selected_entry()
4902 ),
4903 select_first_in_all_matches("inlay_hints/")
4904 );
4905 });
4906
4907 outline_panel.update(cx, |outline_panel, cx| {
4908 outline_panel.select_parent(&SelectParent, cx);
4909 assert_eq!(
4910 display_entries(
4911 &snapshot(&outline_panel, cx),
4912 &outline_panel.cached_entries,
4913 outline_panel.selected_entry()
4914 ),
4915 select_first_in_all_matches("ide/src/")
4916 );
4917 });
4918
4919 outline_panel.update(cx, |outline_panel, cx| {
4920 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, cx);
4921 });
4922 cx.executor()
4923 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
4924 cx.run_until_parked();
4925 outline_panel.update(cx, |outline_panel, cx| {
4926 assert_eq!(
4927 display_entries(
4928 &snapshot(&outline_panel, cx),
4929 &outline_panel.cached_entries,
4930 outline_panel.selected_entry()
4931 ),
4932 format!(
4933 r#"/
4934 crates/
4935 ide/src/{SELECTED_MARKER}
4936 rust-analyzer/src/
4937 cli/
4938 analysis_stats.rs
4939 search: param_names_for_lifetime_elision_hints: true,
4940 config.rs
4941 search: param_names_for_lifetime_elision_hints: self"#,
4942 )
4943 );
4944 });
4945 outline_panel.update(cx, |outline_panel, cx| {
4946 outline_panel.expand_selected_entry(&ExpandSelectedEntry, cx);
4947 });
4948 cx.executor()
4949 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
4950 cx.run_until_parked();
4951 outline_panel.update(cx, |outline_panel, cx| {
4952 assert_eq!(
4953 display_entries(
4954 &snapshot(&outline_panel, cx),
4955 &outline_panel.cached_entries,
4956 outline_panel.selected_entry()
4957 ),
4958 select_first_in_all_matches("ide/src/")
4959 );
4960 });
4961 }
4962
4963 #[gpui::test(iterations = 10)]
4964 async fn test_item_filtering(cx: &mut TestAppContext) {
4965 init_test(cx);
4966
4967 let fs = FakeFs::new(cx.background_executor.clone());
4968 populate_with_test_ra_project(&fs, "/rust-analyzer").await;
4969 let project = Project::test(fs.clone(), ["/rust-analyzer".as_ref()], cx).await;
4970 project.read_with(cx, |project, _| {
4971 project.languages().add(Arc::new(rust_lang()))
4972 });
4973 let workspace = add_outline_panel(&project, cx).await;
4974 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4975 let outline_panel = outline_panel(&workspace, cx);
4976 outline_panel.update(cx, |outline_panel, cx| outline_panel.set_active(true, cx));
4977
4978 workspace
4979 .update(cx, |workspace, cx| {
4980 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::default(), cx)
4981 })
4982 .unwrap();
4983 let search_view = workspace
4984 .update(cx, |workspace, cx| {
4985 workspace
4986 .active_pane()
4987 .read(cx)
4988 .items()
4989 .find_map(|item| item.downcast::<ProjectSearchView>())
4990 .expect("Project search view expected to appear after new search event trigger")
4991 })
4992 .unwrap();
4993
4994 let query = "param_names_for_lifetime_elision_hints";
4995 perform_project_search(&search_view, query, cx);
4996 search_view.update(cx, |search_view, cx| {
4997 search_view
4998 .results_editor()
4999 .update(cx, |results_editor, cx| {
5000 assert_eq!(
5001 results_editor.display_text(cx).match_indices(query).count(),
5002 9
5003 );
5004 });
5005 });
5006 let all_matches = r#"/
5007 crates/
5008 ide/src/
5009 inlay_hints/
5010 fn_lifetime_fn.rs
5011 search: match config.param_names_for_lifetime_elision_hints {
5012 search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
5013 search: Some(it) if config.param_names_for_lifetime_elision_hints => {
5014 search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
5015 inlay_hints.rs
5016 search: pub param_names_for_lifetime_elision_hints: bool,
5017 search: param_names_for_lifetime_elision_hints: self
5018 static_index.rs
5019 search: param_names_for_lifetime_elision_hints: false,
5020 rust-analyzer/src/
5021 cli/
5022 analysis_stats.rs
5023 search: param_names_for_lifetime_elision_hints: true,
5024 config.rs
5025 search: param_names_for_lifetime_elision_hints: self"#;
5026
5027 cx.executor()
5028 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5029 cx.run_until_parked();
5030 outline_panel.update(cx, |outline_panel, cx| {
5031 assert_eq!(
5032 display_entries(
5033 &snapshot(&outline_panel, cx),
5034 &outline_panel.cached_entries,
5035 None,
5036 ),
5037 all_matches,
5038 );
5039 });
5040
5041 let filter_text = "a";
5042 outline_panel.update(cx, |outline_panel, cx| {
5043 outline_panel.filter_editor.update(cx, |filter_editor, cx| {
5044 filter_editor.set_text(filter_text, cx);
5045 });
5046 });
5047 cx.executor()
5048 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5049 cx.run_until_parked();
5050
5051 outline_panel.update(cx, |outline_panel, cx| {
5052 assert_eq!(
5053 display_entries(
5054 &snapshot(&outline_panel, cx),
5055 &outline_panel.cached_entries,
5056 None,
5057 ),
5058 all_matches
5059 .lines()
5060 .filter(|item| item.contains(filter_text))
5061 .collect::<Vec<_>>()
5062 .join("\n"),
5063 );
5064 });
5065
5066 outline_panel.update(cx, |outline_panel, cx| {
5067 outline_panel.filter_editor.update(cx, |filter_editor, cx| {
5068 filter_editor.set_text("", cx);
5069 });
5070 });
5071 cx.executor()
5072 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5073 cx.run_until_parked();
5074 outline_panel.update(cx, |outline_panel, cx| {
5075 assert_eq!(
5076 display_entries(
5077 &snapshot(&outline_panel, cx),
5078 &outline_panel.cached_entries,
5079 None,
5080 ),
5081 all_matches,
5082 );
5083 });
5084 }
5085
5086 #[gpui::test(iterations = 10)]
5087 async fn test_item_opening(cx: &mut TestAppContext) {
5088 init_test(cx);
5089
5090 let fs = FakeFs::new(cx.background_executor.clone());
5091 populate_with_test_ra_project(&fs, "/rust-analyzer").await;
5092 let project = Project::test(fs.clone(), ["/rust-analyzer".as_ref()], cx).await;
5093 project.read_with(cx, |project, _| {
5094 project.languages().add(Arc::new(rust_lang()))
5095 });
5096 let workspace = add_outline_panel(&project, cx).await;
5097 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5098 let outline_panel = outline_panel(&workspace, cx);
5099 outline_panel.update(cx, |outline_panel, cx| outline_panel.set_active(true, cx));
5100
5101 workspace
5102 .update(cx, |workspace, cx| {
5103 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::default(), cx)
5104 })
5105 .unwrap();
5106 let search_view = workspace
5107 .update(cx, |workspace, cx| {
5108 workspace
5109 .active_pane()
5110 .read(cx)
5111 .items()
5112 .find_map(|item| item.downcast::<ProjectSearchView>())
5113 .expect("Project search view expected to appear after new search event trigger")
5114 })
5115 .unwrap();
5116
5117 let query = "param_names_for_lifetime_elision_hints";
5118 perform_project_search(&search_view, query, cx);
5119 search_view.update(cx, |search_view, cx| {
5120 search_view
5121 .results_editor()
5122 .update(cx, |results_editor, cx| {
5123 assert_eq!(
5124 results_editor.display_text(cx).match_indices(query).count(),
5125 9
5126 );
5127 });
5128 });
5129 let all_matches = r#"/
5130 crates/
5131 ide/src/
5132 inlay_hints/
5133 fn_lifetime_fn.rs
5134 search: match config.param_names_for_lifetime_elision_hints {
5135 search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
5136 search: Some(it) if config.param_names_for_lifetime_elision_hints => {
5137 search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
5138 inlay_hints.rs
5139 search: pub param_names_for_lifetime_elision_hints: bool,
5140 search: param_names_for_lifetime_elision_hints: self
5141 static_index.rs
5142 search: param_names_for_lifetime_elision_hints: false,
5143 rust-analyzer/src/
5144 cli/
5145 analysis_stats.rs
5146 search: param_names_for_lifetime_elision_hints: true,
5147 config.rs
5148 search: param_names_for_lifetime_elision_hints: self"#;
5149 let select_first_in_all_matches = |line_to_select: &str| {
5150 assert!(all_matches.contains(line_to_select));
5151 all_matches.replacen(
5152 line_to_select,
5153 &format!("{line_to_select}{SELECTED_MARKER}"),
5154 1,
5155 )
5156 };
5157 cx.executor()
5158 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5159 cx.run_until_parked();
5160
5161 let active_editor = outline_panel.update(cx, |outline_panel, _| {
5162 outline_panel
5163 .active_editor()
5164 .expect("should have an active editor open")
5165 });
5166 let initial_outline_selection =
5167 "search: match config.param_names_for_lifetime_elision_hints {";
5168 outline_panel.update(cx, |outline_panel, cx| {
5169 assert_eq!(
5170 display_entries(
5171 &snapshot(&outline_panel, cx),
5172 &outline_panel.cached_entries,
5173 outline_panel.selected_entry(),
5174 ),
5175 select_first_in_all_matches(initial_outline_selection)
5176 );
5177 assert_eq!(
5178 selected_row_text(&active_editor, cx),
5179 initial_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5180 "Should place the initial editor selection on the corresponding search result"
5181 );
5182
5183 outline_panel.select_next(&SelectNext, cx);
5184 outline_panel.select_next(&SelectNext, cx);
5185 });
5186
5187 let navigated_outline_selection =
5188 "search: Some(it) if config.param_names_for_lifetime_elision_hints => {";
5189 outline_panel.update(cx, |outline_panel, cx| {
5190 assert_eq!(
5191 display_entries(
5192 &snapshot(&outline_panel, cx),
5193 &outline_panel.cached_entries,
5194 outline_panel.selected_entry(),
5195 ),
5196 select_first_in_all_matches(navigated_outline_selection)
5197 );
5198 });
5199 cx.executor()
5200 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5201 outline_panel.update(cx, |_, cx| {
5202 assert_eq!(
5203 selected_row_text(&active_editor, cx),
5204 navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5205 "Should still have the initial caret position after SelectNext calls"
5206 );
5207 });
5208
5209 outline_panel.update(cx, |outline_panel, cx| {
5210 outline_panel.open(&Open, cx);
5211 });
5212 outline_panel.update(cx, |_, cx| {
5213 assert_eq!(
5214 selected_row_text(&active_editor, cx),
5215 navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5216 "After opening, should move the caret to the opened outline entry's position"
5217 );
5218 });
5219
5220 outline_panel.update(cx, |outline_panel, cx| {
5221 outline_panel.select_next(&SelectNext, cx);
5222 });
5223 let next_navigated_outline_selection =
5224 "search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },";
5225 outline_panel.update(cx, |outline_panel, cx| {
5226 assert_eq!(
5227 display_entries(
5228 &snapshot(&outline_panel, cx),
5229 &outline_panel.cached_entries,
5230 outline_panel.selected_entry(),
5231 ),
5232 select_first_in_all_matches(next_navigated_outline_selection)
5233 );
5234 });
5235 cx.executor()
5236 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5237 outline_panel.update(cx, |_, cx| {
5238 assert_eq!(
5239 selected_row_text(&active_editor, cx),
5240 next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5241 "Should again preserve the selection after another SelectNext call"
5242 );
5243 });
5244
5245 outline_panel.update(cx, |outline_panel, cx| {
5246 outline_panel.open_excerpts(&editor::OpenExcerpts, cx);
5247 });
5248 cx.executor()
5249 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5250 cx.run_until_parked();
5251 let new_active_editor = outline_panel.update(cx, |outline_panel, _| {
5252 outline_panel
5253 .active_editor()
5254 .expect("should have an active editor open")
5255 });
5256 outline_panel.update(cx, |outline_panel, cx| {
5257 assert_ne!(
5258 active_editor, new_active_editor,
5259 "After opening an excerpt, new editor should be open"
5260 );
5261 assert_eq!(
5262 display_entries(
5263 &snapshot(&outline_panel, cx),
5264 &outline_panel.cached_entries,
5265 outline_panel.selected_entry(),
5266 ),
5267 "fn_lifetime_fn.rs <==== selected"
5268 );
5269 assert_eq!(
5270 selected_row_text(&new_active_editor, cx),
5271 next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
5272 "When opening the excerpt, should navigate to the place corresponding the outline entry"
5273 );
5274 });
5275 }
5276
5277 #[gpui::test(iterations = 10)]
5278 async fn test_frontend_repo_structure(cx: &mut TestAppContext) {
5279 init_test(cx);
5280
5281 let root = "/frontend-project";
5282 let fs = FakeFs::new(cx.background_executor.clone());
5283 fs.insert_tree(
5284 root,
5285 json!({
5286 "public": {
5287 "lottie": {
5288 "syntax-tree.json": r#"{ "something": "static" }"#
5289 }
5290 },
5291 "src": {
5292 "app": {
5293 "(site)": {
5294 "(about)": {
5295 "jobs": {
5296 "[slug]": {
5297 "page.tsx": r#"static"#
5298 }
5299 }
5300 },
5301 "(blog)": {
5302 "post": {
5303 "[slug]": {
5304 "page.tsx": r#"static"#
5305 }
5306 }
5307 },
5308 }
5309 },
5310 "components": {
5311 "ErrorBoundary.tsx": r#"static"#,
5312 }
5313 }
5314
5315 }),
5316 )
5317 .await;
5318 let project = Project::test(fs.clone(), [root.as_ref()], cx).await;
5319 let workspace = add_outline_panel(&project, cx).await;
5320 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5321 let outline_panel = outline_panel(&workspace, cx);
5322 outline_panel.update(cx, |outline_panel, cx| outline_panel.set_active(true, cx));
5323
5324 workspace
5325 .update(cx, |workspace, cx| {
5326 ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::default(), cx)
5327 })
5328 .unwrap();
5329 let search_view = workspace
5330 .update(cx, |workspace, cx| {
5331 workspace
5332 .active_pane()
5333 .read(cx)
5334 .items()
5335 .find_map(|item| item.downcast::<ProjectSearchView>())
5336 .expect("Project search view expected to appear after new search event trigger")
5337 })
5338 .unwrap();
5339
5340 let query = "static";
5341 perform_project_search(&search_view, query, cx);
5342 search_view.update(cx, |search_view, cx| {
5343 search_view
5344 .results_editor()
5345 .update(cx, |results_editor, cx| {
5346 assert_eq!(
5347 results_editor.display_text(cx).match_indices(query).count(),
5348 4
5349 );
5350 });
5351 });
5352
5353 cx.executor()
5354 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5355 cx.run_until_parked();
5356 outline_panel.update(cx, |outline_panel, cx| {
5357 assert_eq!(
5358 display_entries(
5359 &snapshot(&outline_panel, cx),
5360 &outline_panel.cached_entries,
5361 outline_panel.selected_entry()
5362 ),
5363 r#"/
5364 public/lottie/
5365 syntax-tree.json
5366 search: { "something": "static" } <==== selected
5367 src/
5368 app/(site)/
5369 (about)/jobs/[slug]/
5370 page.tsx
5371 search: static
5372 (blog)/post/[slug]/
5373 page.tsx
5374 search: static
5375 components/
5376 ErrorBoundary.tsx
5377 search: static"#
5378 );
5379 });
5380
5381 outline_panel.update(cx, |outline_panel, cx| {
5382 // Move to 5th element in the list, 3 items down.
5383 for _ in 0..2 {
5384 outline_panel.select_next(&SelectNext, cx);
5385 }
5386 outline_panel.collapse_selected_entry(&CollapseSelectedEntry, cx);
5387 });
5388 cx.executor()
5389 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5390 cx.run_until_parked();
5391 outline_panel.update(cx, |outline_panel, cx| {
5392 assert_eq!(
5393 display_entries(
5394 &snapshot(&outline_panel, cx),
5395 &outline_panel.cached_entries,
5396 outline_panel.selected_entry()
5397 ),
5398 r#"/
5399 public/lottie/
5400 syntax-tree.json
5401 search: { "something": "static" }
5402 src/
5403 app/(site)/ <==== selected
5404 components/
5405 ErrorBoundary.tsx
5406 search: static"#
5407 );
5408 });
5409
5410 outline_panel.update(cx, |outline_panel, cx| {
5411 // Move to the next visible non-FS entry
5412 for _ in 0..3 {
5413 outline_panel.select_next(&SelectNext, cx);
5414 }
5415 });
5416 cx.run_until_parked();
5417 outline_panel.update(cx, |outline_panel, cx| {
5418 assert_eq!(
5419 display_entries(
5420 &snapshot(&outline_panel, cx),
5421 &outline_panel.cached_entries,
5422 outline_panel.selected_entry()
5423 ),
5424 r#"/
5425 public/lottie/
5426 syntax-tree.json
5427 search: { "something": "static" }
5428 src/
5429 app/(site)/
5430 components/
5431 ErrorBoundary.tsx
5432 search: static <==== selected"#
5433 );
5434 });
5435
5436 outline_panel.update(cx, |outline_panel, cx| {
5437 outline_panel
5438 .active_editor()
5439 .expect("Should have an active editor")
5440 .update(cx, |editor, cx| {
5441 editor.toggle_fold(&editor::actions::ToggleFold, cx)
5442 });
5443 });
5444 cx.executor()
5445 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5446 cx.run_until_parked();
5447 outline_panel.update(cx, |outline_panel, cx| {
5448 assert_eq!(
5449 display_entries(
5450 &snapshot(&outline_panel, cx),
5451 &outline_panel.cached_entries,
5452 outline_panel.selected_entry()
5453 ),
5454 r#"/
5455 public/lottie/
5456 syntax-tree.json
5457 search: { "something": "static" }
5458 src/
5459 app/(site)/
5460 components/
5461 ErrorBoundary.tsx <==== selected"#
5462 );
5463 });
5464
5465 outline_panel.update(cx, |outline_panel, cx| {
5466 outline_panel
5467 .active_editor()
5468 .expect("Should have an active editor")
5469 .update(cx, |editor, cx| {
5470 editor.toggle_fold(&editor::actions::ToggleFold, cx)
5471 });
5472 });
5473 cx.executor()
5474 .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
5475 cx.run_until_parked();
5476 outline_panel.update(cx, |outline_panel, cx| {
5477 assert_eq!(
5478 display_entries(
5479 &snapshot(&outline_panel, cx),
5480 &outline_panel.cached_entries,
5481 outline_panel.selected_entry()
5482 ),
5483 r#"/
5484 public/lottie/
5485 syntax-tree.json
5486 search: { "something": "static" }
5487 src/
5488 app/(site)/
5489 components/
5490 ErrorBoundary.tsx <==== selected
5491 search: static"#
5492 );
5493 });
5494 }
5495
5496 async fn add_outline_panel(
5497 project: &Model<Project>,
5498 cx: &mut TestAppContext,
5499 ) -> WindowHandle<Workspace> {
5500 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5501
5502 let outline_panel = window
5503 .update(cx, |_, cx| cx.spawn(OutlinePanel::load))
5504 .unwrap()
5505 .await
5506 .expect("Failed to load outline panel");
5507
5508 window
5509 .update(cx, |workspace, cx| {
5510 workspace.add_panel(outline_panel, cx);
5511 })
5512 .unwrap();
5513 window
5514 }
5515
5516 fn outline_panel(
5517 workspace: &WindowHandle<Workspace>,
5518 cx: &mut TestAppContext,
5519 ) -> View<OutlinePanel> {
5520 workspace
5521 .update(cx, |workspace, cx| {
5522 workspace
5523 .panel::<OutlinePanel>(cx)
5524 .expect("no outline panel")
5525 })
5526 .unwrap()
5527 }
5528
5529 fn display_entries(
5530 multi_buffer_snapshot: &MultiBufferSnapshot,
5531 cached_entries: &[CachedEntry],
5532 selected_entry: Option<&PanelEntry>,
5533 ) -> String {
5534 let mut display_string = String::new();
5535 for entry in cached_entries {
5536 if !display_string.is_empty() {
5537 display_string += "\n";
5538 }
5539 for _ in 0..entry.depth {
5540 display_string += " ";
5541 }
5542 display_string += &match &entry.entry {
5543 PanelEntry::Fs(entry) => match entry {
5544 FsEntry::ExternalFile(_, _) => {
5545 panic!("Did not cover external files with tests")
5546 }
5547 FsEntry::Directory(_, dir_entry) => format!(
5548 "{}/",
5549 dir_entry
5550 .path
5551 .file_name()
5552 .map(|name| name.to_string_lossy().to_string())
5553 .unwrap_or_default()
5554 ),
5555 FsEntry::File(_, file_entry, ..) => file_entry
5556 .path
5557 .file_name()
5558 .map(|name| name.to_string_lossy().to_string())
5559 .unwrap_or_default(),
5560 },
5561 PanelEntry::FoldedDirs(_, dirs) => dirs
5562 .iter()
5563 .filter_map(|dir| dir.path.file_name())
5564 .map(|name| name.to_string_lossy().to_string() + "/")
5565 .collect(),
5566 PanelEntry::Outline(outline_entry) => match outline_entry {
5567 OutlineEntry::Excerpt(_, _, _) => continue,
5568 OutlineEntry::Outline(_, _, outline) => format!("outline: {}", outline.text),
5569 },
5570 PanelEntry::Search(SearchEntry {
5571 render_data,
5572 match_range,
5573 ..
5574 }) => {
5575 format!(
5576 "search: {}",
5577 render_data
5578 .get_or_init(|| SearchData::new(match_range, &multi_buffer_snapshot))
5579 .context_text
5580 )
5581 }
5582 };
5583
5584 if Some(&entry.entry) == selected_entry {
5585 display_string += SELECTED_MARKER;
5586 }
5587 }
5588 display_string
5589 }
5590
5591 fn init_test(cx: &mut TestAppContext) {
5592 cx.update(|cx| {
5593 let settings = SettingsStore::test(cx);
5594 cx.set_global(settings);
5595
5596 theme::init(theme::LoadThemes::JustBase, cx);
5597
5598 language::init(cx);
5599 editor::init(cx);
5600 workspace::init_settings(cx);
5601 Project::init_settings(cx);
5602 project_search::init(cx);
5603 super::init((), cx);
5604 });
5605 }
5606
5607 // Based on https://github.com/rust-lang/rust-analyzer/
5608 async fn populate_with_test_ra_project(fs: &FakeFs, root: &str) {
5609 fs.insert_tree(
5610 root,
5611 json!({
5612 "crates": {
5613 "ide": {
5614 "src": {
5615 "inlay_hints": {
5616 "fn_lifetime_fn.rs": r##"
5617 pub(super) fn hints(
5618 acc: &mut Vec<InlayHint>,
5619 config: &InlayHintsConfig,
5620 func: ast::Fn,
5621 ) -> Option<()> {
5622 // ... snip
5623
5624 let mut used_names: FxHashMap<SmolStr, usize> =
5625 match config.param_names_for_lifetime_elision_hints {
5626 true => generic_param_list
5627 .iter()
5628 .flat_map(|gpl| gpl.lifetime_params())
5629 .filter_map(|param| param.lifetime())
5630 .filter_map(|lt| Some((SmolStr::from(lt.text().as_str().get(1..)?), 0)))
5631 .collect(),
5632 false => Default::default(),
5633 };
5634 {
5635 let mut potential_lt_refs = potential_lt_refs.iter().filter(|&&(.., is_elided)| is_elided);
5636 if self_param.is_some() && potential_lt_refs.next().is_some() {
5637 allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
5638 // self can't be used as a lifetime, so no need to check for collisions
5639 "'self".into()
5640 } else {
5641 gen_idx_name()
5642 });
5643 }
5644 potential_lt_refs.for_each(|(name, ..)| {
5645 let name = match name {
5646 Some(it) if config.param_names_for_lifetime_elision_hints => {
5647 if let Some(c) = used_names.get_mut(it.text().as_str()) {
5648 *c += 1;
5649 SmolStr::from(format!("'{text}{c}", text = it.text().as_str()))
5650 } else {
5651 used_names.insert(it.text().as_str().into(), 0);
5652 SmolStr::from_iter(["\'", it.text().as_str()])
5653 }
5654 }
5655 _ => gen_idx_name(),
5656 };
5657 allocated_lifetimes.push(name);
5658 });
5659 }
5660
5661 // ... snip
5662 }
5663
5664 // ... snip
5665
5666 #[test]
5667 fn hints_lifetimes_named() {
5668 check_with_config(
5669 InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
5670 r#"
5671 fn nested_in<'named>(named: & &X< &()>) {}
5672 // ^'named1, 'named2, 'named3, $
5673 //^'named1 ^'named2 ^'named3
5674 "#,
5675 );
5676 }
5677
5678 // ... snip
5679 "##,
5680 },
5681 "inlay_hints.rs": r#"
5682 #[derive(Clone, Debug, PartialEq, Eq)]
5683 pub struct InlayHintsConfig {
5684 // ... snip
5685 pub param_names_for_lifetime_elision_hints: bool,
5686 pub max_length: Option<usize>,
5687 // ... snip
5688 }
5689
5690 impl Config {
5691 pub fn inlay_hints(&self) -> InlayHintsConfig {
5692 InlayHintsConfig {
5693 // ... snip
5694 param_names_for_lifetime_elision_hints: self
5695 .inlayHints_lifetimeElisionHints_useParameterNames()
5696 .to_owned(),
5697 max_length: self.inlayHints_maxLength().to_owned(),
5698 // ... snip
5699 }
5700 }
5701 }
5702 "#,
5703 "static_index.rs": r#"
5704// ... snip
5705 fn add_file(&mut self, file_id: FileId) {
5706 let current_crate = crates_for(self.db, file_id).pop().map(Into::into);
5707 let folds = self.analysis.folding_ranges(file_id).unwrap();
5708 let inlay_hints = self
5709 .analysis
5710 .inlay_hints(
5711 &InlayHintsConfig {
5712 // ... snip
5713 closure_style: hir::ClosureStyle::ImplFn,
5714 param_names_for_lifetime_elision_hints: false,
5715 binding_mode_hints: false,
5716 max_length: Some(25),
5717 closure_capture_hints: false,
5718 // ... snip
5719 },
5720 file_id,
5721 None,
5722 )
5723 .unwrap();
5724 // ... snip
5725 }
5726// ... snip
5727 "#
5728 }
5729 },
5730 "rust-analyzer": {
5731 "src": {
5732 "cli": {
5733 "analysis_stats.rs": r#"
5734 // ... snip
5735 for &file_id in &file_ids {
5736 _ = analysis.inlay_hints(
5737 &InlayHintsConfig {
5738 // ... snip
5739 implicit_drop_hints: true,
5740 lifetime_elision_hints: ide::LifetimeElisionHints::Always,
5741 param_names_for_lifetime_elision_hints: true,
5742 hide_named_constructor_hints: false,
5743 hide_closure_initialization_hints: false,
5744 closure_style: hir::ClosureStyle::ImplFn,
5745 max_length: Some(25),
5746 closing_brace_hints_min_lines: Some(20),
5747 fields_to_resolve: InlayFieldsToResolve::empty(),
5748 range_exclusive_hints: true,
5749 },
5750 file_id.into(),
5751 None,
5752 );
5753 }
5754 // ... snip
5755 "#,
5756 },
5757 "config.rs": r#"
5758 config_data! {
5759 /// Configs that only make sense when they are set by a client. As such they can only be defined
5760 /// by setting them using client's settings (e.g `settings.json` on VS Code).
5761 client: struct ClientDefaultConfigData <- ClientConfigInput -> {
5762 // ... snip
5763 /// Maximum length for inlay hints. Set to null to have an unlimited length.
5764 inlayHints_maxLength: Option<usize> = Some(25),
5765 // ... snip
5766 /// Whether to prefer using parameter names as the name for elided lifetime hints if possible.
5767 inlayHints_lifetimeElisionHints_useParameterNames: bool = false,
5768 // ... snip
5769 }
5770 }
5771
5772 impl Config {
5773 // ... snip
5774 pub fn inlay_hints(&self) -> InlayHintsConfig {
5775 InlayHintsConfig {
5776 // ... snip
5777 param_names_for_lifetime_elision_hints: self
5778 .inlayHints_lifetimeElisionHints_useParameterNames()
5779 .to_owned(),
5780 max_length: self.inlayHints_maxLength().to_owned(),
5781 // ... snip
5782 }
5783 }
5784 // ... snip
5785 }
5786 "#
5787 }
5788 }
5789 }
5790 }),
5791 )
5792 .await;
5793 }
5794
5795 fn rust_lang() -> Language {
5796 Language::new(
5797 LanguageConfig {
5798 name: "Rust".into(),
5799 matcher: LanguageMatcher {
5800 path_suffixes: vec!["rs".to_string()],
5801 ..Default::default()
5802 },
5803 ..Default::default()
5804 },
5805 Some(tree_sitter_rust::LANGUAGE.into()),
5806 )
5807 .with_highlights_query(
5808 r#"
5809 (field_identifier) @field
5810 (struct_expression) @struct
5811 "#,
5812 )
5813 .unwrap()
5814 .with_injection_query(
5815 r#"
5816 (macro_invocation
5817 (token_tree) @content
5818 (#set! "language" "rust"))
5819 "#,
5820 )
5821 .unwrap()
5822 }
5823
5824 fn snapshot(outline_panel: &OutlinePanel, cx: &AppContext) -> MultiBufferSnapshot {
5825 outline_panel
5826 .active_editor()
5827 .unwrap()
5828 .read(cx)
5829 .buffer()
5830 .read(cx)
5831 .snapshot(cx)
5832 }
5833
5834 fn selected_row_text(editor: &View<Editor>, cx: &mut WindowContext) -> String {
5835 editor.update(cx, |editor, cx| {
5836 let selections = editor.selections.all::<language::Point>(cx);
5837 assert_eq!(selections.len(), 1, "Active editor should have exactly one selection after any outline panel interactions");
5838 let selection = selections.first().unwrap();
5839 let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
5840 let line_start = language::Point::new(selection.start.row, 0);
5841 let line_end = multi_buffer_snapshot.clip_point(language::Point::new(selection.end.row, u32::MAX), language::Bias::Right);
5842 multi_buffer_snapshot.text_for_range(line_start..line_end).collect::<String>().trim().to_owned()
5843 })
5844 }
5845}