From bb501d922196001ee3011affc65caa81313ad41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Ga=C5=BEo?= Date: Mon, 31 Aug 2026 14:36:04 +0000 Subject: [PATCH] outline_panel: Add setting to hide symbols in multi-buffer views (#63404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/60242 # Objective When reviewing a branch diff — or project search results — the outline panel lists every affected excerpt and symbol beneath each file. On a diff touching more than a handful of files, this consumes so much vertical space that you cannot see the list of changed files at all, which is usually the thing you want the panel for. Existing settings don't cover this: - `expand_outlines_with_depth: 0` still renders excerpt rows and top-level symbol rows. - Collapsing a file row in the panel is coupled to folding that buffer in the editor, so it can't be used purely to tidy the panel. ## Solution A display-only mode that reduces multi-buffer views to just the directory/file tree. - New `outline_panel.multi_buffer_hide_symbols` setting (default `false`). When enabled, multi-buffer views render only directory, file and folded-directory rows — excerpt, symbol and search-match rows are hidden. - New `outline panel: toggle symbols` action, plus a button in the panel footer, which toggles an in-memory per-panel override of the setting (the same pattern as `editor: toggle inlay hints`). Changing the setting clears the override, so the setting wins. - Single-file views are exempt, and the footer button is not shown there — the panel contains only symbols in that case, so hiding them would leave it empty. - The filter is display-only: `collapsed_entries` and editor buffer folding are never touched. - Outline fetching is skipped while the mode is active, so large diffs don't pay for outlines that will not be rendered. Turning the mode off re-triggers fetching. This adds one new icon, `file_code_off`, used by the footer button when symbols are hidden. cc @zed-industries/design for review per the icon guidelines — it is `file_code` with the slash and outline gaps from `file_ignored`, keeping the parts of the chevrons that fall on the near side of the slash. ## Testing Three new tests in `crates/outline_panel/src/outline_panel.rs`: - `test_hide_symbols_in_multibuffer` — outline rows are present by default; only `Fs`/`FoldedDirs` entries remain once the mode is enabled via the setting (which also exercises the settings observer) and via the action; outline rows return when it is toggled off, confirming outlines are re-fetched; asserts `collapsed_entries` is unchanged at every step, covering the display-only guarantee. - `test_hide_symbols_exempts_singleton` — a single-file view still renders its outline with the setting enabled. - `test_hide_symbols_hides_search_matches` — project search match rows are hidden while files and directories still render, and return when toggled off. `cargo test -p outline_panel` (15 passing), `cargo test -p icons`, `cargo test -p settings_ui -p settings_content`, and `./script/clippy` on the touched crates are all green. Tested manually on macOS against a branch diff, project search results, and single-file buffers, checking both the setting and the action (from the panel footer button and from the command palette with focus outside the panel), and that expanding/collapsing directories and editor folding still behave as before. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase | | Symbols shown (default) | Symbols hidden | | --- | --- | --- | | **Footer toggle** | | | | **Branch diff** | | | | **Project search** | | | Single-file views are unaffected — symbols still render, and the toggle is not offered: --- Release Notes: - Fixed too many outlines shown per excerpt in the outline panel. Added an `outline_panel.multi_buffer_hide_symbols` setting and an `outline panel: toggle symbols` action to show only files and directories in the outline panel for multi-buffer views. --------- Co-authored-by: Kirill Bulatov --- assets/icons/file_code_off.svg | 6 + assets/settings/default.json | 5 + crates/icons/src/icons.rs | 1 + crates/outline_panel/src/outline_panel.rs | 1452 ++++++++++++++++- .../src/outline_panel_settings.rs | 2 + .../settings_content/src/settings_content.rs | 7 + crates/settings_ui/src/page_data.rs | 25 +- docs/src/reference/all-settings.md | 3 +- docs/src/visual-customization.md | 3 +- 9 files changed, 1413 insertions(+), 91 deletions(-) create mode 100644 assets/icons/file_code_off.svg 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) } ```