mod outline_panel_settings; use std::{ cmp, collections::BTreeMap, hash::Hash, ops::Range, path::{MAIN_SEPARATOR_STR, Path, PathBuf}, sync::{ Arc, OnceLock, atomic::{self, AtomicBool}, }, time::Duration, u32, }; use anyhow::Context as _; use collections::{BTreeSet, HashMap, HashSet, hash_map}; use db::kvp::KEY_VALUE_STORE; use editor::{ AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, MultiBufferSnapshot, RangeToAnchorExt, SelectionEffects, ShowScrollbar, display_map::ToDisplayPoint, items::{entry_git_aware_label_color, entry_label_color}, scroll::{Autoscroll, ScrollAnchor, ScrollbarAutoHide}, }; use file_icons::FileIcons; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ Action, AnyElement, App, AppContext as _, AsyncWindowContext, Bounds, ClipboardItem, Context, DismissEvent, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, InteractiveElement, IntoElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollStrategy, SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, point, px, size, uniform_list, }; use itertools::Itertools; use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides}; use project::{File, Fs, GitEntry, GitTraversal, Project, ProjectItem}; use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use smol::channel; use theme::{SyntaxTheme, ThemeSettings}; use ui::{DynamicSpacing, IndentGuideColors, IndentGuideLayout}; use util::{RangeExt, ResultExt, TryFutureExt, debug_panic}; use workspace::{ OpenInTerminal, WeakItemHandle, Workspace, dock::{DockPosition, Panel, PanelEvent}, item::ItemHandle, searchable::{SearchEvent, SearchableItem}, ui::{ ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, FluentBuilder, HighlightedLabel, Icon, IconButton, IconButtonShape, IconName, IconSize, Label, LabelCommon, ListItem, Scrollbar, ScrollbarState, StyledExt, StyledTypography, Toggleable, Tooltip, h_flex, v_flex, }, }; use worktree::{Entry, ProjectEntryId, WorktreeId}; actions!( outline_panel, [ /// Collapses all entries in the outline tree. CollapseAllEntries, /// Collapses the currently selected entry. CollapseSelectedEntry, /// Expands all entries in the outline tree. ExpandAllEntries, /// Expands the currently selected entry. ExpandSelectedEntry, /// Folds the selected directory. FoldDirectory, /// Opens the selected entry in the editor. OpenSelectedEntry, /// Reveals the selected item in the system file manager. RevealInFileManager, /// Selects the parent of the current entry. SelectParent, /// Toggles the pin status of the active editor. ToggleActiveEditorPin, /// Unfolds the selected directory. UnfoldDirectory, /// Toggles focus on the outline panel. ToggleFocus, ] ); const OUTLINE_PANEL_KEY: &str = "OutlinePanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); type Outline = OutlineItem; type HighlightStyleData = Arc, HighlightStyle)>>>; pub struct OutlinePanel { fs: Arc, width: Option, project: Entity, workspace: WeakEntity, active: bool, pinned: bool, scroll_handle: UniformListScrollHandle, context_menu: Option<(Entity, Point, Subscription)>, focus_handle: FocusHandle, pending_serialization: Task>, fs_entries_depth: HashMap<(WorktreeId, ProjectEntryId), usize>, fs_entries: Vec, fs_children_count: HashMap, FsChildren>>, collapsed_entries: HashSet, unfolded_dirs: HashMap>, selected_entry: SelectedEntry, active_item: Option, _subscriptions: Vec, updating_fs_entries: bool, updating_cached_entries: bool, new_entries_for_fs_update: HashSet, fs_entries_update_task: Task<()>, cached_entries_update_task: Task<()>, reveal_selection_task: Task>, outline_fetch_tasks: HashMap<(BufferId, ExcerptId), Task<()>>, excerpts: HashMap>, cached_entries: Vec, filter_editor: Entity, mode: ItemsDisplayMode, show_scrollbar: bool, vertical_scrollbar_state: ScrollbarState, horizontal_scrollbar_state: ScrollbarState, hide_scrollbar_task: Option>, max_width_item_index: Option, preserve_selection_on_buffer_fold_toggles: HashSet, } #[derive(Debug)] enum ItemsDisplayMode { Search(SearchState), Outline, } #[derive(Debug)] struct SearchState { kind: SearchKind, query: String, matches: Vec<(Range, Arc>)>, highlight_search_match_tx: channel::Sender, _search_match_highlighter: Task<()>, _search_match_notify: Task<()>, } struct HighlightArguments { multi_buffer_snapshot: MultiBufferSnapshot, match_range: Range, search_data: Arc>, } impl SearchState { fn new( kind: SearchKind, query: String, previous_matches: HashMap, Arc>>, new_matches: Vec>, theme: Arc, window: &mut Window, cx: &mut Context, ) -> Self { let (highlight_search_match_tx, highlight_search_match_rx) = channel::unbounded(); let (notify_tx, notify_rx) = channel::unbounded::<()>(); Self { kind, query, matches: new_matches .into_iter() .map(|range| { let search_data = previous_matches .get(&range) .map(Arc::clone) .unwrap_or_default(); (range, search_data) }) .collect(), highlight_search_match_tx, _search_match_highlighter: cx.background_spawn(async move { while let Ok(highlight_arguments) = highlight_search_match_rx.recv().await { let needs_init = highlight_arguments.search_data.get().is_none(); let search_data = highlight_arguments.search_data.get_or_init(|| { SearchData::new( &highlight_arguments.match_range, &highlight_arguments.multi_buffer_snapshot, ) }); if needs_init { notify_tx.try_send(()).ok(); } let highlight_data = &search_data.highlights_data; if highlight_data.get().is_some() { continue; } let mut left_whitespaces_count = 0; let mut non_whitespace_symbol_occurred = false; let context_offset_range = search_data .context_range .to_offset(&highlight_arguments.multi_buffer_snapshot); let mut offset = context_offset_range.start; let mut context_text = String::new(); let mut highlight_ranges = Vec::new(); for mut chunk in highlight_arguments .multi_buffer_snapshot .chunks(context_offset_range.start..context_offset_range.end, true) { if !non_whitespace_symbol_occurred { for c in chunk.text.chars() { if c.is_whitespace() { left_whitespaces_count += c.len_utf8(); } else { non_whitespace_symbol_occurred = true; break; } } } if chunk.text.len() > context_offset_range.end - offset { chunk.text = &chunk.text[0..(context_offset_range.end - offset)]; offset = context_offset_range.end; } else { offset += chunk.text.len(); } let style = chunk .syntax_highlight_id .and_then(|highlight| highlight.style(&theme)); if let Some(style) = style { let start = context_text.len(); let end = start + chunk.text.len(); highlight_ranges.push((start..end, style)); } context_text.push_str(chunk.text); if offset >= context_offset_range.end { break; } } highlight_ranges.iter_mut().for_each(|(range, _)| { range.start = range.start.saturating_sub(left_whitespaces_count); range.end = range.end.saturating_sub(left_whitespaces_count); }); if highlight_data.set(highlight_ranges).ok().is_some() { notify_tx.try_send(()).ok(); } let trimmed_text = context_text[left_whitespaces_count..].to_owned(); debug_assert_eq!( trimmed_text, search_data.context_text, "Highlighted text that does not match the buffer text" ); } }), _search_match_notify: cx.spawn_in(window, async move |outline_panel, cx| { loop { match notify_rx.recv().await { Ok(()) => {} Err(_) => break, }; while let Ok(()) = notify_rx.try_recv() { // } let update_result = outline_panel.update(cx, |_, cx| { cx.notify(); }); if update_result.is_err() { break; } } }), } } } #[derive(Debug)] enum SelectedEntry { Invalidated(Option), Valid(PanelEntry, usize), None, } impl SelectedEntry { fn invalidate(&mut self) { match std::mem::replace(self, SelectedEntry::None) { Self::Valid(entry, _) => *self = Self::Invalidated(Some(entry)), Self::None => *self = Self::Invalidated(None), other => *self = other, } } fn is_invalidated(&self) -> bool { matches!(self, Self::Invalidated(_)) } } #[derive(Debug, Clone, Copy, Default)] struct FsChildren { files: usize, dirs: usize, } impl FsChildren { fn may_be_fold_part(&self) -> bool { self.dirs == 0 || (self.dirs == 1 && self.files == 0) } } #[derive(Clone, Debug)] struct CachedEntry { depth: usize, string_match: Option, entry: PanelEntry, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum CollapsedEntry { Dir(WorktreeId, ProjectEntryId), File(WorktreeId, BufferId), ExternalFile(BufferId), Excerpt(BufferId, ExcerptId), } #[derive(Debug)] struct Excerpt { range: ExcerptRange, outlines: ExcerptOutlines, } impl Excerpt { fn invalidate_outlines(&mut self) { if let ExcerptOutlines::Outlines(valid_outlines) = &mut self.outlines { self.outlines = ExcerptOutlines::Invalidated(std::mem::take(valid_outlines)); } } fn iter_outlines(&self) -> impl Iterator { match &self.outlines { ExcerptOutlines::Outlines(outlines) => outlines.iter(), ExcerptOutlines::Invalidated(outlines) => outlines.iter(), ExcerptOutlines::NotFetched => [].iter(), } } fn should_fetch_outlines(&self) -> bool { match &self.outlines { ExcerptOutlines::Outlines(_) => false, ExcerptOutlines::Invalidated(_) => true, ExcerptOutlines::NotFetched => true, } } } #[derive(Debug)] enum ExcerptOutlines { Outlines(Vec), Invalidated(Vec), NotFetched, } #[derive(Clone, Debug, PartialEq, Eq)] struct FoldedDirsEntry { worktree_id: WorktreeId, entries: Vec, } // TODO: collapse the inner enums into panel entry #[derive(Clone, Debug)] enum PanelEntry { Fs(FsEntry), FoldedDirs(FoldedDirsEntry), Outline(OutlineEntry), Search(SearchEntry), } #[derive(Clone, Debug)] struct SearchEntry { match_range: Range, kind: SearchKind, render_data: Arc>, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] enum SearchKind { Project, Buffer, } #[derive(Clone, Debug)] struct SearchData { context_range: Range, context_text: String, truncated_left: bool, truncated_right: bool, search_match_indices: Vec>, highlights_data: HighlightStyleData, } impl PartialEq for PanelEntry { fn eq(&self, other: &Self) -> bool { match (self, other) { (Self::Fs(a), Self::Fs(b)) => a == b, ( Self::FoldedDirs(FoldedDirsEntry { worktree_id: worktree_id_a, entries: entries_a, }), Self::FoldedDirs(FoldedDirsEntry { worktree_id: worktree_id_b, entries: entries_b, }), ) => worktree_id_a == worktree_id_b && entries_a == entries_b, (Self::Outline(a), Self::Outline(b)) => a == b, ( Self::Search(SearchEntry { match_range: match_range_a, kind: kind_a, .. }), Self::Search(SearchEntry { match_range: match_range_b, kind: kind_b, .. }), ) => match_range_a == match_range_b && kind_a == kind_b, _ => false, } } } impl Eq for PanelEntry {} const SEARCH_MATCH_CONTEXT_SIZE: u32 = 40; const TRUNCATED_CONTEXT_MARK: &str = "…"; impl SearchData { fn new( match_range: &Range, multi_buffer_snapshot: &MultiBufferSnapshot, ) -> Self { let match_point_range = match_range.to_point(multi_buffer_snapshot); let context_left_border = multi_buffer_snapshot.clip_point( language::Point::new( match_point_range.start.row, match_point_range .start .column .saturating_sub(SEARCH_MATCH_CONTEXT_SIZE), ), Bias::Left, ); let context_right_border = multi_buffer_snapshot.clip_point( language::Point::new( match_point_range.end.row, match_point_range.end.column + SEARCH_MATCH_CONTEXT_SIZE, ), Bias::Right, ); let context_anchor_range = (context_left_border..context_right_border).to_anchors(multi_buffer_snapshot); let context_offset_range = context_anchor_range.to_offset(multi_buffer_snapshot); let match_offset_range = match_range.to_offset(multi_buffer_snapshot); let mut search_match_indices = vec![ multi_buffer_snapshot.clip_offset( match_offset_range.start - context_offset_range.start, Bias::Left, ) ..multi_buffer_snapshot.clip_offset( match_offset_range.end - context_offset_range.start, Bias::Right, ), ]; let entire_context_text = multi_buffer_snapshot .text_for_range(context_offset_range.clone()) .collect::(); let left_whitespaces_offset = entire_context_text .chars() .take_while(|c| c.is_whitespace()) .map(|c| c.len_utf8()) .sum::(); let mut extended_context_left_border = context_left_border; extended_context_left_border.column = extended_context_left_border.column.saturating_sub(1); let extended_context_left_border = multi_buffer_snapshot.clip_point(extended_context_left_border, Bias::Left); let mut extended_context_right_border = context_right_border; extended_context_right_border.column += 1; let extended_context_right_border = multi_buffer_snapshot.clip_point(extended_context_right_border, Bias::Right); let truncated_left = left_whitespaces_offset == 0 && extended_context_left_border < context_left_border && multi_buffer_snapshot .chars_at(extended_context_left_border) .last() .map_or(false, |c| !c.is_whitespace()); let truncated_right = entire_context_text .chars() .last() .map_or(true, |c| !c.is_whitespace()) && extended_context_right_border > context_right_border && multi_buffer_snapshot .chars_at(extended_context_right_border) .next() .map_or(false, |c| !c.is_whitespace()); search_match_indices.iter_mut().for_each(|range| { range.start = multi_buffer_snapshot.clip_offset( range.start.saturating_sub(left_whitespaces_offset), Bias::Left, ); range.end = multi_buffer_snapshot.clip_offset( range.end.saturating_sub(left_whitespaces_offset), Bias::Right, ); }); let trimmed_row_offset_range = context_offset_range.start + left_whitespaces_offset..context_offset_range.end; let trimmed_text = entire_context_text[left_whitespaces_offset..].to_owned(); Self { highlights_data: Arc::default(), search_match_indices, context_range: trimmed_row_offset_range.to_anchors(multi_buffer_snapshot), context_text: trimmed_text, truncated_left, truncated_right, } } } #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct OutlineEntryExcerpt { id: ExcerptId, buffer_id: BufferId, range: ExcerptRange, } #[derive(Clone, Debug, Eq)] struct OutlineEntryOutline { buffer_id: BufferId, excerpt_id: ExcerptId, outline: Outline, } impl PartialEq for OutlineEntryOutline { fn eq(&self, other: &Self) -> bool { self.buffer_id == other.buffer_id && self.excerpt_id == other.excerpt_id && self.outline.depth == other.outline.depth && self.outline.range == other.outline.range && self.outline.text == other.outline.text } } impl Hash for OutlineEntryOutline { fn hash(&self, state: &mut H) { ( self.buffer_id, self.excerpt_id, self.outline.depth, &self.outline.range, &self.outline.text, ) .hash(state); } } #[derive(Clone, Debug, PartialEq, Eq)] enum OutlineEntry { Excerpt(OutlineEntryExcerpt), Outline(OutlineEntryOutline), } impl OutlineEntry { fn ids(&self) -> (BufferId, ExcerptId) { match self { OutlineEntry::Excerpt(excerpt) => (excerpt.buffer_id, excerpt.id), OutlineEntry::Outline(outline) => (outline.buffer_id, outline.excerpt_id), } } } #[derive(Debug, Clone, Eq)] struct FsEntryFile { worktree_id: WorktreeId, entry: GitEntry, buffer_id: BufferId, excerpts: Vec, } impl PartialEq for FsEntryFile { fn eq(&self, other: &Self) -> bool { self.worktree_id == other.worktree_id && self.entry.id == other.entry.id && self.buffer_id == other.buffer_id } } impl Hash for FsEntryFile { fn hash(&self, state: &mut H) { (self.buffer_id, self.entry.id, self.worktree_id).hash(state); } } #[derive(Debug, Clone, Eq)] struct FsEntryDirectory { worktree_id: WorktreeId, entry: GitEntry, } impl PartialEq for FsEntryDirectory { fn eq(&self, other: &Self) -> bool { self.worktree_id == other.worktree_id && self.entry.id == other.entry.id } } impl Hash for FsEntryDirectory { fn hash(&self, state: &mut H) { (self.worktree_id, self.entry.id).hash(state); } } #[derive(Debug, Clone, Eq)] struct FsEntryExternalFile { buffer_id: BufferId, excerpts: Vec, } impl PartialEq for FsEntryExternalFile { fn eq(&self, other: &Self) -> bool { self.buffer_id == other.buffer_id } } impl Hash for FsEntryExternalFile { fn hash(&self, state: &mut H) { self.buffer_id.hash(state); } } #[derive(Clone, Debug, Eq, PartialEq)] enum FsEntry { ExternalFile(FsEntryExternalFile), Directory(FsEntryDirectory), File(FsEntryFile), } struct ActiveItem { item_handle: Box, active_editor: WeakEntity, _buffer_search_subscription: Subscription, _editor_subscription: Subscription, } #[derive(Debug)] pub enum Event { Focus, } #[derive(Serialize, Deserialize)] struct SerializedOutlinePanel { width: Option, active: Option, } pub fn init_settings(cx: &mut App) { OutlinePanelSettings::register(cx); } pub fn init(cx: &mut App) { init_settings(cx); cx.observe_new(|workspace: &mut Workspace, _, _| { workspace.register_action(|workspace, _: &ToggleFocus, window, cx| { workspace.toggle_panel_focus::(window, cx); }); }) .detach(); } impl OutlinePanel { pub async fn load( workspace: WeakEntity, mut cx: AsyncWindowContext, ) -> anyhow::Result> { let serialized_panel = match workspace .read_with(&cx, |workspace, _| { OutlinePanel::serialization_key(workspace) }) .ok() .flatten() { Some(serialization_key) => cx .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) }) .await .context("loading outline panel") .log_err() .flatten() .map(|panel| serde_json::from_str::(&panel)) .transpose() .log_err() .flatten(), None => None, }; workspace.update_in(&mut cx, |workspace, window, cx| { let panel = Self::new(workspace, window, cx); if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|px| px.round()); panel.active = serialized_panel.active.unwrap_or(false); cx.notify(); }); } panel }) } fn new( workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) -> Entity { let project = workspace.project().clone(); let workspace_handle = cx.entity().downgrade(); let outline_panel = cx.new(|cx| { let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_placeholder_text("Filter...", cx); editor }); let filter_update_subscription = cx.subscribe_in( &filter_editor, window, |outline_panel: &mut Self, _, event, window, cx| { if let editor::EditorEvent::BufferEdited = event { outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); } }, ); let focus_handle = cx.focus_handle(); let focus_subscription = cx.on_focus(&focus_handle, window, Self::focus_in); let focus_out_subscription = cx.on_focus_out(&focus_handle, window, |outline_panel, _, window, cx| { outline_panel.hide_scrollbar(window, cx); }); let workspace_subscription = cx.subscribe_in( &workspace .weak_handle() .upgrade() .expect("have a &mut Workspace"), window, move |outline_panel, workspace, event, window, cx| { if let workspace::Event::ActiveItemChanged = event { if let Some((new_active_item, new_active_editor)) = workspace_active_editor(workspace.read(cx), cx) { if outline_panel.should_replace_active_item(new_active_item.as_ref()) { outline_panel.replace_active_editor( new_active_item, new_active_editor, window, cx, ); } } else { outline_panel.clear_previous(window, cx); cx.notify(); } } }, ); let icons_subscription = cx.observe_global::(|_, cx| { cx.notify(); }); let mut outline_panel_settings = *OutlinePanelSettings::get_global(cx); let mut current_theme = ThemeSettings::get_global(cx).clone(); let settings_subscription = cx.observe_global_in::(window, move |outline_panel, window, cx| { let new_settings = OutlinePanelSettings::get_global(cx); let new_theme = ThemeSettings::get_global(cx); if ¤t_theme != new_theme { outline_panel_settings = *new_settings; current_theme = new_theme.clone(); for excerpts in outline_panel.excerpts.values_mut() { for excerpt in excerpts.values_mut() { excerpt.invalidate_outlines(); } } let update_cached_items = outline_panel.update_non_fs_items(window, cx); if update_cached_items { outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); } } else if &outline_panel_settings != new_settings { outline_panel_settings = *new_settings; cx.notify(); } }); let scroll_handle = UniformListScrollHandle::new(); let mut outline_panel = Self { mode: ItemsDisplayMode::Outline, active: false, pinned: false, workspace: workspace_handle, project, fs: workspace.app_state().fs.clone(), show_scrollbar: !Self::should_autohide_scrollbar(cx), hide_scrollbar_task: None, vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone()) .parent_entity(&cx.entity()), horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone()) .parent_entity(&cx.entity()), max_width_item_index: None, scroll_handle, focus_handle, filter_editor, fs_entries: Vec::new(), fs_entries_depth: HashMap::default(), fs_children_count: HashMap::default(), collapsed_entries: HashSet::default(), unfolded_dirs: HashMap::default(), selected_entry: SelectedEntry::None, context_menu: None, width: None, active_item: None, pending_serialization: Task::ready(None), updating_fs_entries: false, updating_cached_entries: false, new_entries_for_fs_update: HashSet::default(), preserve_selection_on_buffer_fold_toggles: HashSet::default(), fs_entries_update_task: Task::ready(()), cached_entries_update_task: Task::ready(()), reveal_selection_task: Task::ready(Ok(())), outline_fetch_tasks: HashMap::default(), excerpts: HashMap::default(), cached_entries: Vec::new(), _subscriptions: vec![ settings_subscription, icons_subscription, focus_subscription, focus_out_subscription, workspace_subscription, filter_update_subscription, ], }; if let Some((item, editor)) = workspace_active_editor(workspace, cx) { outline_panel.replace_active_editor(item, editor, window, cx); } outline_panel }); outline_panel } fn serialization_key(workspace: &Workspace) -> Option { workspace .database_id() .map(|id| i64::from(id).to_string()) .or(workspace.session_id()) .map(|id| format!("{}-{:?}", OUTLINE_PANEL_KEY, id)) } fn serialize(&mut self, cx: &mut Context) { let Some(serialization_key) = self .workspace .read_with(cx, |workspace, _| { OutlinePanel::serialization_key(workspace) }) .ok() .flatten() else { return; }; let width = self.width; let active = Some(self.active); self.pending_serialization = cx.background_spawn( async move { KEY_VALUE_STORE .write_kvp( serialization_key, serde_json::to_string(&SerializedOutlinePanel { width, active })?, ) .await?; anyhow::Ok(()) } .log_err(), ); } fn dispatch_context(&self, window: &mut Window, cx: &mut Context) -> KeyContext { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("OutlinePanel"); dispatch_context.add("menu"); let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) { "editing" } else { "not_editing" }; dispatch_context.add(identifier); dispatch_context } fn unfold_directory( &mut self, _: &UnfoldDirectory, window: &mut Window, cx: &mut Context, ) { if let Some(PanelEntry::FoldedDirs(FoldedDirsEntry { worktree_id, entries, .. })) = self.selected_entry().cloned() { self.unfolded_dirs .entry(worktree_id) .or_default() .extend(entries.iter().map(|entry| entry.id)); self.update_cached_entries(None, window, cx); } } fn fold_directory(&mut self, _: &FoldDirectory, window: &mut Window, cx: &mut Context) { let (worktree_id, entry) = match self.selected_entry().cloned() { Some(PanelEntry::Fs(FsEntry::Directory(directory))) => { (directory.worktree_id, Some(directory.entry)) } Some(PanelEntry::FoldedDirs(folded_dirs)) => { (folded_dirs.worktree_id, folded_dirs.entries.last().cloned()) } _ => return, }; let Some(entry) = entry else { return; }; let unfolded_dirs = self.unfolded_dirs.get_mut(&worktree_id); let worktree = self .project .read(cx) .worktree_for_id(worktree_id, cx) .map(|w| w.read(cx).snapshot()); let Some((_, unfolded_dirs)) = worktree.zip(unfolded_dirs) else { return; }; unfolded_dirs.remove(&entry.id); self.update_cached_entries(None, window, cx); } fn open_selected_entry( &mut self, _: &OpenSelectedEntry, window: &mut Window, cx: &mut Context, ) { if self.filter_editor.focus_handle(cx).is_focused(window) { cx.propagate() } else if let Some(selected_entry) = self.selected_entry().cloned() { self.toggle_expanded(&selected_entry, window, cx); self.scroll_editor_to_entry(&selected_entry, true, true, window, cx); } } fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.filter_editor.focus_handle(cx).is_focused(window) { self.focus_handle.focus(window); } else { self.filter_editor.focus_handle(cx).focus(window); } if self.context_menu.is_some() { self.context_menu.take(); cx.notify(); } } fn open_excerpts( &mut self, action: &editor::OpenExcerpts, window: &mut Window, cx: &mut Context, ) { if self.filter_editor.focus_handle(cx).is_focused(window) { cx.propagate() } else if let Some((active_editor, selected_entry)) = self.active_editor().zip(self.selected_entry().cloned()) { self.scroll_editor_to_entry(&selected_entry, true, true, window, cx); active_editor.update(cx, |editor, cx| editor.open_excerpts(action, window, cx)); } } fn open_excerpts_split( &mut self, action: &editor::OpenExcerptsSplit, window: &mut Window, cx: &mut Context, ) { if self.filter_editor.focus_handle(cx).is_focused(window) { cx.propagate() } else if let Some((active_editor, selected_entry)) = self.active_editor().zip(self.selected_entry().cloned()) { self.scroll_editor_to_entry(&selected_entry, true, true, window, cx); active_editor.update(cx, |editor, cx| { editor.open_excerpts_in_split(action, window, cx) }); } } fn scroll_editor_to_entry( &mut self, entry: &PanelEntry, prefer_selection_change: bool, prefer_focus_change: bool, window: &mut Window, cx: &mut Context, ) { let Some(active_editor) = self.active_editor() else { return; }; let active_multi_buffer = active_editor.read(cx).buffer().clone(); let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); let mut change_selection = prefer_selection_change; let mut change_focus = prefer_focus_change; let mut scroll_to_buffer = None; let scroll_target = match entry { PanelEntry::FoldedDirs(..) | PanelEntry::Fs(FsEntry::Directory(..)) => { change_focus = false; None } PanelEntry::Fs(FsEntry::ExternalFile(file)) => { change_selection = false; scroll_to_buffer = Some(file.buffer_id); multi_buffer_snapshot.excerpts().find_map( |(excerpt_id, buffer_snapshot, excerpt_range)| { if buffer_snapshot.remote_id() == file.buffer_id { multi_buffer_snapshot .anchor_in_excerpt(excerpt_id, excerpt_range.context.start) } else { None } }, ) } PanelEntry::Fs(FsEntry::File(file)) => { change_selection = false; scroll_to_buffer = Some(file.buffer_id); self.project .update(cx, |project, cx| { project .path_for_entry(file.entry.id, cx) .and_then(|path| project.get_open_buffer(&path, cx)) }) .map(|buffer| { active_multi_buffer .read(cx) .excerpts_for_buffer(buffer.read(cx).remote_id(), cx) }) .and_then(|excerpts| { let (excerpt_id, excerpt_range) = excerpts.first()?; multi_buffer_snapshot .anchor_in_excerpt(*excerpt_id, excerpt_range.context.start) }) } PanelEntry::Outline(OutlineEntry::Outline(outline)) => multi_buffer_snapshot .anchor_in_excerpt(outline.excerpt_id, outline.outline.range.start) .or_else(|| { multi_buffer_snapshot .anchor_in_excerpt(outline.excerpt_id, outline.outline.range.end) }), PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { change_selection = false; change_focus = false; multi_buffer_snapshot.anchor_in_excerpt(excerpt.id, excerpt.range.context.start) } PanelEntry::Search(search_entry) => Some(search_entry.match_range.start), }; if let Some(anchor) = scroll_target { let activate = self .workspace .update(cx, |workspace, cx| match self.active_item() { Some(active_item) => workspace.activate_item( active_item.as_ref(), true, change_focus, window, cx, ), None => workspace.activate_item(&active_editor, true, change_focus, window, cx), }); if activate.is_ok() { self.select_entry(entry.clone(), true, window, cx); if change_selection { active_editor.update(cx, |editor, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::center()), window, cx, |s| s.select_ranges(Some(anchor..anchor)), ); }); } else { let mut offset = Point::default(); if let Some(buffer_id) = scroll_to_buffer { if multi_buffer_snapshot.as_singleton().is_none() && !active_editor.read(cx).is_buffer_folded(buffer_id, cx) { offset.y = -(active_editor.read(cx).file_header_size() as f32); } } active_editor.update(cx, |editor, cx| { editor.set_scroll_anchor(ScrollAnchor { offset, anchor }, window, cx); }); } if change_focus { active_editor.focus_handle(cx).focus(window); } else { self.focus_handle.focus(window); } } } } fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| { self.cached_entries .iter() .map(|cached_entry| &cached_entry.entry) .skip_while(|entry| entry != &selected_entry) .nth(1) .cloned() }) { self.select_entry(entry_to_select, true, window, cx); } else { self.select_first(&SelectFirst {}, window, cx) } if let Some(selected_entry) = self.selected_entry().cloned() { self.scroll_editor_to_entry(&selected_entry, true, false, window, cx); } } fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| { self.cached_entries .iter() .rev() .map(|cached_entry| &cached_entry.entry) .skip_while(|entry| entry != &selected_entry) .nth(1) .cloned() }) { self.select_entry(entry_to_select, true, window, cx); } else { self.select_last(&SelectLast, window, cx) } if let Some(selected_entry) = self.selected_entry().cloned() { self.scroll_editor_to_entry(&selected_entry, true, false, window, cx); } } fn select_parent(&mut self, _: &SelectParent, window: &mut Window, cx: &mut Context) { if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| { let mut previous_entries = self .cached_entries .iter() .rev() .map(|cached_entry| &cached_entry.entry) .skip_while(|entry| entry != &selected_entry) .skip(1); match &selected_entry { PanelEntry::Fs(fs_entry) => match fs_entry { FsEntry::ExternalFile(..) => None, FsEntry::File(FsEntryFile { worktree_id, entry, .. }) | FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. }) => entry.path.parent().and_then(|parent_path| { previous_entries.find(|entry| match entry { PanelEntry::Fs(FsEntry::Directory(directory)) => { directory.worktree_id == *worktree_id && directory.entry.path.as_ref() == parent_path } PanelEntry::FoldedDirs(FoldedDirsEntry { worktree_id: dirs_worktree_id, entries: dirs, .. }) => { dirs_worktree_id == worktree_id && dirs .last() .map_or(false, |dir| dir.path.as_ref() == parent_path) } _ => false, }) }), }, PanelEntry::FoldedDirs(folded_dirs) => folded_dirs .entries .first() .and_then(|entry| entry.path.parent()) .and_then(|parent_path| { previous_entries.find(|entry| { if let PanelEntry::Fs(FsEntry::Directory(directory)) = entry { directory.worktree_id == folded_dirs.worktree_id && directory.entry.path.as_ref() == parent_path } else { false } }) }), PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { previous_entries.find(|entry| match entry { PanelEntry::Fs(FsEntry::File(file)) => { file.buffer_id == excerpt.buffer_id && file.excerpts.contains(&excerpt.id) } PanelEntry::Fs(FsEntry::ExternalFile(external_file)) => { external_file.buffer_id == excerpt.buffer_id && external_file.excerpts.contains(&excerpt.id) } _ => false, }) } PanelEntry::Outline(OutlineEntry::Outline(outline)) => { previous_entries.find(|entry| { if let PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) = entry { outline.buffer_id == excerpt.buffer_id && outline.excerpt_id == excerpt.id } else { false } }) } PanelEntry::Search(_) => { previous_entries.find(|entry| !matches!(entry, PanelEntry::Search(_))) } } }) { self.select_entry(entry_to_select.clone(), true, window, cx); } else { self.select_first(&SelectFirst {}, window, cx); } } fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context) { if let Some(first_entry) = self.cached_entries.first() { self.select_entry(first_entry.entry.clone(), true, window, cx); } } fn select_last(&mut self, _: &SelectLast, window: &mut Window, cx: &mut Context) { if let Some(new_selection) = self .cached_entries .iter() .rev() .map(|cached_entry| &cached_entry.entry) .next() { self.select_entry(new_selection.clone(), true, window, cx); } } fn autoscroll(&mut self, cx: &mut Context) { if let Some(selected_entry) = self.selected_entry() { let index = self .cached_entries .iter() .position(|cached_entry| &cached_entry.entry == selected_entry); if let Some(index) = index { self.scroll_handle .scroll_to_item(index, ScrollStrategy::Center); cx.notify(); } } } fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { if !self.focus_handle.contains_focused(window, cx) { cx.emit(Event::Focus); } } fn deploy_context_menu( &mut self, position: Point, entry: PanelEntry, window: &mut Window, cx: &mut Context, ) { self.select_entry(entry.clone(), true, window, cx); let is_root = match &entry { PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, entry, .. })) | PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. })) => self .project .read(cx) .worktree_for_id(*worktree_id, cx) .map(|worktree| { worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id) }) .unwrap_or(false), PanelEntry::FoldedDirs(FoldedDirsEntry { worktree_id, entries, .. }) => entries .first() .and_then(|entry| { self.project .read(cx) .worktree_for_id(*worktree_id, cx) .map(|worktree| { worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id) }) }) .unwrap_or(false), PanelEntry::Fs(FsEntry::ExternalFile(..)) => false, PanelEntry::Outline(..) => { cx.notify(); return; } PanelEntry::Search(_) => { cx.notify(); return; } }; let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; let is_foldable = auto_fold_dirs && !is_root && self.is_foldable(&entry); let is_unfoldable = auto_fold_dirs && !is_root && self.is_unfoldable(&entry); let context_menu = ContextMenu::build(window, cx, |menu, _, _| { menu.context(self.focus_handle.clone()) .when(cfg!(target_os = "macos"), |menu| { menu.action("Reveal in Finder", Box::new(RevealInFileManager)) }) .when(cfg!(not(target_os = "macos")), |menu| { menu.action("Reveal in File Manager", Box::new(RevealInFileManager)) }) .action("Open in Terminal", Box::new(OpenInTerminal)) .when(is_unfoldable, |menu| { menu.action("Unfold Directory", Box::new(UnfoldDirectory)) }) .when(is_foldable, |menu| { menu.action("Fold Directory", Box::new(FoldDirectory)) }) .separator() .action("Copy Path", Box::new(zed_actions::workspace::CopyPath)) .action( "Copy Relative Path", Box::new(zed_actions::workspace::CopyRelativePath), ) }); window.focus(&context_menu.focus_handle(cx)); let subscription = cx.subscribe(&context_menu, |outline_panel, _, _: &DismissEvent, cx| { outline_panel.context_menu.take(); cx.notify(); }); self.context_menu = Some((context_menu, position, subscription)); cx.notify(); } fn is_unfoldable(&self, entry: &PanelEntry) -> bool { matches!(entry, PanelEntry::FoldedDirs(..)) } fn is_foldable(&self, entry: &PanelEntry) -> bool { let (directory_worktree, directory_entry) = match entry { PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry: directory_entry, .. })) => (*worktree_id, Some(directory_entry)), _ => return false, }; let Some(directory_entry) = directory_entry else { return false; }; if self .unfolded_dirs .get(&directory_worktree) .map_or(true, |unfolded_dirs| { !unfolded_dirs.contains(&directory_entry.id) }) { return false; } let children = self .fs_children_count .get(&directory_worktree) .and_then(|entries| entries.get(&directory_entry.path)) .copied() .unwrap_or_default(); children.may_be_fold_part() && children.dirs > 0 } fn expand_selected_entry( &mut self, _: &ExpandSelectedEntry, window: &mut Window, cx: &mut Context, ) { let Some(active_editor) = self.active_editor() else { return; }; let Some(selected_entry) = self.selected_entry().cloned() else { return; }; let mut buffers_to_unfold = HashSet::default(); let entry_to_expand = match &selected_entry { PanelEntry::FoldedDirs(FoldedDirsEntry { entries: dir_entries, worktree_id, .. }) => dir_entries.last().map(|entry| { buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry)); CollapsedEntry::Dir(*worktree_id, entry.id) }), PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. })) => { buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry)); Some(CollapsedEntry::Dir(*worktree_id, entry.id)) } PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, buffer_id, .. })) => { buffers_to_unfold.insert(*buffer_id); Some(CollapsedEntry::File(*worktree_id, *buffer_id)) } PanelEntry::Fs(FsEntry::ExternalFile(external_file)) => { buffers_to_unfold.insert(external_file.buffer_id); Some(CollapsedEntry::ExternalFile(external_file.buffer_id)) } PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { Some(CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id)) } PanelEntry::Search(_) | PanelEntry::Outline(..) => return, }; let Some(collapsed_entry) = entry_to_expand else { return; }; let expanded = self.collapsed_entries.remove(&collapsed_entry); if expanded { if let CollapsedEntry::Dir(worktree_id, dir_entry_id) = collapsed_entry { let task = self.project.update(cx, |project, cx| { project.expand_entry(worktree_id, dir_entry_id, cx) }); if let Some(task) = task { task.detach_and_log_err(cx); } }; active_editor.update(cx, |editor, cx| { buffers_to_unfold.retain(|buffer_id| editor.is_buffer_folded(*buffer_id, cx)); }); self.select_entry(selected_entry, true, window, cx); if buffers_to_unfold.is_empty() { self.update_cached_entries(None, window, cx); } else { self.toggle_buffers_fold(buffers_to_unfold, false, window, cx) .detach(); } } else { self.select_next(&SelectNext, window, cx) } } fn collapse_selected_entry( &mut self, _: &CollapseSelectedEntry, window: &mut Window, cx: &mut Context, ) { let Some(active_editor) = self.active_editor() else { return; }; let Some(selected_entry) = self.selected_entry().cloned() else { return; }; let mut buffers_to_fold = HashSet::default(); let collapsed = match &selected_entry { PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. })) => { if self .collapsed_entries .insert(CollapsedEntry::Dir(*worktree_id, entry.id)) { buffers_to_fold.extend(self.buffers_inside_directory(*worktree_id, entry)); true } else { false } } PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, buffer_id, .. })) => { if self .collapsed_entries .insert(CollapsedEntry::File(*worktree_id, *buffer_id)) { buffers_to_fold.insert(*buffer_id); true } else { false } } PanelEntry::Fs(FsEntry::ExternalFile(external_file)) => { if self .collapsed_entries .insert(CollapsedEntry::ExternalFile(external_file.buffer_id)) { buffers_to_fold.insert(external_file.buffer_id); true } else { false } } PanelEntry::FoldedDirs(folded_dirs) => { let mut folded = false; if let Some(dir_entry) = folded_dirs.entries.last() { if self .collapsed_entries .insert(CollapsedEntry::Dir(folded_dirs.worktree_id, dir_entry.id)) { folded = true; buffers_to_fold.extend( self.buffers_inside_directory(folded_dirs.worktree_id, dir_entry), ); } } folded } PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => self .collapsed_entries .insert(CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id)), PanelEntry::Search(_) | PanelEntry::Outline(..) => false, }; if collapsed { active_editor.update(cx, |editor, cx| { buffers_to_fold.retain(|buffer_id| !editor.is_buffer_folded(*buffer_id, cx)); }); self.select_entry(selected_entry, true, window, cx); if buffers_to_fold.is_empty() { self.update_cached_entries(None, window, cx); } else { self.toggle_buffers_fold(buffers_to_fold, true, window, cx) .detach(); } } else { self.select_parent(&SelectParent, window, cx); } } pub fn expand_all_entries( &mut self, _: &ExpandAllEntries, window: &mut Window, cx: &mut Context, ) { let Some(active_editor) = self.active_editor() else { return; }; let mut buffers_to_unfold = HashSet::default(); let expanded_entries = self.fs_entries .iter() .fold(HashSet::default(), |mut entries, fs_entry| { match fs_entry { FsEntry::ExternalFile(external_file) => { buffers_to_unfold.insert(external_file.buffer_id); entries.insert(CollapsedEntry::ExternalFile(external_file.buffer_id)); entries.extend( self.excerpts .get(&external_file.buffer_id) .into_iter() .flat_map(|excerpts| { excerpts.keys().map(|excerpt_id| { CollapsedEntry::Excerpt( external_file.buffer_id, *excerpt_id, ) }) }), ); } FsEntry::Directory(directory) => { entries.insert(CollapsedEntry::Dir( directory.worktree_id, directory.entry.id, )); } FsEntry::File(file) => { buffers_to_unfold.insert(file.buffer_id); entries.insert(CollapsedEntry::File(file.worktree_id, file.buffer_id)); entries.extend( self.excerpts.get(&file.buffer_id).into_iter().flat_map( |excerpts| { excerpts.keys().map(|excerpt_id| { CollapsedEntry::Excerpt(file.buffer_id, *excerpt_id) }) }, ), ); } }; entries }); self.collapsed_entries .retain(|entry| !expanded_entries.contains(entry)); active_editor.update(cx, |editor, cx| { buffers_to_unfold.retain(|buffer_id| editor.is_buffer_folded(*buffer_id, cx)); }); if buffers_to_unfold.is_empty() { self.update_cached_entries(None, window, cx); } else { self.toggle_buffers_fold(buffers_to_unfold, false, window, cx) .detach(); } } pub fn collapse_all_entries( &mut self, _: &CollapseAllEntries, window: &mut Window, cx: &mut Context, ) { let Some(active_editor) = self.active_editor() else { return; }; let mut buffers_to_fold = HashSet::default(); let new_entries = self .cached_entries .iter() .flat_map(|cached_entry| match &cached_entry.entry { PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. })) => Some(CollapsedEntry::Dir(*worktree_id, entry.id)), PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, buffer_id, .. })) => { buffers_to_fold.insert(*buffer_id); Some(CollapsedEntry::File(*worktree_id, *buffer_id)) } PanelEntry::Fs(FsEntry::ExternalFile(external_file)) => { buffers_to_fold.insert(external_file.buffer_id); Some(CollapsedEntry::ExternalFile(external_file.buffer_id)) } PanelEntry::FoldedDirs(FoldedDirsEntry { worktree_id, entries, .. }) => Some(CollapsedEntry::Dir(*worktree_id, entries.last()?.id)), PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { Some(CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id)) } PanelEntry::Search(_) | PanelEntry::Outline(..) => None, }) .collect::>(); self.collapsed_entries.extend(new_entries); active_editor.update(cx, |editor, cx| { buffers_to_fold.retain(|buffer_id| !editor.is_buffer_folded(*buffer_id, cx)); }); if buffers_to_fold.is_empty() { self.update_cached_entries(None, window, cx); } else { self.toggle_buffers_fold(buffers_to_fold, true, window, cx) .detach(); } } fn toggle_expanded(&mut self, entry: &PanelEntry, window: &mut Window, cx: &mut Context) { let Some(active_editor) = self.active_editor() else { return; }; let mut fold = false; let mut buffers_to_toggle = HashSet::default(); match entry { PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry: dir_entry, .. })) => { let entry_id = dir_entry.id; let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); buffers_to_toggle.extend(self.buffers_inside_directory(*worktree_id, dir_entry)); if self.collapsed_entries.remove(&collapsed_entry) { self.project .update(cx, |project, cx| { project.expand_entry(*worktree_id, entry_id, cx) }) .unwrap_or_else(|| Task::ready(Ok(()))) .detach_and_log_err(cx); } else { self.collapsed_entries.insert(collapsed_entry); fold = true; } } PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, buffer_id, .. })) => { let collapsed_entry = CollapsedEntry::File(*worktree_id, *buffer_id); buffers_to_toggle.insert(*buffer_id); if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); fold = true; } } PanelEntry::Fs(FsEntry::ExternalFile(external_file)) => { let collapsed_entry = CollapsedEntry::ExternalFile(external_file.buffer_id); buffers_to_toggle.insert(external_file.buffer_id); if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); fold = true; } } PanelEntry::FoldedDirs(FoldedDirsEntry { worktree_id, entries: dir_entries, .. }) => { if let Some(dir_entry) = dir_entries.first() { let entry_id = dir_entry.id; let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); buffers_to_toggle .extend(self.buffers_inside_directory(*worktree_id, dir_entry)); if self.collapsed_entries.remove(&collapsed_entry) { self.project .update(cx, |project, cx| { project.expand_entry(*worktree_id, entry_id, cx) }) .unwrap_or_else(|| Task::ready(Ok(()))) .detach_and_log_err(cx); } else { self.collapsed_entries.insert(collapsed_entry); fold = true; } } } PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { let collapsed_entry = CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id); if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); } } PanelEntry::Search(_) | PanelEntry::Outline(..) => return, } active_editor.update(cx, |editor, cx| { buffers_to_toggle.retain(|buffer_id| { let folded = editor.is_buffer_folded(*buffer_id, cx); if fold { !folded } else { folded } }); }); self.select_entry(entry.clone(), true, window, cx); if buffers_to_toggle.is_empty() { self.update_cached_entries(None, window, cx); } else { self.toggle_buffers_fold(buffers_to_toggle, fold, window, cx) .detach(); } } fn toggle_buffers_fold( &self, buffers: HashSet, fold: bool, window: &mut Window, cx: &mut Context, ) -> Task<()> { let Some(active_editor) = self.active_editor() else { return Task::ready(()); }; cx.spawn_in(window, async move |outline_panel, cx| { outline_panel .update_in(cx, |outline_panel, window, cx| { active_editor.update(cx, |editor, cx| { for buffer_id in buffers { outline_panel .preserve_selection_on_buffer_fold_toggles .insert(buffer_id); if fold { editor.fold_buffer(buffer_id, cx); } else { editor.unfold_buffer(buffer_id, cx); } } }); if let Some(selection) = outline_panel.selected_entry().cloned() { outline_panel.scroll_editor_to_entry(&selection, false, false, window, cx); } }) .ok(); }) } fn copy_path( &mut self, _: &zed_actions::workspace::CopyPath, _: &mut Window, cx: &mut Context, ) { if let Some(clipboard_text) = self .selected_entry() .and_then(|entry| self.abs_path(entry, cx)) .map(|p| p.to_string_lossy().to_string()) { cx.write_to_clipboard(ClipboardItem::new_string(clipboard_text)); } } fn copy_relative_path( &mut self, _: &zed_actions::workspace::CopyRelativePath, _: &mut Window, cx: &mut Context, ) { if let Some(clipboard_text) = self .selected_entry() .and_then(|entry| match entry { PanelEntry::Fs(entry) => self.relative_path(entry, cx), PanelEntry::FoldedDirs(folded_dirs) => { folded_dirs.entries.last().map(|entry| entry.path.clone()) } PanelEntry::Search(_) | PanelEntry::Outline(..) => None, }) .map(|p| p.to_string_lossy().to_string()) { cx.write_to_clipboard(ClipboardItem::new_string(clipboard_text)); } } fn reveal_in_finder( &mut self, _: &RevealInFileManager, _: &mut Window, cx: &mut Context, ) { if let Some(abs_path) = self .selected_entry() .and_then(|entry| self.abs_path(entry, cx)) { cx.reveal_path(&abs_path); } } fn open_in_terminal( &mut self, _: &OpenInTerminal, window: &mut Window, cx: &mut Context, ) { let selected_entry = self.selected_entry(); let abs_path = selected_entry.and_then(|entry| self.abs_path(entry, cx)); let working_directory = if let ( Some(abs_path), Some(PanelEntry::Fs(FsEntry::File(..) | FsEntry::ExternalFile(..))), ) = (&abs_path, selected_entry) { abs_path.parent().map(|p| p.to_owned()) } else { abs_path }; if let Some(working_directory) = working_directory { window.dispatch_action( workspace::OpenTerminal { working_directory }.boxed_clone(), cx, ) } } fn reveal_entry_for_selection( &mut self, editor: Entity, window: &mut Window, cx: &mut Context, ) { if !self.active || !OutlinePanelSettings::get_global(cx).auto_reveal_entries || self.focus_handle.contains_focused(window, cx) { return; } let project = self.project.clone(); self.reveal_selection_task = cx.spawn_in(window, async move |outline_panel, cx| { cx.background_executor().timer(UPDATE_DEBOUNCE).await; let entry_with_selection = outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.location_for_editor_selection(&editor, window, cx) })?; let Some(entry_with_selection) = entry_with_selection else { outline_panel.update(cx, |outline_panel, cx| { outline_panel.selected_entry = SelectedEntry::None; cx.notify(); })?; return Ok(()); }; let related_buffer_entry = match &entry_with_selection { PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, buffer_id, .. })) => project.update(cx, |project, cx| { let entry_id = project .buffer_for_id(*buffer_id, cx) .and_then(|buffer| buffer.read(cx).entry_id(cx)); project .worktree_for_id(*worktree_id, cx) .zip(entry_id) .and_then(|(worktree, entry_id)| { let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); Some((worktree, entry)) }) })?, PanelEntry::Outline(outline_entry) => { let (buffer_id, excerpt_id) = outline_entry.ids(); outline_panel.update(cx, |outline_panel, cx| { outline_panel .collapsed_entries .remove(&CollapsedEntry::ExternalFile(buffer_id)); outline_panel .collapsed_entries .remove(&CollapsedEntry::Excerpt(buffer_id, excerpt_id)); let project = outline_panel.project.read(cx); let entry_id = project .buffer_for_id(buffer_id, cx) .and_then(|buffer| buffer.read(cx).entry_id(cx)); entry_id.and_then(|entry_id| { project .worktree_for_entry(entry_id, cx) .and_then(|worktree| { let worktree_id = worktree.read(cx).id(); outline_panel .collapsed_entries .remove(&CollapsedEntry::File(worktree_id, buffer_id)); let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); Some((worktree, entry)) }) }) })? } PanelEntry::Fs(FsEntry::ExternalFile(..)) => None, PanelEntry::Search(SearchEntry { match_range, .. }) => match_range .start .buffer_id .or(match_range.end.buffer_id) .map(|buffer_id| { outline_panel.update(cx, |outline_panel, cx| { outline_panel .collapsed_entries .remove(&CollapsedEntry::ExternalFile(buffer_id)); let project = project.read(cx); let entry_id = project .buffer_for_id(buffer_id, cx) .and_then(|buffer| buffer.read(cx).entry_id(cx)); entry_id.and_then(|entry_id| { project .worktree_for_entry(entry_id, cx) .and_then(|worktree| { let worktree_id = worktree.read(cx).id(); outline_panel .collapsed_entries .remove(&CollapsedEntry::File(worktree_id, buffer_id)); let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); Some((worktree, entry)) }) }) }) }) .transpose()? .flatten(), _ => return anyhow::Ok(()), }; if let Some((worktree, buffer_entry)) = related_buffer_entry { outline_panel.update(cx, |outline_panel, cx| { let worktree_id = worktree.read(cx).id(); let mut dirs_to_expand = Vec::new(); { let mut traversal = worktree.read(cx).traverse_from_path( true, true, true, buffer_entry.path.as_ref(), ); let mut current_entry = buffer_entry; loop { if current_entry.is_dir() && outline_panel .collapsed_entries .remove(&CollapsedEntry::Dir(worktree_id, current_entry.id)) { dirs_to_expand.push(current_entry.id); } if traversal.back_to_parent() { if let Some(parent_entry) = traversal.entry() { current_entry = parent_entry.clone(); continue; } } break; } } for dir_to_expand in dirs_to_expand { project .update(cx, |project, cx| { project.expand_entry(worktree_id, dir_to_expand, cx) }) .unwrap_or_else(|| Task::ready(Ok(()))) .detach_and_log_err(cx) } })? } outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.select_entry(entry_with_selection, false, window, cx); outline_panel.update_cached_entries(None, window, cx); })?; anyhow::Ok(()) }); } fn render_excerpt( &self, excerpt: &OutlineEntryExcerpt, depth: usize, window: &mut Window, cx: &mut Context, ) -> Option> { let item_id = ElementId::from(excerpt.id.to_proto() as usize); let is_active = match self.selected_entry() { Some(PanelEntry::Outline(OutlineEntry::Excerpt(selected_excerpt))) => { selected_excerpt.buffer_id == excerpt.buffer_id && selected_excerpt.id == excerpt.id } _ => false, }; let has_outlines = self .excerpts .get(&excerpt.buffer_id) .and_then(|excerpts| match &excerpts.get(&excerpt.id)?.outlines { ExcerptOutlines::Outlines(outlines) => Some(outlines), ExcerptOutlines::Invalidated(outlines) => Some(outlines), ExcerptOutlines::NotFetched => None, }) .map_or(false, |outlines| !outlines.is_empty()); let is_expanded = !self .collapsed_entries .contains(&CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id)); let color = entry_label_color(is_active); let icon = if has_outlines { FileIcons::get_chevron_icon(is_expanded, cx) .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element()) } else { None } .unwrap_or_else(empty_icon); let label = self.excerpt_label(excerpt.buffer_id, &excerpt.range, cx)?; let label_element = Label::new(label) .single_line() .color(color) .into_any_element(); Some(self.entry_element( PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.clone())), item_id, depth, Some(icon), is_active, label_element, window, cx, )) } fn excerpt_label( &self, buffer_id: BufferId, range: &ExcerptRange, cx: &App, ) -> Option { let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx)?; let excerpt_range = range.context.to_point(&buffer_snapshot); Some(format!( "Lines {}- {}", excerpt_range.start.row + 1, excerpt_range.end.row + 1, )) } fn render_outline( &self, outline: &OutlineEntryOutline, depth: usize, string_match: Option<&StringMatch>, window: &mut Window, cx: &mut Context, ) -> Stateful
{ let item_id = ElementId::from(SharedString::from(format!( "{:?}|{:?}{:?}|{:?}", outline.buffer_id, outline.excerpt_id, outline.outline.range, &outline.outline.text, ))); let label_element = outline::render_item( &outline.outline, string_match .map(|string_match| string_match.ranges().collect::>()) .unwrap_or_default(), cx, ) .into_any_element(); let is_active = match self.selected_entry() { Some(PanelEntry::Outline(OutlineEntry::Outline(selected))) => { outline == selected && outline.outline == selected.outline } _ => false, }; let icon = if self.is_singleton_active(cx) { None } else { Some(empty_icon()) }; self.entry_element( PanelEntry::Outline(OutlineEntry::Outline(outline.clone())), item_id, depth, icon, is_active, label_element, window, cx, ) } fn render_entry( &self, rendered_entry: &FsEntry, depth: usize, string_match: Option<&StringMatch>, window: &mut Window, cx: &mut Context, ) -> Stateful
{ let settings = OutlinePanelSettings::get_global(cx); let is_active = match self.selected_entry() { Some(PanelEntry::Fs(selected_entry)) => selected_entry == rendered_entry, _ => false, }; let (item_id, label_element, icon) = match rendered_entry { FsEntry::File(FsEntryFile { worktree_id, entry, .. }) => { let name = self.entry_name(worktree_id, entry, cx); let color = entry_git_aware_label_color(entry.git_summary, entry.is_ignored, is_active); let icon = if settings.file_icons { FileIcons::get_icon(&entry.path, cx) .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element()) } else { None }; ( ElementId::from(entry.id.to_proto() as usize), HighlightedLabel::new( name, string_match .map(|string_match| string_match.positions.clone()) .unwrap_or_default(), ) .color(color) .into_any_element(), icon.unwrap_or_else(empty_icon), ) } FsEntry::Directory(directory) => { let name = self.entry_name(&directory.worktree_id, &directory.entry, cx); let is_expanded = !self.collapsed_entries.contains(&CollapsedEntry::Dir( directory.worktree_id, directory.entry.id, )); let color = entry_git_aware_label_color( directory.entry.git_summary, directory.entry.is_ignored, is_active, ); let icon = if settings.folder_icons { FileIcons::get_folder_icon(is_expanded, cx) } else { FileIcons::get_chevron_icon(is_expanded, cx) } .map(Icon::from_path) .map(|icon| icon.color(color).into_any_element()); ( ElementId::from(directory.entry.id.to_proto() as usize), HighlightedLabel::new( name, string_match .map(|string_match| string_match.positions.clone()) .unwrap_or_default(), ) .color(color) .into_any_element(), icon.unwrap_or_else(empty_icon), ) } FsEntry::ExternalFile(external_file) => { let color = entry_label_color(is_active); let (icon, name) = match self.buffer_snapshot_for_id(external_file.buffer_id, cx) { Some(buffer_snapshot) => match buffer_snapshot.file() { Some(file) => { let path = file.path(); let icon = if settings.file_icons { FileIcons::get_icon(path.as_ref(), cx) } else { None } .map(Icon::from_path) .map(|icon| icon.color(color).into_any_element()); (icon, file_name(path.as_ref())) } None => (None, "Untitled".to_string()), }, None => (None, "Unknown buffer".to_string()), }; ( ElementId::from(external_file.buffer_id.to_proto() as usize), HighlightedLabel::new( name, string_match .map(|string_match| string_match.positions.clone()) .unwrap_or_default(), ) .color(color) .into_any_element(), icon.unwrap_or_else(empty_icon), ) } }; self.entry_element( PanelEntry::Fs(rendered_entry.clone()), item_id, depth, Some(icon), is_active, label_element, window, cx, ) } fn render_folded_dirs( &self, folded_dir: &FoldedDirsEntry, depth: usize, string_match: Option<&StringMatch>, window: &mut Window, cx: &mut Context, ) -> Stateful
{ let settings = OutlinePanelSettings::get_global(cx); let is_active = match self.selected_entry() { Some(PanelEntry::FoldedDirs(selected_dirs)) => { selected_dirs.worktree_id == folded_dir.worktree_id && selected_dirs.entries == folded_dir.entries } _ => false, }; let (item_id, label_element, icon) = { let name = self.dir_names_string(&folded_dir.entries, folded_dir.worktree_id, cx); let is_expanded = folded_dir.entries.iter().all(|dir| { !self .collapsed_entries .contains(&CollapsedEntry::Dir(folded_dir.worktree_id, dir.id)) }); let is_ignored = folded_dir.entries.iter().any(|entry| entry.is_ignored); let git_status = folded_dir .entries .first() .map(|entry| entry.git_summary) .unwrap_or_default(); let color = entry_git_aware_label_color(git_status, is_ignored, is_active); let icon = if settings.folder_icons { FileIcons::get_folder_icon(is_expanded, cx) } else { FileIcons::get_chevron_icon(is_expanded, cx) } .map(Icon::from_path) .map(|icon| icon.color(color).into_any_element()); ( ElementId::from( folded_dir .entries .last() .map(|entry| entry.id.to_proto()) .unwrap_or_else(|| folded_dir.worktree_id.to_proto()) as usize, ), HighlightedLabel::new( name, string_match .map(|string_match| string_match.positions.clone()) .unwrap_or_default(), ) .color(color) .into_any_element(), icon.unwrap_or_else(empty_icon), ) }; self.entry_element( PanelEntry::FoldedDirs(folded_dir.clone()), item_id, depth, Some(icon), is_active, label_element, window, cx, ) } fn render_search_match( &mut self, multi_buffer_snapshot: Option<&MultiBufferSnapshot>, match_range: &Range, render_data: &Arc>, kind: SearchKind, depth: usize, string_match: Option<&StringMatch>, window: &mut Window, cx: &mut Context, ) -> Option> { let search_data = match render_data.get() { Some(search_data) => search_data, None => { if let ItemsDisplayMode::Search(search_state) = &mut self.mode { if let Some(multi_buffer_snapshot) = multi_buffer_snapshot { search_state .highlight_search_match_tx .try_send(HighlightArguments { multi_buffer_snapshot: multi_buffer_snapshot.clone(), match_range: match_range.clone(), search_data: Arc::clone(render_data), }) .ok(); } } return None; } }; let search_matches = string_match .iter() .flat_map(|string_match| string_match.ranges()) .collect::>(); let match_ranges = if search_matches.is_empty() { &search_data.search_match_indices } else { &search_matches }; let label_element = outline::render_item( &OutlineItem { depth, annotation_range: None, range: search_data.context_range.clone(), text: search_data.context_text.clone(), highlight_ranges: search_data .highlights_data .get() .cloned() .unwrap_or_default(), name_ranges: search_data.search_match_indices.clone(), body_range: Some(search_data.context_range.clone()), }, match_ranges.iter().cloned(), cx, ); let truncated_contents_label = || Label::new(TRUNCATED_CONTEXT_MARK); let entire_label = h_flex() .justify_center() .p_0() .when(search_data.truncated_left, |parent| { parent.child(truncated_contents_label()) }) .child(label_element) .when(search_data.truncated_right, |parent| { parent.child(truncated_contents_label()) }) .into_any_element(); let is_active = match self.selected_entry() { Some(PanelEntry::Search(SearchEntry { match_range: selected_match_range, .. })) => match_range == selected_match_range, _ => false, }; Some(self.entry_element( PanelEntry::Search(SearchEntry { kind, match_range: match_range.clone(), render_data: render_data.clone(), }), ElementId::from(SharedString::from(format!("search-{match_range:?}"))), depth, None, is_active, entire_label, window, cx, )) } fn entry_element( &self, rendered_entry: PanelEntry, item_id: ElementId, depth: usize, icon_element: Option, is_active: bool, label_element: gpui::AnyElement, window: &mut Window, cx: &mut Context, ) -> Stateful
{ let settings = OutlinePanelSettings::get_global(cx); div() .text_ui(cx) .id(item_id.clone()) .on_click({ let clicked_entry = rendered_entry.clone(); cx.listener(move |outline_panel, event: &gpui::ClickEvent, window, cx| { if event.down.button == MouseButton::Right || event.down.first_mouse { return; } let change_focus = event.down.click_count > 1; outline_panel.toggle_expanded(&clicked_entry, window, cx); outline_panel.scroll_editor_to_entry( &clicked_entry, true, change_focus, window, cx, ); }) }) .cursor_pointer() .child( ListItem::new(item_id) .indent_level(depth) .indent_step_size(px(settings.indent_size)) .toggle_state(is_active) .when_some(icon_element, |list_item, icon_element| { list_item.child(h_flex().child(icon_element)) }) .child(h_flex().h_6().child(label_element).ml_1()) .on_secondary_mouse_down(cx.listener( move |outline_panel, event: &MouseDownEvent, window, cx| { // Stop propagation to prevent the catch-all context menu for the project // panel from being deployed. cx.stop_propagation(); outline_panel.deploy_context_menu( event.position, rendered_entry.clone(), window, cx, ) }, )), ) .border_1() .border_r_2() .rounded_none() .hover(|style| { if is_active { style } else { let hover_color = cx.theme().colors().ghost_element_hover; style.bg(hover_color).border_color(hover_color) } }) .when( is_active && self.focus_handle.contains_focused(window, cx), |div| div.border_color(Color::Selected.color(cx)), ) } fn entry_name(&self, worktree_id: &WorktreeId, entry: &Entry, cx: &App) -> String { let name = match self.project.read(cx).worktree_for_id(*worktree_id, cx) { Some(worktree) => { let worktree = worktree.read(cx); match worktree.snapshot().root_entry() { Some(root_entry) => { if root_entry.id == entry.id { file_name(worktree.abs_path().as_ref()) } else { let path = worktree.absolutize(entry.path.as_ref()).ok(); let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref()); file_name(path) } } None => { let path = worktree.absolutize(entry.path.as_ref()).ok(); let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref()); file_name(path) } } } None => file_name(entry.path.as_ref()), }; name } fn update_fs_entries( &mut self, active_editor: Entity, debounce: Option, window: &mut Window, cx: &mut Context, ) { if !self.active { return; } let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; let active_multi_buffer = active_editor.read(cx).buffer().clone(); let new_entries = self.new_entries_for_fs_update.clone(); let repo_snapshots = self.project.update(cx, |project, cx| { project.git_store().read(cx).repo_snapshots(cx) }); self.updating_fs_entries = true; self.fs_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| { if let Some(debounce) = debounce { cx.background_executor().timer(debounce).await; } let mut new_collapsed_entries = HashSet::default(); let mut new_unfolded_dirs = HashMap::default(); let mut root_entries = HashSet::default(); let mut new_excerpts = HashMap::>::default(); let Ok(buffer_excerpts) = outline_panel.update(cx, |outline_panel, cx| { let git_store = outline_panel.project.read(cx).git_store().clone(); new_collapsed_entries = outline_panel.collapsed_entries.clone(); new_unfolded_dirs = outline_panel.unfolded_dirs.clone(); let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); let buffer_excerpts = multi_buffer_snapshot.excerpts().fold( HashMap::default(), |mut buffer_excerpts, (excerpt_id, buffer_snapshot, excerpt_range)| { let buffer_id = buffer_snapshot.remote_id(); let file = File::from_dyn(buffer_snapshot.file()); let entry_id = file.and_then(|file| file.project_entry_id(cx)); let worktree = file.map(|file| file.worktree.read(cx).snapshot()); let is_new = new_entries.contains(&excerpt_id) || !outline_panel.excerpts.contains_key(&buffer_id); let is_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx); let status = git_store .read(cx) .repository_and_path_for_buffer_id(buffer_id, cx) .and_then(|(repo, path)| { Some(repo.read(cx).status_for_path(&path)?.status) }); buffer_excerpts .entry(buffer_id) .or_insert_with(|| { (is_new, is_folded, Vec::new(), entry_id, worktree, status) }) .2 .push(excerpt_id); let outlines = match outline_panel .excerpts .get(&buffer_id) .and_then(|excerpts| excerpts.get(&excerpt_id)) { Some(old_excerpt) => match &old_excerpt.outlines { ExcerptOutlines::Outlines(outlines) => { ExcerptOutlines::Outlines(outlines.clone()) } ExcerptOutlines::Invalidated(_) => ExcerptOutlines::NotFetched, ExcerptOutlines::NotFetched => ExcerptOutlines::NotFetched, }, None => ExcerptOutlines::NotFetched, }; new_excerpts.entry(buffer_id).or_default().insert( excerpt_id, Excerpt { range: excerpt_range, outlines, }, ); buffer_excerpts }, ); buffer_excerpts }) else { return; }; let Some(( new_collapsed_entries, new_unfolded_dirs, new_fs_entries, new_depth_map, new_children_count, )) = cx .background_spawn(async move { let mut processed_external_buffers = HashSet::default(); let mut new_worktree_entries = BTreeMap::>::default(); let mut worktree_excerpts = HashMap::< WorktreeId, HashMap)>, >::default(); let mut external_excerpts = HashMap::default(); for (buffer_id, (is_new, is_folded, excerpts, entry_id, worktree, status)) in buffer_excerpts { if is_folded { match &worktree { Some(worktree) => { new_collapsed_entries .insert(CollapsedEntry::File(worktree.id(), buffer_id)); } None => { new_collapsed_entries .insert(CollapsedEntry::ExternalFile(buffer_id)); } } } else if is_new { match &worktree { Some(worktree) => { new_collapsed_entries .remove(&CollapsedEntry::File(worktree.id(), buffer_id)); } None => { new_collapsed_entries .remove(&CollapsedEntry::ExternalFile(buffer_id)); } } } if let Some(worktree) = worktree { let worktree_id = worktree.id(); let unfolded_dirs = new_unfolded_dirs.entry(worktree_id).or_default(); match entry_id.and_then(|id| worktree.entry_for_id(id)).cloned() { Some(entry) => { let entry = GitEntry { git_summary: status .map(|status| status.summary()) .unwrap_or_default(), entry, }; let mut traversal = GitTraversal::new( &repo_snapshots, worktree.traverse_from_path( true, true, true, entry.path.as_ref(), ), ); let mut entries_to_add = HashMap::default(); worktree_excerpts .entry(worktree_id) .or_default() .insert(entry.id, (buffer_id, excerpts)); let mut current_entry = entry; loop { if current_entry.is_dir() { let is_root = worktree.root_entry().map(|entry| entry.id) == Some(current_entry.id); if is_root { root_entries.insert(current_entry.id); if auto_fold_dirs { unfolded_dirs.insert(current_entry.id); } } if is_new { new_collapsed_entries.remove(&CollapsedEntry::Dir( worktree_id, current_entry.id, )); } } let new_entry_added = entries_to_add .insert(current_entry.id, current_entry) .is_none(); if new_entry_added && traversal.back_to_parent() { if let Some(parent_entry) = traversal.entry() { current_entry = parent_entry.to_owned(); continue; } } break; } new_worktree_entries .entry(worktree_id) .or_insert_with(HashMap::default) .extend(entries_to_add); } None => { if processed_external_buffers.insert(buffer_id) { external_excerpts .entry(buffer_id) .or_insert_with(Vec::new) .extend(excerpts); } } } } else if processed_external_buffers.insert(buffer_id) { external_excerpts .entry(buffer_id) .or_insert_with(Vec::new) .extend(excerpts); } } let mut new_children_count = HashMap::, FsChildren>>::default(); let worktree_entries = new_worktree_entries .into_iter() .map(|(worktree_id, entries)| { let mut entries = entries.into_values().collect::>(); entries.sort_by(|a, b| a.path.as_ref().cmp(b.path.as_ref())); (worktree_id, entries) }) .flat_map(|(worktree_id, entries)| { { entries .into_iter() .filter_map(|entry| { if auto_fold_dirs { if let Some(parent) = entry.path.parent() { let children = new_children_count .entry(worktree_id) .or_default() .entry(Arc::from(parent)) .or_default(); if entry.is_dir() { children.dirs += 1; } else { children.files += 1; } } } if entry.is_dir() { Some(FsEntry::Directory(FsEntryDirectory { worktree_id, entry, })) } else { let (buffer_id, excerpts) = worktree_excerpts .get_mut(&worktree_id) .and_then(|worktree_excerpts| { worktree_excerpts.remove(&entry.id) })?; Some(FsEntry::File(FsEntryFile { worktree_id, buffer_id, entry, excerpts, })) } }) .collect::>() } }) .collect::>(); let mut visited_dirs = Vec::new(); let mut new_depth_map = HashMap::default(); let new_visible_entries = external_excerpts .into_iter() .sorted_by_key(|(id, _)| *id) .map(|(buffer_id, excerpts)| { FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, excerpts, }) }) .chain(worktree_entries) .filter(|visible_item| { match visible_item { FsEntry::Directory(directory) => { let parent_id = back_to_common_visited_parent( &mut visited_dirs, &directory.worktree_id, &directory.entry, ); let mut depth = 0; if !root_entries.contains(&directory.entry.id) { if auto_fold_dirs { let children = new_children_count .get(&directory.worktree_id) .and_then(|children_count| { children_count.get(&directory.entry.path) }) .copied() .unwrap_or_default(); if !children.may_be_fold_part() || (children.dirs == 0 && visited_dirs .last() .map(|(parent_dir_id, _)| { new_unfolded_dirs .get(&directory.worktree_id) .map_or(true, |unfolded_dirs| { unfolded_dirs .contains(parent_dir_id) }) }) .unwrap_or(true)) { new_unfolded_dirs .entry(directory.worktree_id) .or_default() .insert(directory.entry.id); } } depth = parent_id .and_then(|(worktree_id, id)| { new_depth_map.get(&(worktree_id, id)).copied() }) .unwrap_or(0) + 1; }; visited_dirs .push((directory.entry.id, directory.entry.path.clone())); new_depth_map .insert((directory.worktree_id, directory.entry.id), depth); } FsEntry::File(FsEntryFile { worktree_id, entry: file_entry, .. }) => { let parent_id = back_to_common_visited_parent( &mut visited_dirs, worktree_id, file_entry, ); let depth = if root_entries.contains(&file_entry.id) { 0 } else { parent_id .and_then(|(worktree_id, id)| { new_depth_map.get(&(worktree_id, id)).copied() }) .unwrap_or(0) + 1 }; new_depth_map.insert((*worktree_id, file_entry.id), depth); } FsEntry::ExternalFile(..) => { visited_dirs.clear(); } } true }) .collect::>(); anyhow::Ok(( new_collapsed_entries, new_unfolded_dirs, new_visible_entries, new_depth_map, new_children_count, )) }) .await .log_err() else { return; }; outline_panel .update_in(cx, |outline_panel, window, cx| { outline_panel.updating_fs_entries = false; outline_panel.new_entries_for_fs_update.clear(); outline_panel.excerpts = new_excerpts; outline_panel.collapsed_entries = new_collapsed_entries; outline_panel.unfolded_dirs = new_unfolded_dirs; outline_panel.fs_entries = new_fs_entries; outline_panel.fs_entries_depth = new_depth_map; outline_panel.fs_children_count = new_children_count; outline_panel.update_non_fs_items(window, cx); outline_panel.update_cached_entries(debounce, window, cx); cx.notify(); }) .ok(); }); } fn replace_active_editor( &mut self, new_active_item: Box, new_active_editor: Entity, window: &mut Window, cx: &mut Context, ) { self.clear_previous(window, cx); let buffer_search_subscription = cx.subscribe_in( &new_active_editor, window, |outline_panel: &mut Self, _, e: &SearchEvent, window: &mut Window, cx: &mut Context| { if matches!(e, SearchEvent::MatchesInvalidated) { let update_cached_items = outline_panel.update_search_matches(window, cx); if update_cached_items { outline_panel.selected_entry.invalidate(); outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); } }; outline_panel.autoscroll(cx); }, ); self.active_item = Some(ActiveItem { _buffer_search_subscription: buffer_search_subscription, _editor_subscription: subscribe_for_editor_events(&new_active_editor, window, cx), item_handle: new_active_item.downgrade_item(), active_editor: new_active_editor.downgrade(), }); self.new_entries_for_fs_update .extend(new_active_editor.read(cx).buffer().read(cx).excerpt_ids()); self.selected_entry.invalidate(); self.update_fs_entries(new_active_editor, None, window, cx); } fn clear_previous(&mut self, window: &mut Window, cx: &mut App) { self.fs_entries_update_task = Task::ready(()); self.outline_fetch_tasks.clear(); self.cached_entries_update_task = Task::ready(()); self.reveal_selection_task = Task::ready(Ok(())); self.filter_editor .update(cx, |editor, cx| editor.clear(window, cx)); self.collapsed_entries.clear(); self.unfolded_dirs.clear(); self.active_item = None; self.fs_entries.clear(); self.fs_entries_depth.clear(); self.fs_children_count.clear(); self.excerpts.clear(); self.cached_entries = Vec::new(); self.selected_entry = SelectedEntry::None; self.pinned = false; self.mode = ItemsDisplayMode::Outline; } fn location_for_editor_selection( &self, editor: &Entity, window: &mut Window, cx: &mut Context, ) -> Option { let selection = editor.update(cx, |editor, cx| { editor.selections.newest::(cx).head() }); let editor_snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); let multi_buffer = editor.read(cx).buffer(); let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx); let (excerpt_id, buffer, _) = editor .read(cx) .buffer() .read(cx) .excerpt_containing(selection, cx)?; let buffer_id = buffer.read(cx).remote_id(); if editor.read(cx).is_buffer_folded(buffer_id, cx) { return self .fs_entries .iter() .find(|fs_entry| match fs_entry { FsEntry::Directory(..) => false, FsEntry::File(FsEntryFile { buffer_id: other_buffer_id, .. }) | FsEntry::ExternalFile(FsEntryExternalFile { buffer_id: other_buffer_id, .. }) => buffer_id == *other_buffer_id, }) .cloned() .map(PanelEntry::Fs); } let selection_display_point = selection.to_display_point(&editor_snapshot); match &self.mode { ItemsDisplayMode::Search(search_state) => search_state .matches .iter() .rev() .min_by_key(|&(match_range, _)| { let match_display_range = match_range.clone().to_display_points(&editor_snapshot); let start_distance = if selection_display_point < match_display_range.start { match_display_range.start - selection_display_point } else { selection_display_point - match_display_range.start }; let end_distance = if selection_display_point < match_display_range.end { match_display_range.end - selection_display_point } else { selection_display_point - match_display_range.end }; start_distance + end_distance }) .and_then(|(closest_range, _)| { self.cached_entries.iter().find_map(|cached_entry| { if let PanelEntry::Search(SearchEntry { match_range, .. }) = &cached_entry.entry { if match_range == closest_range { Some(cached_entry.entry.clone()) } else { None } } else { None } }) }), ItemsDisplayMode::Outline => self.outline_location( buffer_id, excerpt_id, multi_buffer_snapshot, editor_snapshot, selection_display_point, ), } } fn outline_location( &self, buffer_id: BufferId, excerpt_id: ExcerptId, multi_buffer_snapshot: editor::MultiBufferSnapshot, editor_snapshot: editor::EditorSnapshot, selection_display_point: DisplayPoint, ) -> Option { let excerpt_outlines = self .excerpts .get(&buffer_id) .and_then(|excerpts| excerpts.get(&excerpt_id)) .into_iter() .flat_map(|excerpt| excerpt.iter_outlines()) .flat_map(|outline| { let start = multi_buffer_snapshot .anchor_in_excerpt(excerpt_id, outline.range.start)? .to_display_point(&editor_snapshot); let end = multi_buffer_snapshot .anchor_in_excerpt(excerpt_id, outline.range.end)? .to_display_point(&editor_snapshot); Some((start..end, outline)) }) .collect::>(); let mut matching_outline_indices = Vec::new(); let mut children = HashMap::default(); let mut parents_stack = Vec::<(&Range, &&Outline, usize)>::new(); for (i, (outline_range, outline)) in excerpt_outlines.iter().enumerate() { if outline_range .to_inclusive() .contains(&selection_display_point) { matching_outline_indices.push(i); } else if (outline_range.start.row()..outline_range.end.row()) .to_inclusive() .contains(&selection_display_point.row()) { matching_outline_indices.push(i); } while let Some((parent_range, parent_outline, _)) = parents_stack.last() { if parent_outline.depth >= outline.depth || !parent_range.contains(&outline_range.start) { parents_stack.pop(); } else { break; } } if let Some((_, _, parent_index)) = parents_stack.last_mut() { children .entry(*parent_index) .or_insert_with(Vec::new) .push(i); } parents_stack.push((outline_range, outline, i)); } let outline_item = matching_outline_indices .into_iter() .flat_map(|i| Some((i, excerpt_outlines.get(i)?))) .filter(|(i, _)| { children .get(i) .map(|children| { children.iter().all(|child_index| { excerpt_outlines .get(*child_index) .map(|(child_range, _)| child_range.start > selection_display_point) .unwrap_or(false) }) }) .unwrap_or(true) }) .min_by_key(|(_, (outline_range, outline))| { let distance_from_start = if outline_range.start > selection_display_point { outline_range.start - selection_display_point } else { selection_display_point - outline_range.start }; let distance_from_end = if outline_range.end > selection_display_point { outline_range.end - selection_display_point } else { selection_display_point - outline_range.end }; ( cmp::Reverse(outline.depth), distance_from_start + distance_from_end, ) }) .map(|(_, (_, outline))| *outline) .cloned(); let closest_container = match outline_item { Some(outline) => PanelEntry::Outline(OutlineEntry::Outline(OutlineEntryOutline { buffer_id, excerpt_id, outline, })), None => { self.cached_entries.iter().rev().find_map(|cached_entry| { match &cached_entry.entry { PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { if excerpt.buffer_id == buffer_id && excerpt.id == excerpt_id { Some(cached_entry.entry.clone()) } else { None } } PanelEntry::Fs( FsEntry::ExternalFile(FsEntryExternalFile { buffer_id: file_buffer_id, excerpts: file_excerpts, }) | FsEntry::File(FsEntryFile { buffer_id: file_buffer_id, excerpts: file_excerpts, .. }), ) => { if file_buffer_id == &buffer_id && file_excerpts.contains(&excerpt_id) { Some(cached_entry.entry.clone()) } else { None } } _ => None, } })? } }; Some(closest_container) } fn fetch_outdated_outlines(&mut self, window: &mut Window, cx: &mut Context) { let excerpt_fetch_ranges = self.excerpt_fetch_ranges(cx); if excerpt_fetch_ranges.is_empty() { return; } let syntax_theme = cx.theme().syntax().clone(); let first_update = Arc::new(AtomicBool::new(true)); for (buffer_id, (buffer_snapshot, excerpt_ranges)) in excerpt_fetch_ranges { for (excerpt_id, excerpt_range) in excerpt_ranges { let syntax_theme = syntax_theme.clone(); let buffer_snapshot = buffer_snapshot.clone(); let first_update = first_update.clone(); self.outline_fetch_tasks.insert( (buffer_id, excerpt_id), cx.spawn_in(window, async move |outline_panel, cx| { let buffer_language = buffer_snapshot.language().cloned(); let fetched_outlines = cx .background_spawn(async move { let mut outlines = buffer_snapshot .outline_items_containing( excerpt_range.context, false, Some(&syntax_theme), ) .unwrap_or_default(); outlines.retain(|outline| { buffer_language.is_none() || buffer_language.as_ref() == buffer_snapshot.language_at(outline.range.start) }); outlines }) .await; outline_panel .update_in(cx, |outline_panel, window, cx| { if let Some(excerpt) = outline_panel .excerpts .entry(buffer_id) .or_default() .get_mut(&excerpt_id) { let debounce = if first_update .fetch_and(false, atomic::Ordering::AcqRel) { None } else { Some(UPDATE_DEBOUNCE) }; excerpt.outlines = ExcerptOutlines::Outlines(fetched_outlines); outline_panel.update_cached_entries(debounce, window, cx); } }) .ok(); }), ); } } } fn is_singleton_active(&self, cx: &App) -> bool { self.active_editor().map_or(false, |active_editor| { active_editor.read(cx).buffer().read(cx).is_singleton() }) } fn invalidate_outlines(&mut self, ids: &[ExcerptId]) { self.outline_fetch_tasks.clear(); let mut ids = ids.iter().collect::>(); for excerpts in self.excerpts.values_mut() { ids.retain(|id| { if let Some(excerpt) = excerpts.get_mut(id) { excerpt.invalidate_outlines(); false } else { true } }); if ids.is_empty() { break; } } } fn excerpt_fetch_ranges( &self, cx: &App, ) -> HashMap< BufferId, ( BufferSnapshot, HashMap>, ), > { self.fs_entries .iter() .fold(HashMap::default(), |mut excerpts_to_fetch, fs_entry| { match fs_entry { FsEntry::File(FsEntryFile { buffer_id, excerpts: file_excerpts, .. }) | FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, excerpts: file_excerpts, }) => { let excerpts = self.excerpts.get(buffer_id); for &file_excerpt in file_excerpts { if let Some(excerpt) = excerpts .and_then(|excerpts| excerpts.get(&file_excerpt)) .filter(|excerpt| excerpt.should_fetch_outlines()) { match excerpts_to_fetch.entry(*buffer_id) { hash_map::Entry::Occupied(mut o) => { o.get_mut().1.insert(file_excerpt, excerpt.range.clone()); } hash_map::Entry::Vacant(v) => { if let Some(buffer_snapshot) = self.buffer_snapshot_for_id(*buffer_id, cx) { v.insert((buffer_snapshot, HashMap::default())) .1 .insert(file_excerpt, excerpt.range.clone()); } } } } } } FsEntry::Directory(..) => {} } excerpts_to_fetch }) } fn buffer_snapshot_for_id(&self, buffer_id: BufferId, cx: &App) -> Option { let editor = self.active_editor()?; Some( editor .read(cx) .buffer() .read(cx) .buffer(buffer_id)? .read(cx) .snapshot(), ) } fn abs_path(&self, entry: &PanelEntry, cx: &App) -> Option { match entry { PanelEntry::Fs( FsEntry::File(FsEntryFile { buffer_id, .. }) | FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }), ) => self .buffer_snapshot_for_id(*buffer_id, cx) .and_then(|buffer_snapshot| { let file = File::from_dyn(buffer_snapshot.file())?; file.worktree.read(cx).absolutize(&file.path).ok() }), PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. })) => self .project .read(cx) .worktree_for_id(*worktree_id, cx)? .read(cx) .absolutize(&entry.path) .ok(), PanelEntry::FoldedDirs(FoldedDirsEntry { worktree_id, entries: dirs, .. }) => dirs.last().and_then(|entry| { self.project .read(cx) .worktree_for_id(*worktree_id, cx) .and_then(|worktree| worktree.read(cx).absolutize(&entry.path).ok()) }), PanelEntry::Search(_) | PanelEntry::Outline(..) => None, } } fn relative_path(&self, entry: &FsEntry, cx: &App) -> Option> { match entry { FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }) => { let buffer_snapshot = self.buffer_snapshot_for_id(*buffer_id, cx)?; Some(buffer_snapshot.file()?.path().clone()) } FsEntry::Directory(FsEntryDirectory { entry, .. }) => Some(entry.path.clone()), FsEntry::File(FsEntryFile { entry, .. }) => Some(entry.path.clone()), } } fn update_cached_entries( &mut self, debounce: Option, window: &mut Window, cx: &mut Context, ) { if !self.active { return; } let is_singleton = self.is_singleton_active(cx); let query = self.query(cx); self.updating_cached_entries = true; self.cached_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| { if let Some(debounce) = debounce { cx.background_executor().timer(debounce).await; } let Some(new_cached_entries) = outline_panel .update_in(cx, |outline_panel, window, cx| { outline_panel.generate_cached_entries(is_singleton, query, window, cx) }) .ok() else { return; }; let (new_cached_entries, max_width_item_index) = new_cached_entries.await; outline_panel .update_in(cx, |outline_panel, window, cx| { outline_panel.cached_entries = new_cached_entries; outline_panel.max_width_item_index = max_width_item_index; if outline_panel.selected_entry.is_invalidated() || matches!(outline_panel.selected_entry, SelectedEntry::None) { if let Some(new_selected_entry) = outline_panel.active_editor().and_then(|active_editor| { outline_panel.location_for_editor_selection( &active_editor, window, cx, ) }) { outline_panel.select_entry(new_selected_entry, false, window, cx); } } outline_panel.autoscroll(cx); outline_panel.updating_cached_entries = false; cx.notify(); }) .ok(); }); } fn generate_cached_entries( &self, is_singleton: bool, query: Option, window: &mut Window, cx: &mut Context, ) -> Task<(Vec, Option)> { let project = self.project.clone(); let Some(active_editor) = self.active_editor() else { return Task::ready((Vec::new(), None)); }; cx.spawn_in(window, async move |outline_panel, cx| { let mut generation_state = GenerationState::default(); let Ok(()) = outline_panel.update(cx, |outline_panel, cx| { let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; let mut folded_dirs_entry = None::<(usize, FoldedDirsEntry)>; let track_matches = query.is_some(); #[derive(Debug)] struct ParentStats { path: Arc, folded: bool, expanded: bool, depth: usize, } let mut parent_dirs = Vec::::new(); for entry in outline_panel.fs_entries.clone() { let is_expanded = outline_panel.is_expanded(&entry); let (depth, should_add) = match &entry { FsEntry::Directory(directory_entry) => { let mut should_add = true; let is_root = project .read(cx) .worktree_for_id(directory_entry.worktree_id, cx) .map_or(false, |worktree| { worktree.read(cx).root_entry() == Some(&directory_entry.entry) }); let folded = auto_fold_dirs && !is_root && outline_panel .unfolded_dirs .get(&directory_entry.worktree_id) .map_or(true, |unfolded_dirs| { !unfolded_dirs.contains(&directory_entry.entry.id) }); let fs_depth = outline_panel .fs_entries_depth .get(&(directory_entry.worktree_id, directory_entry.entry.id)) .copied() .unwrap_or(0); while let Some(parent) = parent_dirs.last() { if !is_root && directory_entry.entry.path.starts_with(&parent.path) { break; } parent_dirs.pop(); } let auto_fold = match parent_dirs.last() { Some(parent) => { parent.folded && Some(parent.path.as_ref()) == directory_entry.entry.path.parent() && outline_panel .fs_children_count .get(&directory_entry.worktree_id) .and_then(|entries| { entries.get(&directory_entry.entry.path) }) .copied() .unwrap_or_default() .may_be_fold_part() } None => false, }; let folded = folded || auto_fold; let (depth, parent_expanded, parent_folded) = match parent_dirs.last() { Some(parent) => { let parent_folded = parent.folded; let parent_expanded = parent.expanded; let new_depth = if parent_folded { parent.depth } else { parent.depth + 1 }; parent_dirs.push(ParentStats { path: directory_entry.entry.path.clone(), folded, expanded: parent_expanded && is_expanded, depth: new_depth, }); (new_depth, parent_expanded, parent_folded) } None => { parent_dirs.push(ParentStats { path: directory_entry.entry.path.clone(), folded, expanded: is_expanded, depth: fs_depth, }); (fs_depth, true, false) } }; if let Some((folded_depth, mut folded_dirs)) = folded_dirs_entry.take() { if folded && directory_entry.worktree_id == folded_dirs.worktree_id && directory_entry.entry.path.parent() == folded_dirs .entries .last() .map(|entry| entry.path.as_ref()) { folded_dirs.entries.push(directory_entry.entry.clone()); folded_dirs_entry = Some((folded_depth, folded_dirs)) } else { if !is_singleton { let start_of_collapsed_dir_sequence = !parent_expanded && parent_dirs .iter() .rev() .nth(folded_dirs.entries.len() + 1) .map_or(true, |parent| parent.expanded); if start_of_collapsed_dir_sequence || parent_expanded || query.is_some() { if parent_folded { folded_dirs .entries .push(directory_entry.entry.clone()); should_add = false; } let new_folded_dirs = PanelEntry::FoldedDirs(folded_dirs.clone()); outline_panel.push_entry( &mut generation_state, track_matches, new_folded_dirs, folded_depth, cx, ); } } folded_dirs_entry = if parent_folded { None } else { Some(( depth, FoldedDirsEntry { worktree_id: directory_entry.worktree_id, entries: vec![directory_entry.entry.clone()], }, )) }; } } else if folded { folded_dirs_entry = Some(( depth, FoldedDirsEntry { worktree_id: directory_entry.worktree_id, entries: vec![directory_entry.entry.clone()], }, )); } let should_add = should_add && parent_expanded && folded_dirs_entry.is_none(); (depth, should_add) } FsEntry::ExternalFile(..) => { if let Some((folded_depth, folded_dir)) = folded_dirs_entry.take() { let parent_expanded = parent_dirs .iter() .rev() .find(|parent| { folded_dir .entries .iter() .all(|entry| entry.path != parent.path) }) .map_or(true, |parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( &mut generation_state, track_matches, PanelEntry::FoldedDirs(folded_dir), folded_depth, cx, ); } } parent_dirs.clear(); (0, true) } FsEntry::File(file) => { if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() { let parent_expanded = parent_dirs .iter() .rev() .find(|parent| { folded_dirs .entries .iter() .all(|entry| entry.path != parent.path) }) .map_or(true, |parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( &mut generation_state, track_matches, PanelEntry::FoldedDirs(folded_dirs), folded_depth, cx, ); } } let fs_depth = outline_panel .fs_entries_depth .get(&(file.worktree_id, file.entry.id)) .copied() .unwrap_or(0); while let Some(parent) = parent_dirs.last() { if file.entry.path.starts_with(&parent.path) { break; } parent_dirs.pop(); } match parent_dirs.last() { Some(parent) => { let new_depth = parent.depth + 1; (new_depth, parent.expanded) } None => (fs_depth, true), } } }; if !is_singleton && (should_add || (query.is_some() && folded_dirs_entry.is_none())) { outline_panel.push_entry( &mut generation_state, track_matches, PanelEntry::Fs(entry.clone()), depth, cx, ); } match outline_panel.mode { ItemsDisplayMode::Search(_) => { if is_singleton || query.is_some() || (should_add && is_expanded) { outline_panel.add_search_entries( &mut generation_state, &active_editor, entry.clone(), depth, query.clone(), is_singleton, cx, ); } } ItemsDisplayMode::Outline => { let excerpts_to_consider = if is_singleton || query.is_some() || (should_add && is_expanded) { match &entry { FsEntry::File(FsEntryFile { buffer_id, excerpts, .. }) | FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, excerpts, .. }) => Some((*buffer_id, excerpts)), _ => None, } } else { None }; if let Some((buffer_id, entry_excerpts)) = excerpts_to_consider { if !active_editor.read(cx).is_buffer_folded(buffer_id, cx) { outline_panel.add_excerpt_entries( &mut generation_state, buffer_id, entry_excerpts, depth, track_matches, is_singleton, query.as_deref(), cx, ); } } } } if is_singleton && matches!(entry, FsEntry::File(..) | FsEntry::ExternalFile(..)) && !generation_state.entries.iter().any(|item| { matches!(item.entry, PanelEntry::Outline(..) | PanelEntry::Search(_)) }) { outline_panel.push_entry( &mut generation_state, track_matches, PanelEntry::Fs(entry.clone()), 0, cx, ); } } if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() { let parent_expanded = parent_dirs .iter() .rev() .find(|parent| { folded_dirs .entries .iter() .all(|entry| entry.path != parent.path) }) .map_or(true, |parent| parent.expanded); if parent_expanded || query.is_some() { outline_panel.push_entry( &mut generation_state, track_matches, PanelEntry::FoldedDirs(folded_dirs), folded_depth, cx, ); } } }) else { return (Vec::new(), None); }; let Some(query) = query else { return ( generation_state.entries, generation_state .max_width_estimate_and_index .map(|(_, index)| index), ); }; let mut matched_ids = match_strings( &generation_state.match_candidates, &query, true, true, usize::MAX, &AtomicBool::default(), cx.background_executor().clone(), ) .await .into_iter() .map(|string_match| (string_match.candidate_id, string_match)) .collect::>(); let mut id = 0; generation_state.entries.retain_mut(|cached_entry| { let retain = match matched_ids.remove(&id) { Some(string_match) => { cached_entry.string_match = Some(string_match); true } None => false, }; id += 1; retain }); ( generation_state.entries, generation_state .max_width_estimate_and_index .map(|(_, index)| index), ) }) } fn push_entry( &self, state: &mut GenerationState, track_matches: bool, entry: PanelEntry, depth: usize, cx: &mut App, ) { let entry = if let PanelEntry::FoldedDirs(folded_dirs_entry) = &entry { match folded_dirs_entry.entries.len() { 0 => { debug_panic!("Empty folded dirs receiver"); return; } 1 => PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id: folded_dirs_entry.worktree_id, entry: folded_dirs_entry.entries[0].clone(), })), _ => entry, } } else { entry }; if track_matches { let id = state.entries.len(); match &entry { PanelEntry::Fs(fs_entry) => { if let Some(file_name) = self.relative_path(fs_entry, cx).as_deref().map(file_name) { state .match_candidates .push(StringMatchCandidate::new(id, &file_name)); } } PanelEntry::FoldedDirs(folded_dir_entry) => { let dir_names = self.dir_names_string( &folded_dir_entry.entries, folded_dir_entry.worktree_id, cx, ); { state .match_candidates .push(StringMatchCandidate::new(id, &dir_names)); } } PanelEntry::Outline(OutlineEntry::Outline(outline_entry)) => state .match_candidates .push(StringMatchCandidate::new(id, &outline_entry.outline.text)), PanelEntry::Outline(OutlineEntry::Excerpt(_)) => {} PanelEntry::Search(new_search_entry) => { if let Some(search_data) = new_search_entry.render_data.get() { state .match_candidates .push(StringMatchCandidate::new(id, &search_data.context_text)); } } } } let width_estimate = self.width_estimate(depth, &entry, cx); if Some(width_estimate) > state .max_width_estimate_and_index .map(|(estimate, _)| estimate) { state.max_width_estimate_and_index = Some((width_estimate, state.entries.len())); } state.entries.push(CachedEntry { depth, entry, string_match: None, }); } fn dir_names_string(&self, entries: &[GitEntry], worktree_id: WorktreeId, cx: &App) -> String { let dir_names_segment = entries .iter() .map(|entry| self.entry_name(&worktree_id, entry, cx)) .collect::(); dir_names_segment.to_string_lossy().to_string() } fn query(&self, cx: &App) -> Option { let query = self.filter_editor.read(cx).text(cx); if query.trim().is_empty() { None } else { Some(query) } } fn is_expanded(&self, entry: &FsEntry) -> bool { let entry_to_check = match entry { FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }) => { CollapsedEntry::ExternalFile(*buffer_id) } FsEntry::File(FsEntryFile { worktree_id, buffer_id, .. }) => CollapsedEntry::File(*worktree_id, *buffer_id), FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. }) => CollapsedEntry::Dir(*worktree_id, entry.id), }; !self.collapsed_entries.contains(&entry_to_check) } fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context) -> bool { if !self.active { return false; } let mut update_cached_items = false; update_cached_items |= self.update_search_matches(window, cx); self.fetch_outdated_outlines(window, cx); if update_cached_items { self.selected_entry.invalidate(); } update_cached_items } fn update_search_matches( &mut self, window: &mut Window, cx: &mut Context, ) -> bool { if !self.active { return false; } let project_search = self .active_item() .and_then(|item| item.downcast::()); let project_search_matches = project_search .as_ref() .map(|project_search| project_search.read(cx).get_matches(cx)) .unwrap_or_default(); let buffer_search = self .active_item() .as_deref() .and_then(|active_item| { self.workspace .upgrade() .and_then(|workspace| workspace.read(cx).pane_for(active_item)) }) .and_then(|pane| { pane.read(cx) .toolbar() .read(cx) .item_of_type::() }); let buffer_search_matches = self .active_editor() .map(|active_editor| { active_editor.update(cx, |editor, cx| editor.get_matches(window, cx)) }) .unwrap_or_default(); let mut update_cached_entries = false; if buffer_search_matches.is_empty() && project_search_matches.is_empty() { if matches!(self.mode, ItemsDisplayMode::Search(_)) { self.mode = ItemsDisplayMode::Outline; update_cached_entries = true; } } else { let (kind, new_search_matches, new_search_query) = if buffer_search_matches.is_empty() { ( SearchKind::Project, project_search_matches, project_search .map(|project_search| project_search.read(cx).search_query_text(cx)) .unwrap_or_default(), ) } else { ( SearchKind::Buffer, buffer_search_matches, buffer_search .map(|buffer_search| buffer_search.read(cx).query(cx)) .unwrap_or_default(), ) }; let mut previous_matches = HashMap::default(); update_cached_entries = match &mut self.mode { ItemsDisplayMode::Search(current_search_state) => { let update = current_search_state.query != new_search_query || current_search_state.kind != kind || current_search_state.matches.is_empty() || current_search_state.matches.iter().enumerate().any( |(i, (match_range, _))| new_search_matches.get(i) != Some(match_range), ); if current_search_state.kind == kind { previous_matches.extend(current_search_state.matches.drain(..)); } update } ItemsDisplayMode::Outline => true, }; self.mode = ItemsDisplayMode::Search(SearchState::new( kind, new_search_query, previous_matches, new_search_matches, cx.theme().syntax().clone(), window, cx, )); } update_cached_entries } fn add_excerpt_entries( &self, state: &mut GenerationState, buffer_id: BufferId, entries_to_add: &[ExcerptId], parent_depth: usize, track_matches: bool, is_singleton: bool, query: Option<&str>, cx: &mut Context, ) { if let Some(excerpts) = self.excerpts.get(&buffer_id) { for &excerpt_id in entries_to_add { let Some(excerpt) = excerpts.get(&excerpt_id) else { continue; }; let excerpt_depth = parent_depth + 1; self.push_entry( state, track_matches, PanelEntry::Outline(OutlineEntry::Excerpt(OutlineEntryExcerpt { buffer_id, id: excerpt_id, range: excerpt.range.clone(), })), excerpt_depth, cx, ); let mut outline_base_depth = excerpt_depth + 1; if is_singleton { outline_base_depth = 0; state.clear(); } else if query.is_none() && self .collapsed_entries .contains(&CollapsedEntry::Excerpt(buffer_id, excerpt_id)) { continue; } for outline in excerpt.iter_outlines() { self.push_entry( state, track_matches, PanelEntry::Outline(OutlineEntry::Outline(OutlineEntryOutline { buffer_id, excerpt_id, outline: outline.clone(), })), outline_base_depth + outline.depth, cx, ); } } } } fn add_search_entries( &mut self, state: &mut GenerationState, active_editor: &Entity, parent_entry: FsEntry, parent_depth: usize, filter_query: Option, is_singleton: bool, cx: &mut Context, ) { let ItemsDisplayMode::Search(search_state) = &mut self.mode else { return; }; let kind = search_state.kind; let related_excerpts = match &parent_entry { FsEntry::Directory(_) => return, FsEntry::ExternalFile(external) => &external.excerpts, FsEntry::File(file) => &file.excerpts, } .iter() .copied() .collect::>(); let depth = if is_singleton { 0 } else { parent_depth + 1 }; let new_search_matches = search_state .matches .iter() .filter(|(match_range, _)| { related_excerpts.contains(&match_range.start.excerpt_id) || related_excerpts.contains(&match_range.end.excerpt_id) }) .filter(|(match_range, _)| { let editor = active_editor.read(cx); if let Some(buffer_id) = match_range.start.buffer_id { if editor.is_buffer_folded(buffer_id, cx) { return false; } } if let Some(buffer_id) = match_range.start.buffer_id { if editor.is_buffer_folded(buffer_id, cx) { return false; } } true }); let new_search_entries = new_search_matches .map(|(match_range, search_data)| SearchEntry { match_range: match_range.clone(), kind, render_data: Arc::clone(search_data), }) .collect::>(); for new_search_entry in new_search_entries { self.push_entry( state, filter_query.is_some(), PanelEntry::Search(new_search_entry), depth, cx, ); } } fn active_editor(&self) -> Option> { self.active_item.as_ref()?.active_editor.upgrade() } fn active_item(&self) -> Option> { self.active_item.as_ref()?.item_handle.upgrade() } fn should_replace_active_item(&self, new_active_item: &dyn ItemHandle) -> bool { self.active_item().map_or(true, |active_item| { !self.pinned && active_item.item_id() != new_active_item.item_id() }) } pub fn toggle_active_editor_pin( &mut self, _: &ToggleActiveEditorPin, window: &mut Window, cx: &mut Context, ) { self.pinned = !self.pinned; if !self.pinned { if let Some((active_item, active_editor)) = self .workspace .upgrade() .and_then(|workspace| workspace_active_editor(workspace.read(cx), cx)) { if self.should_replace_active_item(active_item.as_ref()) { self.replace_active_editor(active_item, active_editor, window, cx); } } } cx.notify(); } fn selected_entry(&self) -> Option<&PanelEntry> { match &self.selected_entry { SelectedEntry::Invalidated(entry) => entry.as_ref(), SelectedEntry::Valid(entry, _) => Some(entry), SelectedEntry::None => None, } } fn select_entry( &mut self, entry: PanelEntry, focus: bool, window: &mut Window, cx: &mut Context, ) { if focus { self.focus_handle.focus(window); } let ix = self .cached_entries .iter() .enumerate() .find(|(_, cached_entry)| &cached_entry.entry == &entry) .map(|(i, _)| i) .unwrap_or_default(); self.selected_entry = SelectedEntry::Valid(entry, ix); self.autoscroll(cx); cx.notify(); } fn render_vertical_scrollbar(&self, cx: &mut Context) -> Option> { if !Self::should_show_scrollbar(cx) || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging()) { return None; } Some( div() .occlude() .id("project-panel-vertical-scroll") .on_mouse_move(cx.listener(|_, _, _, cx| { cx.notify(); cx.stop_propagation() })) .on_hover(|_, _, cx| { cx.stop_propagation(); }) .on_any_mouse_down(|_, _, cx| { cx.stop_propagation(); }) .on_mouse_up( MouseButton::Left, cx.listener(|outline_panel, _, window, cx| { if !outline_panel.vertical_scrollbar_state.is_dragging() && !outline_panel.focus_handle.contains_focused(window, cx) { outline_panel.hide_scrollbar(window, cx); cx.notify(); } cx.stop_propagation(); }), ) .on_scroll_wheel(cx.listener(|_, _, _, cx| { cx.notify(); })) .h_full() .absolute() .right_1() .top_1() .bottom_0() .w(px(12.)) .cursor_default() .children(Scrollbar::vertical(self.vertical_scrollbar_state.clone())), ) } fn render_horizontal_scrollbar( &self, _: &mut Window, cx: &mut Context, ) -> Option> { if !Self::should_show_scrollbar(cx) || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging()) { return None; } Scrollbar::horizontal(self.horizontal_scrollbar_state.clone()).map(|scrollbar| { div() .occlude() .id("project-panel-horizontal-scroll") .on_mouse_move(cx.listener(|_, _, _, cx| { cx.notify(); cx.stop_propagation() })) .on_hover(|_, _, cx| { cx.stop_propagation(); }) .on_any_mouse_down(|_, _, cx| { cx.stop_propagation(); }) .on_mouse_up( MouseButton::Left, cx.listener(|outline_panel, _, window, cx| { if !outline_panel.horizontal_scrollbar_state.is_dragging() && !outline_panel.focus_handle.contains_focused(window, cx) { outline_panel.hide_scrollbar(window, cx); cx.notify(); } cx.stop_propagation(); }), ) .on_scroll_wheel(cx.listener(|_, _, _, cx| { cx.notify(); })) .w_full() .absolute() .right_1() .left_1() .bottom_0() .h(px(12.)) .cursor_default() .child(scrollbar) }) } fn should_show_scrollbar(cx: &App) -> bool { let show = OutlinePanelSettings::get_global(cx) .scrollbar .show .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show); match show { ShowScrollbar::Auto => true, ShowScrollbar::System => true, ShowScrollbar::Always => true, ShowScrollbar::Never => false, } } fn should_autohide_scrollbar(cx: &App) -> bool { let show = OutlinePanelSettings::get_global(cx) .scrollbar .show .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show); match show { ShowScrollbar::Auto => true, ShowScrollbar::System => cx .try_global::() .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0), ShowScrollbar::Always => false, ShowScrollbar::Never => true, } } fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context) { const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); if !Self::should_autohide_scrollbar(cx) { return; } self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| { cx.background_executor() .timer(SCROLLBAR_SHOW_INTERVAL) .await; panel .update(cx, |panel, cx| { panel.show_scrollbar = false; cx.notify(); }) .log_err(); })) } fn width_estimate(&self, depth: usize, entry: &PanelEntry, cx: &App) -> u64 { let item_text_chars = match entry { PanelEntry::Fs(FsEntry::ExternalFile(external)) => self .buffer_snapshot_for_id(external.buffer_id, cx) .and_then(|snapshot| { Some(snapshot.file()?.path().file_name()?.to_string_lossy().len()) }) .unwrap_or_default(), PanelEntry::Fs(FsEntry::Directory(directory)) => directory .entry .path .file_name() .map(|name| name.to_string_lossy().len()) .unwrap_or_default(), PanelEntry::Fs(FsEntry::File(file)) => file .entry .path .file_name() .map(|name| name.to_string_lossy().len()) .unwrap_or_default(), PanelEntry::FoldedDirs(folded_dirs) => { folded_dirs .entries .iter() .map(|dir| { dir.path .file_name() .map(|name| name.to_string_lossy().len()) .unwrap_or_default() }) .sum::() + folded_dirs.entries.len().saturating_sub(1) * MAIN_SEPARATOR_STR.len() } PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => self .excerpt_label(excerpt.buffer_id, &excerpt.range, cx) .map(|label| label.len()) .unwrap_or_default(), PanelEntry::Outline(OutlineEntry::Outline(entry)) => entry.outline.text.len(), PanelEntry::Search(search) => search .render_data .get() .map(|data| data.context_text.len()) .unwrap_or_default(), }; (item_text_chars + depth) as u64 } fn render_main_contents( &mut self, query: Option, show_indent_guides: bool, indent_size: f32, window: &mut Window, cx: &mut Context, ) -> Div { let contents = if self.cached_entries.is_empty() { let header = if self.updating_fs_entries || self.updating_cached_entries { None } else if query.is_some() { Some("No matches for query") } else { Some("No outlines available") }; v_flex() .flex_1() .justify_center() .size_full() .when_some(header, |panel, header| { panel .child(h_flex().justify_center().child(Label::new(header))) .when_some(query.clone(), |panel, query| { panel.child(h_flex().justify_center().child(Label::new(query))) }) .child( h_flex() .pt(DynamicSpacing::Base04.rems(cx)) .justify_center() .child({ let keystroke = match self.position(window, cx) { DockPosition::Left => window .keystroke_text_for(&workspace::ToggleLeftDock), DockPosition::Bottom => window .keystroke_text_for(&workspace::ToggleBottomDock), DockPosition::Right => window .keystroke_text_for(&workspace::ToggleRightDock), }; Label::new(format!("Toggle this panel with {keystroke}")) }), ) }) } else { let list_contents = { let items_len = self.cached_entries.len(); let multi_buffer_snapshot = self .active_editor() .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx)); uniform_list( "entries", items_len, cx.processor(move |outline_panel, range: Range, window, cx| { let entries = outline_panel.cached_entries.get(range); entries .map(|entries| entries.to_vec()) .unwrap_or_default() .into_iter() .filter_map(|cached_entry| match cached_entry.entry { PanelEntry::Fs(entry) => Some(outline_panel.render_entry( &entry, cached_entry.depth, cached_entry.string_match.as_ref(), window, cx, )), PanelEntry::FoldedDirs(folded_dirs_entry) => { Some(outline_panel.render_folded_dirs( &folded_dirs_entry, cached_entry.depth, cached_entry.string_match.as_ref(), window, cx, )) } PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { outline_panel.render_excerpt( &excerpt, cached_entry.depth, window, cx, ) } PanelEntry::Outline(OutlineEntry::Outline(entry)) => { Some(outline_panel.render_outline( &entry, cached_entry.depth, cached_entry.string_match.as_ref(), window, cx, )) } PanelEntry::Search(SearchEntry { match_range, render_data, kind, .. }) => outline_panel.render_search_match( multi_buffer_snapshot.as_ref(), &match_range, &render_data, kind, cached_entry.depth, cached_entry.string_match.as_ref(), window, cx, ), }) .collect() }), ) .with_sizing_behavior(ListSizingBehavior::Infer) .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) .with_width_from_item(self.max_width_item_index) .track_scroll(self.scroll_handle.clone()) .when(show_indent_guides, |list| { list.with_decoration( ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx)) .with_compute_indents_fn( cx.entity().clone(), |outline_panel, range, _, _| { let entries = outline_panel.cached_entries.get(range); if let Some(entries) = entries { entries.into_iter().map(|item| item.depth).collect() } else { smallvec::SmallVec::new() } }, ) .with_render_fn( cx.entity().clone(), move |outline_panel, params, _, _| { const LEFT_OFFSET: Pixels = px(14.); let indent_size = params.indent_size; let item_height = params.item_height; let active_indent_guide_ix = find_active_indent_guide_ix( outline_panel, ¶ms.indent_guides, ); params .indent_guides .into_iter() .enumerate() .map(|(ix, layout)| { let bounds = Bounds::new( point( layout.offset.x * indent_size + LEFT_OFFSET, layout.offset.y * item_height, ), size(px(1.), layout.length * item_height), ); ui::RenderedIndentGuide { bounds, layout, is_active: active_indent_guide_ix == Some(ix), hitbox: None, } }) .collect() }, ), ) }) }; v_flex() .flex_shrink() .size_full() .child(list_contents.size_full().flex_shrink()) .children(self.render_vertical_scrollbar(cx)) .when_some( self.render_horizontal_scrollbar(window, cx), |this, scrollbar| this.pb_4().child(scrollbar), ) } .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( anchored() .position(*position) .anchor(gpui::Corner::TopLeft) .child(menu.clone()), ) .with_priority(1) })); v_flex().w_full().flex_1().overflow_hidden().child(contents) } fn render_filter_footer(&mut self, pinned: bool, cx: &mut Context) -> Div { v_flex().flex_none().child(horizontal_separator(cx)).child( h_flex() .p_2() .w_full() .child(self.filter_editor.clone()) .child( div().child( IconButton::new( "outline-panel-menu", if pinned { IconName::Unpin } else { IconName::Pin }, ) .tooltip(Tooltip::text(if pinned { "Unpin Outline" } else { "Pin Active Outline" })) .shape(IconButtonShape::Square) .on_click(cx.listener( |outline_panel, _, window, cx| { outline_panel.toggle_active_editor_pin( &ToggleActiveEditorPin, window, cx, ); }, )), ), ), ) } fn buffers_inside_directory( &self, dir_worktree: WorktreeId, dir_entry: &GitEntry, ) -> HashSet { if !dir_entry.is_dir() { debug_panic!("buffers_inside_directory called on a non-directory entry {dir_entry:?}"); return HashSet::default(); } self.fs_entries .iter() .skip_while(|fs_entry| match fs_entry { FsEntry::Directory(directory) => { directory.worktree_id != dir_worktree || &directory.entry != dir_entry } _ => true, }) .skip(1) .take_while(|fs_entry| match fs_entry { FsEntry::ExternalFile(..) => false, FsEntry::Directory(directory) => { directory.worktree_id == dir_worktree && directory.entry.path.starts_with(&dir_entry.path) } FsEntry::File(file) => { file.worktree_id == dir_worktree && file.entry.path.starts_with(&dir_entry.path) } }) .filter_map(|fs_entry| match fs_entry { FsEntry::File(file) => Some(file.buffer_id), _ => None, }) .collect() } } fn workspace_active_editor( workspace: &Workspace, cx: &App, ) -> Option<(Box, Entity)> { let active_item = workspace.active_item(cx)?; let active_editor = active_item .act_as::(cx) .filter(|editor| editor.read(cx).mode().is_full())?; Some((active_item, active_editor)) } fn back_to_common_visited_parent( visited_dirs: &mut Vec<(ProjectEntryId, Arc)>, worktree_id: &WorktreeId, new_entry: &Entry, ) -> Option<(WorktreeId, ProjectEntryId)> { while let Some((visited_dir_id, visited_path)) = visited_dirs.last() { match new_entry.path.parent() { Some(parent_path) => { if parent_path == visited_path.as_ref() { return Some((*worktree_id, *visited_dir_id)); } } None => { break; } } visited_dirs.pop(); } None } fn file_name(path: &Path) -> String { let mut current_path = path; loop { if let Some(file_name) = current_path.file_name() { return file_name.to_string_lossy().into_owned(); } match current_path.parent() { Some(parent) => current_path = parent, None => return path.to_string_lossy().into_owned(), } } } impl Panel for OutlinePanel { fn persistent_name() -> &'static str { "Outline Panel" } fn position(&self, _: &Window, cx: &App) -> DockPosition { match OutlinePanelSettings::get_global(cx).dock { OutlinePanelDockPosition::Left => DockPosition::Left, OutlinePanelDockPosition::Right => DockPosition::Right, } } fn position_is_valid(&self, position: DockPosition) -> bool { matches!(position, DockPosition::Left | DockPosition::Right) } fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { settings::update_settings_file::( self.fs.clone(), cx, move |settings, _| { let dock = match position { DockPosition::Left | DockPosition::Bottom => OutlinePanelDockPosition::Left, DockPosition::Right => OutlinePanelDockPosition::Right, }; settings.dock = Some(dock); }, ); } fn size(&self, _: &Window, cx: &App) -> Pixels { self.width .unwrap_or_else(|| OutlinePanelSettings::get_global(cx).default_width) } fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { self.width = size; cx.notify(); cx.defer_in(window, |this, _, cx| { this.serialize(cx); }); } fn icon(&self, _: &Window, cx: &App) -> Option { OutlinePanelSettings::get_global(cx) .button .then_some(IconName::ListTree) } fn icon_tooltip(&self, _window: &Window, _: &App) -> Option<&'static str> { Some("Outline Panel") } fn toggle_action(&self) -> Box { Box::new(ToggleFocus) } fn starts_open(&self, _window: &Window, _: &App) -> bool { self.active } fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { cx.spawn_in(window, async move |outline_panel, cx| { outline_panel .update_in(cx, |outline_panel, window, cx| { let old_active = outline_panel.active; outline_panel.active = active; if old_active != active { if active { if let Some((active_item, active_editor)) = outline_panel.workspace.upgrade().and_then(|workspace| { workspace_active_editor(workspace.read(cx), cx) }) { if outline_panel.should_replace_active_item(active_item.as_ref()) { outline_panel.replace_active_editor( active_item, active_editor, window, cx, ); } else { outline_panel.update_fs_entries(active_editor, None, window, cx) } return; } } if !outline_panel.pinned { outline_panel.clear_previous(window, cx); } } outline_panel.serialize(cx); }) .ok(); }) .detach() } fn activation_priority(&self) -> u32 { 5 } } impl Focusable for OutlinePanel { fn focus_handle(&self, cx: &App) -> FocusHandle { self.filter_editor.focus_handle(cx).clone() } } impl EventEmitter for OutlinePanel {} impl EventEmitter for OutlinePanel {} impl Render for OutlinePanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let (is_local, is_via_ssh) = self .project .read_with(cx, |project, _| (project.is_local(), project.is_via_ssh())); let query = self.query(cx); let pinned = self.pinned; let settings = OutlinePanelSettings::get_global(cx); let indent_size = settings.indent_size; let show_indent_guides = settings.indent_guides.show == ShowIndentGuides::Always; let search_query = match &self.mode { ItemsDisplayMode::Search(search_query) => Some(search_query), _ => None, }; v_flex() .id("outline-panel") .size_full() .overflow_hidden() .relative() .on_hover(cx.listener(|this, hovered, window, cx| { if *hovered { this.show_scrollbar = true; this.hide_scrollbar_task.take(); cx.notify(); } else if !this.focus_handle.contains_focused(window, cx) { this.hide_scrollbar(window, cx); } })) .key_context(self.dispatch_context(window, cx)) .on_action(cx.listener(Self::open_selected_entry)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::select_parent)) .on_action(cx.listener(Self::expand_selected_entry)) .on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::expand_all_entries)) .on_action(cx.listener(Self::collapse_all_entries)) .on_action(cx.listener(Self::copy_path)) .on_action(cx.listener(Self::copy_relative_path)) .on_action(cx.listener(Self::toggle_active_editor_pin)) .on_action(cx.listener(Self::unfold_directory)) .on_action(cx.listener(Self::fold_directory)) .on_action(cx.listener(Self::open_excerpts)) .on_action(cx.listener(Self::open_excerpts_split)) .when(is_local, |el| { el.on_action(cx.listener(Self::reveal_in_finder)) }) .when(is_local || is_via_ssh, |el| { el.on_action(cx.listener(Self::open_in_terminal)) }) .on_mouse_down( MouseButton::Right, cx.listener(move |outline_panel, event: &MouseDownEvent, window, cx| { if let Some(entry) = outline_panel.selected_entry().cloned() { outline_panel.deploy_context_menu(event.position, entry, window, cx) } else if let Some(entry) = outline_panel.fs_entries.first().cloned() { outline_panel.deploy_context_menu( event.position, PanelEntry::Fs(entry), window, cx, ) } }), ) .track_focus(&self.focus_handle) .when_some(search_query, |outline_panel, search_state| { outline_panel.child( h_flex() .py_1p5() .px_2() .h(DynamicSpacing::Base32.px(cx)) .flex_shrink_0() .border_b_1() .border_color(cx.theme().colors().border) .gap_0p5() .child(Label::new("Searching:").color(Color::Muted)) .child(Label::new(search_state.query.to_string())), ) }) .child(self.render_main_contents(query, show_indent_guides, indent_size, window, cx)) .child(self.render_filter_footer(pinned, cx)) } } fn find_active_indent_guide_ix( outline_panel: &OutlinePanel, candidates: &[IndentGuideLayout], ) -> Option { let SelectedEntry::Valid(_, target_ix) = &outline_panel.selected_entry else { return None; }; let target_depth = outline_panel .cached_entries .get(*target_ix) .map(|cached_entry| cached_entry.depth)?; let (target_ix, target_depth) = if let Some(target_depth) = outline_panel .cached_entries .get(target_ix + 1) .filter(|cached_entry| cached_entry.depth > target_depth) .map(|entry| entry.depth) { (target_ix + 1, target_depth.saturating_sub(1)) } else { (*target_ix, target_depth.saturating_sub(1)) }; candidates .iter() .enumerate() .find(|(_, guide)| { guide.offset.y <= target_ix && target_ix < guide.offset.y + guide.length && guide.offset.x == target_depth }) .map(|(ix, _)| ix) } fn subscribe_for_editor_events( editor: &Entity, window: &mut Window, cx: &mut Context, ) -> Subscription { let debounce = Some(UPDATE_DEBOUNCE); cx.subscribe_in( editor, window, move |outline_panel, editor, e: &EditorEvent, window, cx| { if !outline_panel.active { return; } match e { EditorEvent::SelectionsChanged { local: true } => { outline_panel.reveal_entry_for_selection(editor.clone(), window, cx); cx.notify(); } EditorEvent::ExcerptsAdded { excerpts, .. } => { outline_panel .new_entries_for_fs_update .extend(excerpts.iter().map(|&(excerpt_id, _)| excerpt_id)); outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } EditorEvent::ExcerptsRemoved { ids, .. } => { let mut ids = ids.iter().collect::>(); for excerpts in outline_panel.excerpts.values_mut() { excerpts.retain(|excerpt_id, _| !ids.remove(excerpt_id)); if ids.is_empty() { break; } } outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } EditorEvent::ExcerptsExpanded { ids } => { outline_panel.invalidate_outlines(ids); let update_cached_items = outline_panel.update_non_fs_items(window, cx); if update_cached_items { outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); } } EditorEvent::ExcerptsEdited { ids } => { outline_panel.invalidate_outlines(ids); let update_cached_items = outline_panel.update_non_fs_items(window, cx); if update_cached_items { outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); } } EditorEvent::BufferFoldToggled { ids, .. } => { outline_panel.invalidate_outlines(ids); let mut latest_unfolded_buffer_id = None; let mut latest_folded_buffer_id = None; let mut ignore_selections_change = false; outline_panel.new_entries_for_fs_update.extend( ids.iter() .filter(|id| { outline_panel .excerpts .iter() .find_map(|(buffer_id, excerpts)| { if excerpts.contains_key(id) { ignore_selections_change |= outline_panel .preserve_selection_on_buffer_fold_toggles .remove(buffer_id); Some(buffer_id) } else { None } }) .map(|buffer_id| { if editor.read(cx).is_buffer_folded(*buffer_id, cx) { latest_folded_buffer_id = Some(*buffer_id); false } else { latest_unfolded_buffer_id = Some(*buffer_id); true } }) .unwrap_or(true) }) .copied(), ); if !ignore_selections_change { if let Some(entry_to_select) = latest_unfolded_buffer_id .or(latest_folded_buffer_id) .and_then(|toggled_buffer_id| { outline_panel.fs_entries.iter().find_map( |fs_entry| match fs_entry { FsEntry::ExternalFile(external) => { if external.buffer_id == toggled_buffer_id { Some(fs_entry.clone()) } else { None } } FsEntry::File(FsEntryFile { buffer_id, .. }) => { if *buffer_id == toggled_buffer_id { Some(fs_entry.clone()) } else { None } } FsEntry::Directory(..) => None, }, ) }) .map(PanelEntry::Fs) { outline_panel.select_entry(entry_to_select, true, window, cx); } } outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } EditorEvent::Reparsed(buffer_id) => { if let Some(excerpts) = outline_panel.excerpts.get_mut(buffer_id) { for (_, excerpt) in excerpts { excerpt.invalidate_outlines(); } } let update_cached_items = outline_panel.update_non_fs_items(window, cx); if update_cached_items { outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); } } _ => {} } }, ) } fn empty_icon() -> AnyElement { h_flex() .size(IconSize::default().rems()) .invisible() .flex_none() .into_any_element() } fn horizontal_separator(cx: &mut App) -> Div { div().mx_2().border_primary(cx).border_t_1() } #[derive(Debug, Default)] struct GenerationState { entries: Vec, match_candidates: Vec, max_width_estimate_and_index: Option<(u64, usize)>, } impl GenerationState { fn clear(&mut self) { self.entries.clear(); self.match_candidates.clear(); self.max_width_estimate_and_index = None; } } #[cfg(test)] mod tests { use db::indoc; use gpui::{TestAppContext, VisualTestContext, WindowHandle}; use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; use pretty_assertions::assert_eq; use project::FakeFs; use search::project_search::{self, perform_project_search}; use serde_json::json; use util::path; use workspace::{OpenOptions, OpenVisible}; use super::*; const SELECTED_MARKER: &str = " <==== selected"; #[gpui::test(iterations = 10)] async fn test_project_search_results_toggling(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); populate_with_test_ra_project(&fs, "/rust-analyzer").await; let project = Project::test(fs.clone(), ["/rust-analyzer".as_ref()], cx).await; project.read_with(cx, |project, _| { project.languages().add(Arc::new(rust_lang())) }); let workspace = add_outline_panel(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.set_active(true, window, cx) }); workspace .update(cx, |workspace, window, cx| { ProjectSearchView::deploy_search( workspace, &workspace::DeploySearch::default(), window, cx, ) }) .unwrap(); let search_view = workspace .update(cx, |workspace, _, cx| { workspace .active_pane() .read(cx) .items() .find_map(|item| item.downcast::()) .expect("Project search view expected to appear after new search event trigger") }) .unwrap(); let query = "param_names_for_lifetime_elision_hints"; perform_project_search(&search_view, query, cx); search_view.update(cx, |search_view, cx| { search_view .results_editor() .update(cx, |results_editor, cx| { assert_eq!( results_editor.display_text(cx).match_indices(query).count(), 9 ); }); }); let all_matches = r#"/rust-analyzer/ crates/ ide/src/ inlay_hints/ fn_lifetime_fn.rs search: match config.param_names_for_lifetime_elision_hints { search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints { search: Some(it) if config.param_names_for_lifetime_elision_hints => { search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }, inlay_hints.rs search: pub param_names_for_lifetime_elision_hints: bool, search: param_names_for_lifetime_elision_hints: self static_index.rs search: param_names_for_lifetime_elision_hints: false, rust-analyzer/src/ cli/ analysis_stats.rs search: param_names_for_lifetime_elision_hints: true, config.rs search: param_names_for_lifetime_elision_hints: self"#; let select_first_in_all_matches = |line_to_select: &str| { assert!(all_matches.contains(line_to_select)); all_matches.replacen( line_to_select, &format!("{line_to_select}{SELECTED_MARKER}"), 1, ) }; cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), select_first_in_all_matches( "search: match config.param_names_for_lifetime_elision_hints {" ) ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.select_parent(&SelectParent, window, cx); assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), select_first_in_all_matches("fn_lifetime_fn.rs") ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), format!( r#"/rust-analyzer/ crates/ ide/src/ inlay_hints/ fn_lifetime_fn.rs{SELECTED_MARKER} inlay_hints.rs search: pub param_names_for_lifetime_elision_hints: bool, search: param_names_for_lifetime_elision_hints: self static_index.rs search: param_names_for_lifetime_elision_hints: false, rust-analyzer/src/ cli/ analysis_stats.rs search: param_names_for_lifetime_elision_hints: true, config.rs search: param_names_for_lifetime_elision_hints: self"#, ) ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.expand_all_entries(&ExpandAllEntries, window, cx); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.select_parent(&SelectParent, window, cx); assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), select_first_in_all_matches("inlay_hints/") ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.select_parent(&SelectParent, window, cx); assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), select_first_in_all_matches("ide/src/") ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), format!( r#"/rust-analyzer/ crates/ ide/src/{SELECTED_MARKER} rust-analyzer/src/ cli/ analysis_stats.rs search: param_names_for_lifetime_elision_hints: true, config.rs search: param_names_for_lifetime_elision_hints: self"#, ) ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), select_first_in_all_matches("ide/src/") ); }); } #[gpui::test(iterations = 10)] async fn test_item_filtering(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); populate_with_test_ra_project(&fs, "/rust-analyzer").await; let project = Project::test(fs.clone(), ["/rust-analyzer".as_ref()], cx).await; project.read_with(cx, |project, _| { project.languages().add(Arc::new(rust_lang())) }); let workspace = add_outline_panel(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.set_active(true, window, cx) }); workspace .update(cx, |workspace, window, cx| { ProjectSearchView::deploy_search( workspace, &workspace::DeploySearch::default(), window, cx, ) }) .unwrap(); let search_view = workspace .update(cx, |workspace, _, cx| { workspace .active_pane() .read(cx) .items() .find_map(|item| item.downcast::()) .expect("Project search view expected to appear after new search event trigger") }) .unwrap(); let query = "param_names_for_lifetime_elision_hints"; perform_project_search(&search_view, query, cx); search_view.update(cx, |search_view, cx| { search_view .results_editor() .update(cx, |results_editor, cx| { assert_eq!( results_editor.display_text(cx).match_indices(query).count(), 9 ); }); }); let all_matches = r#"/rust-analyzer/ crates/ ide/src/ inlay_hints/ fn_lifetime_fn.rs search: match config.param_names_for_lifetime_elision_hints { search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints { search: Some(it) if config.param_names_for_lifetime_elision_hints => { search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }, inlay_hints.rs search: pub param_names_for_lifetime_elision_hints: bool, search: param_names_for_lifetime_elision_hints: self static_index.rs search: param_names_for_lifetime_elision_hints: false, rust-analyzer/src/ cli/ analysis_stats.rs search: param_names_for_lifetime_elision_hints: true, config.rs search: param_names_for_lifetime_elision_hints: self"#; cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, None, cx, ), all_matches, ); }); let filter_text = "a"; outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.filter_editor.update(cx, |filter_editor, cx| { filter_editor.set_text(filter_text, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, None, cx, ), all_matches .lines() .skip(1) // `/rust-analyzer/` is a root entry with path `` and it will be filtered out .filter(|item| item.contains(filter_text)) .collect::>() .join("\n"), ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.filter_editor.update(cx, |filter_editor, cx| { filter_editor.set_text("", window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, None, cx, ), all_matches, ); }); } #[gpui::test(iterations = 10)] async fn test_item_opening(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); populate_with_test_ra_project(&fs, path!("/rust-analyzer")).await; let project = Project::test(fs.clone(), [path!("/rust-analyzer").as_ref()], cx).await; project.read_with(cx, |project, _| { project.languages().add(Arc::new(rust_lang())) }); let workspace = add_outline_panel(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.set_active(true, window, cx) }); workspace .update(cx, |workspace, window, cx| { ProjectSearchView::deploy_search( workspace, &workspace::DeploySearch::default(), window, cx, ) }) .unwrap(); let search_view = workspace .update(cx, |workspace, _, cx| { workspace .active_pane() .read(cx) .items() .find_map(|item| item.downcast::()) .expect("Project search view expected to appear after new search event trigger") }) .unwrap(); let query = "param_names_for_lifetime_elision_hints"; perform_project_search(&search_view, query, cx); search_view.update(cx, |search_view, cx| { search_view .results_editor() .update(cx, |results_editor, cx| { assert_eq!( results_editor.display_text(cx).match_indices(query).count(), 9 ); }); }); let root_path = format!("{}/", path!("/rust-analyzer")); let all_matches = format!( r#"{root_path} crates/ ide/src/ inlay_hints/ fn_lifetime_fn.rs search: match config.param_names_for_lifetime_elision_hints {{ search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {{ search: Some(it) if config.param_names_for_lifetime_elision_hints => {{ search: InlayHintsConfig {{ param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }}, inlay_hints.rs search: pub param_names_for_lifetime_elision_hints: bool, search: param_names_for_lifetime_elision_hints: self static_index.rs search: param_names_for_lifetime_elision_hints: false, rust-analyzer/src/ cli/ analysis_stats.rs search: param_names_for_lifetime_elision_hints: true, config.rs search: param_names_for_lifetime_elision_hints: self"# ); let select_first_in_all_matches = |line_to_select: &str| { assert!(all_matches.contains(line_to_select)); all_matches.replacen( line_to_select, &format!("{line_to_select}{SELECTED_MARKER}"), 1, ) }; cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); let active_editor = outline_panel.read_with(cx, |outline_panel, _| { outline_panel .active_editor() .expect("should have an active editor open") }); let initial_outline_selection = "search: match config.param_names_for_lifetime_elision_hints {"; outline_panel.update_in(cx, |outline_panel, window, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), select_first_in_all_matches(initial_outline_selection) ); assert_eq!( selected_row_text(&active_editor, cx), initial_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes "Should place the initial editor selection on the corresponding search result" ); outline_panel.select_next(&SelectNext, window, cx); outline_panel.select_next(&SelectNext, window, cx); }); let navigated_outline_selection = "search: Some(it) if config.param_names_for_lifetime_elision_hints => {"; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), select_first_in_all_matches(navigated_outline_selection) ); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); outline_panel.update(cx, |_, cx| { assert_eq!( selected_row_text(&active_editor, cx), navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes "Should still have the initial caret position after SelectNext calls" ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx); }); outline_panel.update(cx, |_outline_panel, cx| { assert_eq!( selected_row_text(&active_editor, cx), navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes "After opening, should move the caret to the opened outline entry's position" ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.select_next(&SelectNext, window, cx); }); let next_navigated_outline_selection = "search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },"; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), select_first_in_all_matches(next_navigated_outline_selection) ); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); outline_panel.update(cx, |_outline_panel, cx| { assert_eq!( selected_row_text(&active_editor, cx), next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes "Should again preserve the selection after another SelectNext call" ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.open_excerpts(&editor::OpenExcerpts, window, cx); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); let new_active_editor = outline_panel.read_with(cx, |outline_panel, _| { outline_panel .active_editor() .expect("should have an active editor open") }); outline_panel.update(cx, |outline_panel, cx| { assert_ne!( active_editor, new_active_editor, "After opening an excerpt, new editor should be open" ); assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), "fn_lifetime_fn.rs <==== selected" ); assert_eq!( selected_row_text(&new_active_editor, cx), next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes "When opening the excerpt, should navigate to the place corresponding the outline entry" ); }); } #[gpui::test] async fn test_multiple_workrees(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( "/root", json!({ "one": { "a.txt": "aaa aaa" }, "two": { "b.txt": "a aaa" } }), ) .await; let project = Project::test(fs.clone(), [Path::new("/root/one")], cx).await; let workspace = add_outline_panel(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.set_active(true, window, cx) }); let items = workspace .update(cx, |workspace, window, cx| { workspace.open_paths( vec![PathBuf::from("/root/two")], OpenOptions { visible: Some(OpenVisible::OnlyDirectories), ..Default::default() }, None, window, cx, ) }) .unwrap() .await; assert_eq!(items.len(), 1, "Were opening another worktree directory"); assert!( items[0].is_none(), "Directory should be opened successfully" ); workspace .update(cx, |workspace, window, cx| { ProjectSearchView::deploy_search( workspace, &workspace::DeploySearch::default(), window, cx, ) }) .unwrap(); let search_view = workspace .update(cx, |workspace, _, cx| { workspace .active_pane() .read(cx) .items() .find_map(|item| item.downcast::()) .expect("Project search view expected to appear after new search event trigger") }) .unwrap(); let query = "aaa"; perform_project_search(&search_view, query, cx); search_view.update(cx, |search_view, cx| { search_view .results_editor() .update(cx, |results_editor, cx| { assert_eq!( results_editor.display_text(cx).match_indices(query).count(), 3 ); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), r#"/root/one/ a.txt search: aaa aaa <==== selected search: aaa aaa /root/two/ b.txt search: a aaa"# ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.select_previous(&SelectPrevious, window, cx); outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), r#"/root/one/ a.txt <==== selected /root/two/ b.txt search: a aaa"# ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.select_next(&SelectNext, window, cx); outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), r#"/root/one/ a.txt /root/two/ <==== selected"# ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), r#"/root/one/ a.txt /root/two/ <==== selected b.txt search: a aaa"# ); }); } #[gpui::test] async fn test_navigating_in_singleton(cx: &mut TestAppContext) { init_test(cx); let root = path!("/root"); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( root, json!({ "src": { "lib.rs": indoc!(" #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct OutlineEntryExcerpt { id: ExcerptId, buffer_id: BufferId, range: ExcerptRange, }"), } }), ) .await; let project = Project::test(fs.clone(), [root.as_ref()], cx).await; project.read_with(cx, |project, _| { project.languages().add(Arc::new( rust_lang() .with_outline_query( r#" (struct_item (visibility_modifier)? @context "struct" @context name: (_) @name) @item (field_declaration (visibility_modifier)? @context name: (_) @name) @item "#, ) .unwrap(), )) }); let workspace = add_outline_panel(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); let outline_panel = outline_panel(&workspace, cx); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.set_active(true, window, cx) }); }); let _editor = workspace .update(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/src/lib.rs")), OpenOptions { visible: Some(OpenVisible::All), ..Default::default() }, window, cx, ) }) .unwrap() .await .expect("Failed to open Rust source file") .downcast::() .expect("Should open an editor for Rust source file"); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt outline: id outline: buffer_id outline: range" ) ); }); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_next(&SelectNext, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt <==== selected outline: id outline: buffer_id outline: range" ) ); }); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_next(&SelectNext, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt outline: id <==== selected outline: buffer_id outline: range" ) ); }); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_next(&SelectNext, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt outline: id outline: buffer_id <==== selected outline: range" ) ); }); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_next(&SelectNext, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt outline: id outline: buffer_id outline: range <==== selected" ) ); }); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_next(&SelectNext, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt <==== selected outline: id outline: buffer_id outline: range" ) ); }); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_previous(&SelectPrevious, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt outline: id outline: buffer_id outline: range <==== selected" ) ); }); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_previous(&SelectPrevious, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt outline: id outline: buffer_id <==== selected outline: range" ) ); }); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_previous(&SelectPrevious, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt outline: id <==== selected outline: buffer_id outline: range" ) ); }); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_previous(&SelectPrevious, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt <==== selected outline: id outline: buffer_id outline: range" ) ); }); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_previous(&SelectPrevious, window, cx); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), indoc!( " outline: struct OutlineEntryExcerpt outline: id outline: buffer_id outline: range <==== selected" ) ); }); } #[gpui::test(iterations = 10)] async fn test_frontend_repo_structure(cx: &mut TestAppContext) { init_test(cx); let root = "/frontend-project"; let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( root, json!({ "public": { "lottie": { "syntax-tree.json": r#"{ "something": "static" }"# } }, "src": { "app": { "(site)": { "(about)": { "jobs": { "[slug]": { "page.tsx": r#"static"# } } }, "(blog)": { "post": { "[slug]": { "page.tsx": r#"static"# } } }, } }, "components": { "ErrorBoundary.tsx": r#"static"#, } } }), ) .await; let project = Project::test(fs.clone(), [root.as_ref()], cx).await; let workspace = add_outline_panel(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.set_active(true, window, cx) }); workspace .update(cx, |workspace, window, cx| { ProjectSearchView::deploy_search( workspace, &workspace::DeploySearch::default(), window, cx, ) }) .unwrap(); let search_view = workspace .update(cx, |workspace, _, cx| { workspace .active_pane() .read(cx) .items() .find_map(|item| item.downcast::()) .expect("Project search view expected to appear after new search event trigger") }) .unwrap(); let query = "static"; perform_project_search(&search_view, query, cx); search_view.update(cx, |search_view, cx| { search_view .results_editor() .update(cx, |results_editor, cx| { assert_eq!( results_editor.display_text(cx).match_indices(query).count(), 4 ); }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), r#"/frontend-project/ public/lottie/ syntax-tree.json search: { "something": "static" } <==== selected src/ app/(site)/ (about)/jobs/[slug]/ page.tsx search: static (blog)/post/[slug]/ page.tsx search: static components/ ErrorBoundary.tsx search: static"# ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { // Move to 5th element in the list, 3 items down. for _ in 0..2 { outline_panel.select_next(&SelectNext, window, cx); } outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), r#"/frontend-project/ public/lottie/ syntax-tree.json search: { "something": "static" } src/ app/(site)/ <==== selected components/ ErrorBoundary.tsx search: static"# ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { // Move to the next visible non-FS entry for _ in 0..3 { outline_panel.select_next(&SelectNext, window, cx); } }); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), r#"/frontend-project/ public/lottie/ syntax-tree.json search: { "something": "static" } src/ app/(site)/ components/ ErrorBoundary.tsx search: static <==== selected"# ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel .active_editor() .expect("Should have an active editor") .update(cx, |editor, cx| { editor.toggle_fold(&editor::actions::ToggleFold, window, cx) }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), r#"/frontend-project/ public/lottie/ syntax-tree.json search: { "something": "static" } src/ app/(site)/ components/ ErrorBoundary.tsx <==== selected"# ); }); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel .active_editor() .expect("Should have an active editor") .update(cx, |editor, cx| { editor.toggle_fold(&editor::actions::ToggleFold, window, cx) }); }); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( display_entries( &project, &snapshot(&outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), r#"/frontend-project/ public/lottie/ syntax-tree.json search: { "something": "static" } src/ app/(site)/ components/ ErrorBoundary.tsx <==== selected search: static"# ); }); } async fn add_outline_panel( project: &Entity, cx: &mut TestAppContext, ) -> WindowHandle { let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let outline_panel = window .update(cx, |_, window, cx| { cx.spawn_in(window, async |this, cx| { OutlinePanel::load(this, cx.clone()).await }) }) .unwrap() .await .expect("Failed to load outline panel"); window .update(cx, |workspace, window, cx| { workspace.add_panel(outline_panel, window, cx); }) .unwrap(); window } fn outline_panel( workspace: &WindowHandle, cx: &mut TestAppContext, ) -> Entity { workspace .update(cx, |workspace, _, cx| { workspace .panel::(cx) .expect("no outline panel") }) .unwrap() } fn display_entries( project: &Entity, multi_buffer_snapshot: &MultiBufferSnapshot, cached_entries: &[CachedEntry], selected_entry: Option<&PanelEntry>, cx: &mut App, ) -> String { let mut display_string = String::new(); for entry in cached_entries { if !display_string.is_empty() { display_string += "\n"; } for _ in 0..entry.depth { display_string += " "; } display_string += &match &entry.entry { PanelEntry::Fs(entry) => match entry { FsEntry::ExternalFile(_) => { panic!("Did not cover external files with tests") } FsEntry::Directory(directory) => { match project .read(cx) .worktree_for_id(directory.worktree_id, cx) .and_then(|worktree| { if worktree.read(cx).root_entry() == Some(&directory.entry.entry) { Some(worktree.read(cx).abs_path()) } else { None } }) { Some(root_path) => format!( "{}/{}", root_path.display(), directory.entry.path.display(), ), None => format!( "{}/", directory .entry .path .file_name() .unwrap_or_default() .to_string_lossy() ), } } FsEntry::File(file) => file .entry .path .file_name() .map(|name| name.to_string_lossy().to_string()) .unwrap_or_default(), }, PanelEntry::FoldedDirs(folded_dirs) => folded_dirs .entries .iter() .filter_map(|dir| dir.path.file_name()) .map(|name| name.to_string_lossy().to_string() + "/") .collect(), PanelEntry::Outline(outline_entry) => match outline_entry { OutlineEntry::Excerpt(_) => continue, OutlineEntry::Outline(outline_entry) => { format!("outline: {}", outline_entry.outline.text) } }, PanelEntry::Search(search_entry) => { format!( "search: {}", search_entry .render_data .get_or_init(|| SearchData::new( &search_entry.match_range, &multi_buffer_snapshot )) .context_text ) } }; if Some(&entry.entry) == selected_entry { display_string += SELECTED_MARKER; } } display_string } fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings = SettingsStore::test(cx); cx.set_global(settings); theme::init(theme::LoadThemes::JustBase, cx); language::init(cx); editor::init(cx); workspace::init_settings(cx); Project::init_settings(cx); project_search::init(cx); super::init(cx); }); } // Based on https://github.com/rust-lang/rust-analyzer/ async fn populate_with_test_ra_project(fs: &FakeFs, root: &str) { fs.insert_tree( root, json!({ "crates": { "ide": { "src": { "inlay_hints": { "fn_lifetime_fn.rs": r##" pub(super) fn hints( acc: &mut Vec, config: &InlayHintsConfig, func: ast::Fn, ) -> Option<()> { // ... snip let mut used_names: FxHashMap = match config.param_names_for_lifetime_elision_hints { true => generic_param_list .iter() .flat_map(|gpl| gpl.lifetime_params()) .filter_map(|param| param.lifetime()) .filter_map(|lt| Some((SmolStr::from(lt.text().as_str().get(1..)?), 0))) .collect(), false => Default::default(), }; { let mut potential_lt_refs = potential_lt_refs.iter().filter(|&&(.., is_elided)| is_elided); if self_param.is_some() && potential_lt_refs.next().is_some() { allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints { // self can't be used as a lifetime, so no need to check for collisions "'self".into() } else { gen_idx_name() }); } potential_lt_refs.for_each(|(name, ..)| { let name = match name { Some(it) if config.param_names_for_lifetime_elision_hints => { if let Some(c) = used_names.get_mut(it.text().as_str()) { *c += 1; SmolStr::from(format!("'{text}{c}", text = it.text().as_str())) } else { used_names.insert(it.text().as_str().into(), 0); SmolStr::from_iter(["\'", it.text().as_str()]) } } _ => gen_idx_name(), }; allocated_lifetimes.push(name); }); } // ... snip } // ... snip #[test] fn hints_lifetimes_named() { check_with_config( InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }, r#" fn nested_in<'named>(named: & &X< &()>) {} // ^'named1, 'named2, 'named3, $ //^'named1 ^'named2 ^'named3 "#, ); } // ... snip "##, }, "inlay_hints.rs": r#" #[derive(Clone, Debug, PartialEq, Eq)] pub struct InlayHintsConfig { // ... snip pub param_names_for_lifetime_elision_hints: bool, pub max_length: Option, // ... snip } impl Config { pub fn inlay_hints(&self) -> InlayHintsConfig { InlayHintsConfig { // ... snip param_names_for_lifetime_elision_hints: self .inlayHints_lifetimeElisionHints_useParameterNames() .to_owned(), max_length: self.inlayHints_maxLength().to_owned(), // ... snip } } } "#, "static_index.rs": r#" // ... snip fn add_file(&mut self, file_id: FileId) { let current_crate = crates_for(self.db, file_id).pop().map(Into::into); let folds = self.analysis.folding_ranges(file_id).unwrap(); let inlay_hints = self .analysis .inlay_hints( &InlayHintsConfig { // ... snip closure_style: hir::ClosureStyle::ImplFn, param_names_for_lifetime_elision_hints: false, binding_mode_hints: false, max_length: Some(25), closure_capture_hints: false, // ... snip }, file_id, None, ) .unwrap(); // ... snip } // ... snip "# } }, "rust-analyzer": { "src": { "cli": { "analysis_stats.rs": r#" // ... snip for &file_id in &file_ids { _ = analysis.inlay_hints( &InlayHintsConfig { // ... snip implicit_drop_hints: true, lifetime_elision_hints: ide::LifetimeElisionHints::Always, param_names_for_lifetime_elision_hints: true, hide_named_constructor_hints: false, hide_closure_initialization_hints: false, closure_style: hir::ClosureStyle::ImplFn, max_length: Some(25), closing_brace_hints_min_lines: Some(20), fields_to_resolve: InlayFieldsToResolve::empty(), range_exclusive_hints: true, }, file_id.into(), None, ); } // ... snip "#, }, "config.rs": r#" config_data! { /// Configs that only make sense when they are set by a client. As such they can only be defined /// by setting them using client's settings (e.g `settings.json` on VS Code). client: struct ClientDefaultConfigData <- ClientConfigInput -> { // ... snip /// Maximum length for inlay hints. Set to null to have an unlimited length. inlayHints_maxLength: Option = Some(25), // ... snip /// Whether to prefer using parameter names as the name for elided lifetime hints if possible. inlayHints_lifetimeElisionHints_useParameterNames: bool = false, // ... snip } } impl Config { // ... snip pub fn inlay_hints(&self) -> InlayHintsConfig { InlayHintsConfig { // ... snip param_names_for_lifetime_elision_hints: self .inlayHints_lifetimeElisionHints_useParameterNames() .to_owned(), max_length: self.inlayHints_maxLength().to_owned(), // ... snip } } // ... snip } "# } } } }), ) .await; } fn rust_lang() -> Language { Language::new( LanguageConfig { name: "Rust".into(), matcher: LanguageMatcher { path_suffixes: vec!["rs".to_string()], ..Default::default() }, ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), ) .with_highlights_query( r#" (field_identifier) @field (struct_expression) @struct "#, ) .unwrap() .with_injection_query( r#" (macro_invocation (token_tree) @injection.content (#set! injection.language "rust")) "#, ) .unwrap() } fn snapshot(outline_panel: &OutlinePanel, cx: &App) -> MultiBufferSnapshot { outline_panel .active_editor() .unwrap() .read(cx) .buffer() .read(cx) .snapshot(cx) } fn selected_row_text(editor: &Entity, cx: &mut App) -> String { editor.update(cx, |editor, cx| { let selections = editor.selections.all::(cx); assert_eq!(selections.len(), 1, "Active editor should have exactly one selection after any outline panel interactions"); let selection = selections.first().unwrap(); let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); let line_start = language::Point::new(selection.start.row, 0); let line_end = multi_buffer_snapshot.clip_point(language::Point::new(selection.end.row, u32::MAX), language::Bias::Right); multi_buffer_snapshot.text_for_range(line_start..line_end).collect::().trim().to_owned() }) } }