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