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