diff --git a/assets/icons/file_code_off.svg b/assets/icons/file_code_off.svg new file mode 100644 index 0000000000..c97b461853 --- /dev/null +++ b/assets/icons/file_code_off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/settings/default.json b/assets/settings/default.json index 2fc3b3d0a2..78749a6be7 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1008,6 +1008,11 @@ // Default depth to expand outline items in the current file. // Set to 0 to collapse all items that have children, 1 or higher to collapse items at that depth or deeper. "expand_outlines_with_depth": 100, + // Whether to hide symbols, excerpts and search matches in the outline panel + // when a multi-buffer view (e.g. a diff or search results) is active, + // showing only files and directories. + // Does not affect single-file views. + "multi_buffer_hide_symbols": false, }, "collaboration_panel": { // Whether to show the collaboration panel button in the status bar. diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 64531a165a..22ca64a582 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -127,6 +127,7 @@ pub enum IconName { FastForwardOff, File, FileCode, + FileCodeOff, FileDiff, FileDoc, FileGeneric, diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index b251aaab1d..c2a42c123a 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -95,6 +95,8 @@ actions!( SelectParent, /// Toggles the pin status of the active editor. ToggleActiveEditorPin, + /// Toggles showing symbols, excerpts and search matches for multi-buffer views. + ToggleSymbols, /// Unfolds the selected directory. UnfoldDirectory, /// Toggles the outline panel. @@ -143,7 +145,9 @@ pub struct OutlinePanel { max_width_item_index: Option, preserve_selection_on_buffer_fold_toggles: HashSet, pending_default_expansion_depth: Option, + default_depth_applied_buffers: HashSet, outline_children_cache: HashMap, usize), bool>>, + hide_symbols_override: Option, } #[derive(Debug)] @@ -660,6 +664,16 @@ pub fn init(cx: &mut App) { workspace.close_panel::(window, cx); } }); + workspace.register_action(|workspace, _: &ToggleSymbols, window, cx| { + if let Some(outline_panel) = workspace.panel::(cx) { + // Defer to avoid updating the panel while the workspace is still being updated. + window.defer(cx, move |window, cx| { + outline_panel.update(cx, |outline_panel, cx| { + outline_panel.toggle_symbols(&ToggleSymbols, window, cx); + }); + }); + } + }); }) .detach(); } @@ -777,15 +791,33 @@ impl OutlinePanel { } } else if &outline_panel_settings != new_settings { let old_expansion_depth = outline_panel_settings.expand_outlines_with_depth; + let old_hide_symbols = outline_panel_settings.multi_buffer_hide_symbols; outline_panel_settings = *new_settings; - if old_expansion_depth != new_settings.expand_outlines_with_depth { + let mut update_cached_entries = false; + if old_hide_symbols != outline_panel_settings.multi_buffer_hide_symbols { + outline_panel.hide_symbols_override = None; + outline_panel.update_non_fs_items(window, cx); + outline_panel.remap_selection_for_hidden_symbols(window, cx); + update_cached_entries = true; + } + if old_expansion_depth != outline_panel_settings.expand_outlines_with_depth + { let old_collapsed_entries = outline_panel.collapsed_entries.clone(); outline_panel .collapsed_entries .retain(|entry| !matches!(entry, CollapsedEntry::Outline(..))); - let new_depth = new_settings.expand_outlines_with_depth; + let new_depth = outline_panel_settings.expand_outlines_with_depth; + outline_panel.pending_default_expansion_depth = Some(new_depth); + outline_panel.default_depth_applied_buffers = outline_panel + .buffers + .iter() + .filter(|(_, buffer)| { + matches!(buffer.outlines, OutlineState::Outlines(_)) + }) + .map(|(buffer_id, _)| *buffer_id) + .collect(); for (buffer_id, buffer) in &outline_panel.buffers { if let OutlineState::Outlines(outlines) = &buffer.outlines { @@ -810,12 +842,11 @@ impl OutlinePanel { } if old_collapsed_entries != outline_panel.collapsed_entries { - outline_panel.update_cached_entries( - Some(UPDATE_DEBOUNCE), - window, - cx, - ); + update_cached_entries = true; } + } + if update_cached_entries { + outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); } else { cx.notify(); } @@ -879,6 +910,7 @@ impl OutlinePanel { new_entries_for_fs_update: HashSet::default(), preserve_selection_on_buffer_fold_toggles: HashSet::default(), pending_default_expansion_depth: None, + default_depth_applied_buffers: HashSet::default(), fs_entries_update_task: Task::ready(()), fs_entries_update_pending: false, cached_entries_update_task: Task::ready(()), @@ -895,6 +927,7 @@ impl OutlinePanel { filter_update_subscription, ], outline_children_cache: HashMap::default(), + hide_symbols_override: None, }; if let Some((item, editor)) = workspace_active_editor(workspace, cx) { outline_panel.replace_active_editor(item, editor, window, cx); @@ -3139,7 +3172,7 @@ impl OutlinePanel { // Only update cached entries if we don't have outlines to fetch // If we do have outlines to fetch, let fetch_outdated_outlines handle the update - if outline_panel.buffers_to_fetch().is_empty() { + if outline_panel.buffers_to_fetch(cx).is_empty() { outline_panel.update_cached_entries(debounce, window, cx); } @@ -3219,6 +3252,7 @@ impl OutlinePanel { self.pinned = false; self.mode = ItemsDisplayMode::Outline; self.pending_default_expansion_depth = None; + self.default_depth_applied_buffers.clear(); } fn location_for_editor_selection( @@ -3449,7 +3483,7 @@ impl OutlinePanel { } fn fetch_outdated_outlines(&mut self, window: &mut Window, cx: &mut Context) { - let buffers_to_fetch = self.buffers_to_fetch(); + let buffers_to_fetch = self.buffers_to_fetch(cx); if buffers_to_fetch.is_empty() { return; } @@ -3483,7 +3517,11 @@ impl OutlinePanel { outline_panel .update_in(cx, |outline_panel, window, cx| { let pending_default_depth = - outline_panel.pending_default_expansion_depth.take(); + outline_panel.pending_default_expansion_depth.filter(|_| { + outline_panel + .default_depth_applied_buffers + .insert(buffer_id) + }); let debounce = if first_update.fetch_and(false, atomic::Ordering::AcqRel) { @@ -3528,6 +3566,61 @@ impl OutlinePanel { .is_some_and(|active_editor| active_editor.read(cx).buffer().read(cx).is_singleton()) } + fn multi_buffer_active(&self, cx: &App) -> bool { + self.active_editor() + .is_some_and(|active_editor| !active_editor.read(cx).buffer().read(cx).is_singleton()) + } + + fn hide_symbols_active(&self, cx: &App) -> bool { + self.hide_symbols_override + .unwrap_or(OutlinePanelSettings::get_global(cx).multi_buffer_hide_symbols) + } + + fn remap_selection_for_hidden_symbols(&mut self, window: &mut Window, cx: &mut Context) { + if !self.multi_buffer_active(cx) || !self.hide_symbols_active(cx) { + return; + } + let buffer_id = match self.selected_entry() { + Some(PanelEntry::Outline(OutlineEntry::Excerpt(excerpt))) => { + Some(excerpt.context.start.buffer_id) + } + Some(PanelEntry::Outline(OutlineEntry::Outline(outline))) => { + Some(outline.range.start.buffer_id) + } + Some(PanelEntry::Search(search_entry)) => { + let match_start = search_entry.match_range.start; + self.active_editor().and_then(|editor| { + let multi_buffer_snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + multi_buffer_snapshot + .anchor_to_buffer_anchor(match_start) + .map(|(anchor, _)| anchor.buffer_id) + }) + } + Some(PanelEntry::Fs(_) | PanelEntry::FoldedDirs(_)) | None => None, + }; + let Some(buffer_id) = buffer_id else { + return; + }; + let file_entry = self + .fs_entries + .iter() + .find(|fs_entry| match fs_entry { + FsEntry::File(FsEntryFile { + buffer_id: file_buffer_id, + .. + }) + | FsEntry::ExternalFile(FsEntryExternalFile { + buffer_id: file_buffer_id, + .. + }) => *file_buffer_id == buffer_id, + FsEntry::Directory(..) => false, + }) + .cloned(); + if let Some(file_entry) = file_entry { + self.select_entry(PanelEntry::Fs(file_entry), false, window, cx); + } + } + fn invalidate_outlines(&mut self, ids: &[BufferId]) { self.outline_fetch_tasks.clear(); let mut ids = ids.iter().collect::>(); @@ -3541,7 +3634,13 @@ impl OutlinePanel { } } - fn buffers_to_fetch(&self) -> HashSet { + fn buffers_to_fetch(&self, cx: &App) -> HashSet { + // Hide-symbols mode never renders outline rows, so fetching outlines would be wasted + // work; `update_fs_entries`'s completion only refreshes cached entries once this is + // empty, so the gate has to live here rather than in `fetch_outdated_outlines` alone. + if self.multi_buffer_active(cx) && self.hide_symbols_active(cx) { + return HashSet::default(); + } self.fs_entries .iter() .fold(HashSet::default(), |mut buffers_to_fetch, fs_entry| { @@ -3692,6 +3791,7 @@ impl OutlinePanel { let Ok(()) = outline_panel.update(cx, |outline_panel, cx| { let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; + let hide_symbols = !is_singleton && outline_panel.hide_symbols_active(cx); let mut folded_dirs_entry = None::<(usize, FoldedDirsEntry)>; let track_matches = query.is_some(); @@ -3703,58 +3803,59 @@ impl OutlinePanel { depth: usize, } - let search_precomputed = - if let ItemsDisplayMode::Search(search_state) = &outline_panel.mode { - let multi_buffer_snapshot = - active_editor.read(cx).buffer().read(cx).snapshot(cx); - let mut folded_buffers = HashSet::default(); - let mut not_folded_buffers = HashSet::default(); - let mut matches_by_buffer = HashMap::default(); + let search_precomputed = if hide_symbols { + None + } else if let ItemsDisplayMode::Search(search_state) = &outline_panel.mode { + let multi_buffer_snapshot = + active_editor.read(cx).buffer().read(cx).snapshot(cx); + let mut folded_buffers = HashSet::default(); + let mut not_folded_buffers = HashSet::default(); + let mut matches_by_buffer = HashMap::default(); - for (match_range, search_data) in &search_state.matches { - let Some((start_anchor, _)) = - multi_buffer_snapshot.anchor_to_buffer_anchor(match_range.start) - else { - continue; - }; - let start_buffer_id = start_anchor.buffer_id; - let end_buffer_id = multi_buffer_snapshot - .anchor_to_buffer_anchor(match_range.end) - .map(|(anchor, _)| anchor.buffer_id); + for (match_range, search_data) in &search_state.matches { + let Some((start_anchor, _)) = + multi_buffer_snapshot.anchor_to_buffer_anchor(match_range.start) + else { + continue; + }; + let start_buffer_id = start_anchor.buffer_id; + let end_buffer_id = multi_buffer_snapshot + .anchor_to_buffer_anchor(match_range.end) + .map(|(anchor, _)| anchor.buffer_id); - let mut any_folded = false; - for buffer_id in - [Some(start_buffer_id), end_buffer_id].into_iter().flatten() - { - if folded_buffers.contains(&buffer_id) { + let mut any_folded = false; + for buffer_id in + [Some(start_buffer_id), end_buffer_id].into_iter().flatten() + { + if folded_buffers.contains(&buffer_id) { + any_folded = true; + } else if !not_folded_buffers.contains(&buffer_id) { + if active_editor.read(cx).is_buffer_folded(buffer_id, cx) { + folded_buffers.insert(buffer_id); any_folded = true; - } else if !not_folded_buffers.contains(&buffer_id) { - if active_editor.read(cx).is_buffer_folded(buffer_id, cx) { - folded_buffers.insert(buffer_id); - any_folded = true; - } else { - not_folded_buffers.insert(buffer_id); - } + } else { + not_folded_buffers.insert(buffer_id); } } - if any_folded { - continue; - } - - matches_by_buffer - .entry(start_buffer_id) - .or_insert_with(Vec::new) - .push((match_range.clone(), Arc::clone(search_data))); + } + if any_folded { + continue; } - Some(SearchPrecomputed { - multi_buffer_snapshot, - matches_by_buffer, - folded_buffers, - }) - } else { - None - }; + matches_by_buffer + .entry(start_buffer_id) + .or_insert_with(Vec::new) + .push((match_range.clone(), Arc::clone(search_data))); + } + + Some(SearchPrecomputed { + multi_buffer_snapshot, + matches_by_buffer, + folded_buffers, + }) + } else { + None + }; let mut parent_dirs = Vec::::new(); for entry in outline_panel.fs_entries.clone() { @@ -3983,25 +4084,28 @@ impl OutlinePanel { ); } - match outline_panel.mode { - ItemsDisplayMode::Search(_) => { - if (is_singleton || query.is_some() || (should_add && is_expanded)) - && let Some(search) = &search_precomputed - { - outline_panel.add_search_entries( - &mut generation_state, - search, - &entry, - depth, - query.is_some(), - is_singleton, - cx, - ); + if !hide_symbols { + match outline_panel.mode { + ItemsDisplayMode::Search(_) => { + if (is_singleton || query.is_some() || (should_add && is_expanded)) + && let Some(search) = &search_precomputed + { + outline_panel.add_search_entries( + &mut generation_state, + search, + &entry, + depth, + query.is_some(), + is_singleton, + cx, + ); + } } - } - ItemsDisplayMode::Outline => { - let excerpts_to_consider = - if is_singleton || query.is_some() || (should_add && is_expanded) { + ItemsDisplayMode::Outline => { + let excerpts_to_consider = if is_singleton + || query.is_some() + || (should_add && is_expanded) + { match &entry { FsEntry::File(FsEntryFile { buffer_id, @@ -4018,18 +4122,19 @@ impl OutlinePanel { } else { None }; - if let Some((buffer_id, _entry_excerpts)) = excerpts_to_consider - && !active_editor.read(cx).is_buffer_folded(buffer_id, cx) - { - outline_panel.add_buffer_entries( - &mut generation_state, - buffer_id, - depth, - track_matches, - is_singleton, - query.as_deref(), - cx, - ); + if let Some((buffer_id, _entry_excerpts)) = excerpts_to_consider + && !active_editor.read(cx).is_buffer_folded(buffer_id, cx) + { + outline_panel.add_buffer_entries( + &mut generation_state, + buffer_id, + depth, + track_matches, + is_singleton, + query.as_deref(), + cx, + ); + } } } } @@ -4387,7 +4492,24 @@ impl OutlinePanel { let mut last_depth_at_level: Vec>> = vec![None; 10]; - let all_outlines: Vec<_> = buffer.iter_outlines().collect(); + let all_outlines = buffer + .iter_outlines() + .filter(|outline| match &buffer_snapshot { + Some(buffer_snapshot) => { + outline + .range + .start + .cmp(&excerpt.context.end, buffer_snapshot) + .is_le() + && outline + .range + .end + .cmp(&excerpt.context.start, buffer_snapshot) + .is_ge() + } + None => true, + }) + .collect::>(); let mut outline_has_children = HashMap::default(); let mut visible_outlines = Vec::new(); @@ -4558,6 +4680,22 @@ impl OutlinePanel { cx.notify(); } + pub fn toggle_symbols( + &mut self, + _: &ToggleSymbols, + window: &mut Window, + cx: &mut Context, + ) { + if !self.multi_buffer_active(cx) { + return; + } + self.hide_symbols_override = Some(!self.hide_symbols_active(cx)); + self.update_non_fs_items(window, cx); + self.remap_selection_for_hidden_symbols(window, cx); + self.update_cached_entries(None, window, cx); + cx.notify(); + } + fn selected_entry(&self) -> Option<&PanelEntry> { match &self.selected_entry { SelectedEntry::Invalidated(entry) => entry.as_ref(), @@ -4835,6 +4973,13 @@ impl OutlinePanel { }; let has_query = self.query(cx).is_some(); + let show_symbols_toggle = self.multi_buffer_active(cx); + let hide_symbols = self.hide_symbols_active(cx); + let (hide_symbols_icon, hide_symbols_tooltip) = if hide_symbols { + (IconName::FileCodeOff, "Show Symbols") + } else { + (IconName::FileCode, "Hide Symbols") + }; h_flex() .p_2() @@ -4868,9 +5013,27 @@ impl OutlinePanel { })), ) }) + .when(show_symbols_toggle, |this| { + this.child( + IconButton::new("toggle_symbols", hide_symbols_icon) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::for_action_title_in( + hide_symbols_tooltip, + &ToggleSymbols, + &self.focus_handle, + )) + .on_click(cx.listener(|outline_panel, _, window, cx| { + outline_panel.toggle_symbols(&ToggleSymbols, window, cx); + })), + ) + }) .child( IconButton::new(pin_button_id, icon) - .tooltip(Tooltip::text(icon_tooltip)) + .tooltip(Tooltip::for_action_title_in( + icon_tooltip, + &ToggleActiveEditorPin, + &self.focus_handle, + )) .shape(IconButtonShape::Square) .on_click(cx.listener(|outline_panel, _, window, cx| { outline_panel.toggle_active_editor_pin( @@ -5122,6 +5285,7 @@ impl Render for OutlinePanel { .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::toggle_symbols)) .on_action(cx.listener(Self::unfold_directory)) .on_action(cx.listener(Self::fold_directory)) .on_action(cx.listener(Self::open_excerpts)) @@ -6841,6 +7005,75 @@ outline: struct OutlineEntryExcerpt }) } + async fn open_buffer( + project: &Entity, + abs_path: &str, + cx: &mut VisualTestContext, + ) -> Entity { + project + .update(cx, |project, cx| { + let path = project.find_project_path(abs_path, cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap() + } + + fn add_multi_buffer_editor( + workspace: &Entity, + project: &Entity, + excerpts: &[(&Entity, Vec>)], + cx: &mut VisualTestContext, + ) -> Entity { + workspace.update_in(cx, |workspace, window, cx| { + let multibuffer = cx.new(|cx| { + let mut multibuffer = editor::MultiBuffer::new(language::Capability::ReadWrite); + for (buffer, ranges) in excerpts { + let ranges = if ranges.is_empty() { + vec![language::Point::default()..buffer.read(cx).max_point()] + } else { + ranges.clone() + }; + multibuffer.set_excerpts_for_buffer((*buffer).clone(), ranges, 0, cx); + } + multibuffer + }); + let editor = cx + .new(|cx| Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx)); + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + editor.update(cx, |editor, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + editor + }) + } + + fn settle_outline_panel(outline_panel: &Entity, cx: &mut VisualTestContext) { + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + outline_panel.update_in(cx, |panel, window, cx| { + panel.update_non_fs_items(window, cx); + panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + } + + fn update_outline_panel_settings( + cx: &mut VisualTestContext, + update: impl FnOnce(&mut settings::OutlinePanelSettingsContent), + ) { + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + update(settings.outline_panel.get_or_insert_default()); + }); + }); + }); + } + fn display_entries( project: &Entity, multi_buffer_snapshot: &MultiBufferSnapshot, @@ -6851,6 +7084,9 @@ outline: struct OutlineEntryExcerpt let project = project.read(cx); let mut display_string = String::new(); for entry in cached_entries { + if matches!(entry.entry, PanelEntry::Outline(OutlineEntry::Excerpt(_))) { + continue; + } if !display_string.is_empty() { display_string += "\n"; } @@ -8233,4 +8469,1044 @@ outline: struct Foo <==== selected "Cursor at row 6 should select '# Section B'" ); } + + #[gpui::test] + async fn test_hide_symbols_in_multibuffer(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/test"), + json!({ + "src": { + "one.rs": indoc!(" + pub fn one() { + let x = 1; + } + "), + "two.rs": indoc!(" + pub struct Two { + field: i32, + } + "), + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), 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 buffer_one = open_buffer(&project, path!("/test/src/one.rs"), cx).await; + let buffer_two = open_buffer(&project, path!("/test/src/two.rs"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], + cx, + ); + settle_outline_panel(&outline_panel, cx); + + outline_panel.update(cx, |panel, _cx| { + assert!( + panel.cached_entries.iter().any(|entry| matches!( + entry.entry, + PanelEntry::Outline(OutlineEntry::Outline(_)) + )), + "expected outline rows in the multibuffer before enabling hide-symbols mode" + ); + }); + + let collapsed_entries_snapshot = + outline_panel.read_with(cx, |panel, _cx| panel.collapsed_entries.clone()); + + let struct_two_outline = outline_panel.read_with(cx, |panel, _cx| { + panel + .cached_entries + .iter() + .find_map(|entry| match &entry.entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) + if outline.text == "pub struct Two" => + { + Some(entry.entry.clone()) + } + _ => None, + }) + .expect("expected an outline row for `pub struct Two`") + }); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_entry(struct_two_outline, true, window, cx); + }); + + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(true); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + + outline_panel.update(cx, |panel, _cx| { + assert!( + panel.cached_entries.iter().all(|entry| matches!( + entry.entry, + PanelEntry::Fs(_) | PanelEntry::FoldedDirs(_) + )), + "hide-symbols mode should only show fs entries, got {:#?}", + panel.cached_entries + ); + assert_eq!( + panel.collapsed_entries, collapsed_entries_snapshot, + "hide-symbols mode should not mutate collapsed_entries" + ); + }); + + outline_panel.update(cx, |panel, cx| { + let buffer_two_id = buffer_two.read(cx).remote_id(); + match panel.selected_entry() { + Some(PanelEntry::Fs(FsEntry::File(file))) => assert_eq!( + file.buffer_id, buffer_two_id, + "hiding symbols should remap the selected outline to its containing file" + ), + other => panic!("expected the file row of two.rs to be selected, got {other:?}"), + } + }); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.toggle_symbols(&ToggleSymbols, window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + + outline_panel.update(cx, |panel, cx| { + assert!( + panel.cached_entries.iter().any(|entry| matches!( + entry.entry, + PanelEntry::Outline(OutlineEntry::Outline(_)) + )), + "toggling hide-symbols off should re-fetch and show outline rows again" + ); + assert_eq!( + panel.collapsed_entries, collapsed_entries_snapshot, + "toggling hide-symbols should not mutate collapsed_entries" + ); + let buffer_two_id = buffer_two.read(cx).remote_id(); + match panel.selected_entry() { + Some(PanelEntry::Fs(FsEntry::File(file))) => assert_eq!( + file.buffer_id, buffer_two_id, + "re-showing symbols should keep the remapped file row selected" + ), + other => panic!("expected the file row of two.rs to stay selected, got {other:?}"), + } + }); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.toggle_symbols(&ToggleSymbols, window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + + outline_panel.update(cx, |panel, _cx| { + assert!( + panel.cached_entries.iter().all(|entry| matches!( + entry.entry, + PanelEntry::Fs(_) | PanelEntry::FoldedDirs(_) + )), + "toggling hide-symbols back on should hide outline rows again" + ); + assert_eq!( + panel.collapsed_entries, collapsed_entries_snapshot, + "hide-symbols mode should not mutate collapsed_entries" + ); + }); + + // `hide_symbols_override` is `Some(true)` here while the setting is still `true` too, + // so flipping the setting to `false` makes the override disagree with the incoming + // settings change, exercising the settings-observer's override reset. + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(false); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + + outline_panel.update(cx, |panel, _cx| { + assert!( + panel.cached_entries.iter().any(|entry| matches!( + entry.entry, + PanelEntry::Outline(OutlineEntry::Outline(_)) + )), + "settings change should clear a stale hide-symbols override and re-show outline rows" + ); + assert_eq!( + panel.collapsed_entries, collapsed_entries_snapshot, + "settings-driven override reset should not mutate collapsed_entries" + ); + }); + + // Regression test: dispatch through the workspace-registered handler (not by calling + // `toggle_symbols` directly) while the editor, not the panel, is focused. This is the + // path that used to double-lease the `Workspace` entity and panic. + outline_panel.update_in(cx, |panel, window, cx| { + assert!( + !panel.focus_handle(cx).contains_focused(window, cx), + "the editor, not the outline panel, should be focused before dispatching" + ); + }); + + cx.dispatch_action(ToggleSymbols); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + + outline_panel.update(cx, |panel, _cx| { + assert!( + panel.cached_entries.iter().all(|entry| matches!( + entry.entry, + PanelEntry::Fs(_) | PanelEntry::FoldedDirs(_) + )), + "dispatching ToggleSymbols via the workspace handler while unfocused should \ + enable hide-symbols mode without panicking, got {:#?}", + panel.cached_entries + ); + }); + } + + #[gpui::test] + async fn test_hide_symbols_exempts_singleton(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .outline_panel + .get_or_insert_default() + .multi_buffer_hide_symbols = Some(true); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/test", + json!({ + "src": { + "lib.rs": indoc!(" + mod outer { + pub struct OuterStruct { + field: String, + } + impl OuterStruct { + pub fn new() -> Self { + Self { field: String::new() } + } + pub fn method(&self) { + println!(\"{}\", self.field); + } + } + mod inner { + pub fn inner_function() { + let x = 42; + println!(\"{}\", x); + } + pub struct InnerStruct { + value: i32, + } + } + } + fn main() { + let s = outer::OuterStruct::new(); + s.method(); + } + "), + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), 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_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from("/test/src/lib.rs"), + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + window, + cx, + ) + }) + .await + .unwrap(); + + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + + // Force another update cycle to ensure outlines are fetched + outline_panel.update_in(cx, |panel, window, cx| { + panel.update_non_fs_items(window, cx); + panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + 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: mod outer <==== selected + outline: pub struct OuterStruct + outline: field + outline: impl OuterStruct + outline: pub fn new + outline: pub fn method + outline: mod inner + outline: pub fn inner_function + outline: pub struct InnerStruct + outline: value +outline: fn main" + ), + "singleton editors should be exempt from hide-symbols mode" + ); + }); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.toggle_symbols(&ToggleSymbols, window, cx); + assert_eq!( + panel.hide_symbols_override, None, + "toggling symbols in a singleton view should be a no-op and not arm the override" + ); + }); + } + + #[gpui::test(iterations = 10)] + async fn test_hide_symbols_hides_search_matches(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + let root = path!("/rust-analyzer"); + populate_with_test_ra_project(&fs, root).await; + let project = Project::test(fs.clone(), [Path::new(root)], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), 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_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch::default(), + window, + cx, + ) + }); + let search_view = workspace.update_in(cx, |workspace, _window, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + .expect("Project search view expected to appear after new search event trigger") + }); + + 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"# + .to_string(); + + let select_first_in_all_matches = |line_to_select: &str| { + assert!( + all_matches.contains(line_to_select), + "`{line_to_select}` was not found in all matches `{all_matches}`" + ); + 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» {" + ) + ); + }); + + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(true); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert!( + outline_panel.cached_entries.iter().all(|entry| matches!( + entry.entry, + PanelEntry::Fs(_) | PanelEntry::FoldedDirs(_) + )), + "hide-symbols mode should hide search match rows, got {:#?}", + outline_panel.cached_entries + ); + assert_eq!( + display_entries( + &project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +rust-analyzer/ + crates/ + ide/src/ + inlay_hints/ + fn_lifetime_fn.rs <==== selected + inlay_hints.rs + static_index.rs + rust-analyzer/src/ + cli/ + analysis_stats.rs + config.rs" + ), + "directories and files should still render in hide-symbols mode, \ + with the previously selected search match remapped to its file" + ); + }); + + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.toggle_symbols(&ToggleSymbols, window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert!( + outline_panel + .cached_entries + .iter() + .any(|entry| matches!(entry.entry, PanelEntry::Search(_))), + "toggling hide-symbols off should bring search match rows back" + ); + assert_eq!( + display_entries( + &project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + all_matches.replacen( + "fn_lifetime_fn.rs", + &format!("fn_lifetime_fn.rs{SELECTED_MARKER}"), + 1, + ), + "re-showing search matches should keep the remapped file row selected" + ); + }); + } + + #[gpui::test] + async fn test_excerpt_outlines_scoped_to_excerpt_range(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/test"), + json!({ + "src": { + "one.rs": indoc!(" + fn alpha() { + let one = 1; + } + + fn beta() { + let two = 2; + } + "), + "two.rs": indoc!(" + struct Gamma { + field: i32, + } + + fn delta() {} + "), + "three.rs": indoc!(" + mod epsilon { + fn zeta() { + let three = 3; + } + } + "), + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), 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 buffer_one = open_buffer(&project, path!("/test/src/one.rs"), cx).await; + let buffer_two = open_buffer(&project, path!("/test/src/two.rs"), cx).await; + let buffer_three = open_buffer(&project, path!("/test/src/three.rs"), cx).await; + + add_multi_buffer_editor( + &workspace, + &project, + &[ + ( + &buffer_one, + vec![ + language::Point::new(0, 0)..language::Point::new(2, 1), + language::Point::new(4, 0)..language::Point::new(6, 1), + ], + ), + ( + &buffer_two, + vec![language::Point::new(0, 0)..language::Point::new(2, 1)], + ), + ( + &buffer_three, + vec![language::Point::new(2, 0)..language::Point::new(2, 22)], + ), + ], + cx, + ); + settle_outline_panel(&outline_panel, cx); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + None, + cx, + ), + indoc!( + " +test/ + src/ + one.rs + outline: fn alpha + outline: fn beta + three.rs + outline: mod epsilon + outline: fn zeta + two.rs + outline: struct Gamma + outline: field" + ), + "each excerpt should only show outlines intersecting its range, \ + without duplicating outlines from other excerpts of the same buffer \ + and without outlines that lie outside every excerpt" + ); + }); + } + + #[gpui::test] + async fn test_simultaneous_hide_symbols_and_depth_settings_change(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/test"), + json!({ + "src": { + "one.rs": indoc!(" + pub fn one() { + let x = 1; + } + "), + "two.rs": indoc!(" + pub struct Two { + field: i32, + } + "), + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), 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 buffer_one = open_buffer(&project, path!("/test/src/one.rs"), cx).await; + let buffer_two = open_buffer(&project, path!("/test/src/two.rs"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], + cx, + ); + settle_outline_panel(&outline_panel, cx); + + outline_panel.update(cx, |panel, _cx| { + assert!( + panel.cached_entries.iter().any(|entry| matches!( + entry.entry, + PanelEntry::Outline(OutlineEntry::Outline(_)) + )), + "expected outline rows before the settings change" + ); + assert_eq!( + panel + .collapsed_entries + .iter() + .filter(|entry| matches!(entry, CollapsedEntry::Outline(..))) + .count(), + 0, + "no outline should be collapsed at the default expansion depth" + ); + }); + + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(true); + settings.expand_outlines_with_depth = Some(0); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + + outline_panel.update(cx, |panel, _cx| { + assert!( + panel.cached_entries.iter().all(|entry| matches!( + entry.entry, + PanelEntry::Fs(_) | PanelEntry::FoldedDirs(_) + )), + "hide-symbols mode should only show fs entries, got {:#?}", + panel.cached_entries + ); + assert_eq!( + panel + .collapsed_entries + .iter() + .filter(|entry| matches!(entry, CollapsedEntry::Outline(..))) + .count(), + 1, + "a depth change arriving together with a hide-symbols change should still \ + collapse the only outline with children (struct Two)" + ); + }); + + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(false); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + + outline_panel.update(cx, |panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(panel, cx), + &panel.cached_entries, + None, + cx + ), + indoc!( + " +test/ + src/ + one.rs + outline: pub fn one + two.rs + outline: pub struct Two" + ), + "after re-showing symbols, the depth applied during the combined settings \ + change should keep struct Two collapsed, hiding its field" + ); + }); + } + + #[gpui::test] + async fn test_filtered_symbol_selection_survives_hide_symbols(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/test"), + json!({ + "src": { + "one.rs": indoc!(" + fn alpha() { + let one = 1; + } + "), + "two.rs": indoc!(" + pub struct Two { + field: i32, + } + "), + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), 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 buffer_one = open_buffer(&project, path!("/test/src/one.rs"), cx).await; + let buffer_two = open_buffer(&project, path!("/test/src/two.rs"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], + cx, + ); + settle_outline_panel(&outline_panel, cx); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.filter_editor.update(cx, |filter_editor, cx| { + filter_editor.set_text("alpha", window, cx); + }); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + let alpha_outline = outline_panel.read_with(cx, |panel, _cx| { + panel + .cached_entries + .iter() + .find_map(|entry| match &entry.entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) + if outline.text == "fn alpha" => + { + Some(entry.entry.clone()) + } + _ => None, + }) + .expect("expected the filtered view to contain the `fn alpha` outline") + }); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_entry(alpha_outline, true, window, cx); + }); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.toggle_symbols(&ToggleSymbols, window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update_in(cx, |panel, window, cx| { + assert_eq!( + panel.cached_entries.len(), + 0, + "hiding symbols with a symbol-only filter should show an empty list, got {:#?}", + panel.cached_entries + ); + let buffer_one_id = buffer_one.read(cx).remote_id(); + match panel.selected_entry() { + Some(PanelEntry::Fs(FsEntry::File(file))) => assert_eq!( + file.buffer_id, buffer_one_id, + "the filtered outline selection should remap to its containing file" + ), + other => panic!("expected the file row of one.rs to be selected, got {other:?}"), + } + panel.select_next(&SelectNext, window, cx); + panel.select_previous(&SelectPrevious, window, cx); + }); + + outline_panel.update_in(cx, |panel, window, cx| { + 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, |panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(panel, cx), + &panel.cached_entries, + panel.selected_entry(), + cx, + ), + indoc!( + " +test/ + src/ + one.rs <==== selected + two.rs" + ), + "clearing the filter should reveal the remapped file row still selected" + ); + }); + } + + #[gpui::test] + async fn test_default_depth_applies_to_all_buffers(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .outline_panel + .get_or_insert_default() + .expand_outlines_with_depth = Some(0); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/test"), + json!({ + "src": { + "one.rs": indoc!(" + mod alpha { + fn a() {} + } + "), + "two.rs": indoc!(" + pub struct Two { + field: i32, + } + "), + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), 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 buffer_one = open_buffer(&project, path!("/test/src/one.rs"), cx).await; + let buffer_two = open_buffer(&project, path!("/test/src/two.rs"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], + cx, + ); + settle_outline_panel(&outline_panel, cx); + + outline_panel.update(cx, |panel, cx| { + assert_eq!( + panel + .collapsed_entries + .iter() + .filter(|entry| matches!(entry, CollapsedEntry::Outline(..))) + .count(), + 2, + "the default expansion depth should collapse parent outlines in every \ + buffer of the multi-buffer, not just the first fetched one" + ); + assert_eq!( + display_entries( + &project, + &snapshot(panel, cx), + &panel.cached_entries, + None, + cx + ), + indoc!( + " +test/ + src/ + one.rs + outline: mod alpha + two.rs + outline: pub struct Two" + ), + "both files should render only their collapsed parent outlines" + ); + }); + } + + #[gpui::test] + async fn test_depth_change_while_symbols_hidden_applies_on_reveal(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .outline_panel + .get_or_insert_default() + .multi_buffer_hide_symbols = Some(true); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/test"), + json!({ + "src": { + "one.rs": indoc!(" + mod alpha { + fn a() {} + } + "), + "two.rs": indoc!(" + pub struct Two { + field: i32, + } + "), + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), 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 buffer_one = open_buffer(&project, path!("/test/src/one.rs"), cx).await; + let buffer_two = open_buffer(&project, path!("/test/src/two.rs"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], + cx, + ); + settle_outline_panel(&outline_panel, cx); + + outline_panel.update(cx, |panel, _cx| { + assert!( + panel.cached_entries.iter().all(|entry| matches!( + entry.entry, + PanelEntry::Fs(_) | PanelEntry::FoldedDirs(_) + )), + "symbols should be hidden and outlines unfetched before the settings change" + ); + }); + + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(false); + settings.expand_outlines_with_depth = Some(0); + }); + settle_outline_panel(&outline_panel, cx); + + outline_panel.update(cx, |panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(panel, cx), + &panel.cached_entries, + None, + cx + ), + indoc!( + " +test/ + src/ + one.rs + outline: mod alpha + two.rs + outline: pub struct Two" + ), + "a depth change while symbols were hidden should apply to outlines \ + fetched after the symbols are shown again" + ); + }); + } } diff --git a/crates/outline_panel/src/outline_panel_settings.rs b/crates/outline_panel/src/outline_panel_settings.rs index 238da62e2f..0c4605b5a8 100644 --- a/crates/outline_panel/src/outline_panel_settings.rs +++ b/crates/outline_panel/src/outline_panel_settings.rs @@ -18,6 +18,7 @@ pub struct OutlinePanelSettings { pub auto_fold_dirs: bool, pub scrollbar: ScrollbarSettings, pub expand_outlines_with_depth: usize, + pub multi_buffer_hide_symbols: bool, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -76,6 +77,7 @@ impl Settings for OutlinePanelSettings { .map(ui_scrollbar_settings_from_raw), }, expand_outlines_with_depth: panel.expand_outlines_with_depth.unwrap(), + multi_buffer_hide_symbols: panel.multi_buffer_hide_symbols.unwrap(), } } } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 55436c87a9..5e00367889 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1210,6 +1210,13 @@ pub struct OutlinePanelSettingsContent { /// /// Default: 100 pub expand_outlines_with_depth: Option, + /// Whether to hide symbols, excerpts and search matches in the outline panel + /// when a multi-buffer view (e.g. a diff or search results) is active, + /// showing only files and directories. + /// Does not affect single-file views. + /// + /// Default: false + pub multi_buffer_hide_symbols: Option, } #[derive( diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 5f00e971df..9941e2c3fd 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -5892,7 +5892,7 @@ fn panels_page() -> SettingsPage { ] } - fn outline_panel_section() -> [SettingsPageItem; 11] { + fn outline_panel_section() -> [SettingsPageItem; 12] { [ SettingsPageItem::SectionHeader("Outline Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -6108,6 +6108,29 @@ fn panels_page() -> SettingsPage { }), metadata: None, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Hide Symbols in Multi-Buffers", + description: "Whether to hide symbols, excerpts and search matches in the outline panel when a multi-buffer view is active.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("outline_panel.multi_buffer_hide_symbols"), + pick: |settings_content| { + settings_content + .outline_panel + .as_ref()? + .multi_buffer_hide_symbols + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .outline_panel + .get_or_insert_default() + .multi_buffer_hide_symbols = value; + }, + }), + metadata: None, + files: USER, + }), ] } diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index ecd9aca7d9..2eb333f1ee 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -5817,7 +5817,8 @@ You can define these in user or project settings; project settings are merged on }, "scrollbar": { "show": null - } + }, + "multi_buffer_hide_symbols": false } } ``` diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index a562634acf..da848c6325 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -608,7 +608,8 @@ See [Terminal settings](./reference/all-settings.md#terminal) for additional non "auto_fold_dirs": true, // Fold dirs with single subdir "scrollbar": { // Project panel scrollbar settings "show": null // Show/hide: (auto, system, always, never) - } + }, + "multi_buffer_hide_symbols": false // Hide symbols in multi-buffer views (show only files) } ```