From cf7b0c8971b53e3587f450827cea62a1d2bf98ae Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 30 Oct 2024 19:09:14 +0200 Subject: [PATCH] Add scrollbars to outline panel (#19969) Part of https://github.com/zed-industries/zed/issues/15324 ![image](https://github.com/user-attachments/assets/4f32d585-9bd2-46be-8234-3658a71906ee) Repeats the approach used in the project panel. Release Notes: - Added scrollbars to outline panel --------- Co-authored-by: Nate Butler --- assets/settings/default.json | 17 + crates/outline_panel/src/outline_panel.rs | 864 ++++++++++++------ .../src/outline_panel_settings.rs | 20 + docs/src/configuring-zed.md | 3 + 4 files changed, 613 insertions(+), 291 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 748a4b12d1..5295052215 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -414,6 +414,23 @@ // 2. Never show indent guides: // "never" "show": "always" + }, + /// Scrollbar-related settings + "scrollbar": { + /// When to show the scrollbar in the project panel. + /// This setting can take four values: + /// + /// 1. null (default): Inherit editor settings + /// 2. Show the scrollbar if there's important information or + /// follow the system's configured behavior (default): + /// "auto" + /// 3. Match the system's configured behavior: + /// "system" + /// 4. Always show the scrollbar: + /// "always" + /// 5. Never show the scrollbar: + /// "never" + "show": null } }, "collaboration_panel": { diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 10ca2b0712..6ffac21021 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -5,7 +5,7 @@ use std::{ cmp, hash::Hash, ops::Range, - path::{Path, PathBuf}, + path::{Path, PathBuf, MAIN_SEPARATOR_STR}, sync::{atomic::AtomicBool, Arc, OnceLock}, time::Duration, u32, @@ -17,9 +17,9 @@ use db::kvp::KEY_VALUE_STORE; use editor::{ display_map::ToDisplayPoint, items::{entry_git_aware_label_color, entry_label_color}, - scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor}, - AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, EditorMode, ExcerptId, ExcerptRange, - MultiBufferSnapshot, RangeToAnchorExt, + scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor, ScrollbarAutoHide}, + AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, EditorMode, EditorSettings, ExcerptId, + ExcerptRange, MultiBufferSnapshot, RangeToAnchorExt, ShowScrollbar, }; use file_icons::FileIcons; use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; @@ -27,8 +27,9 @@ use gpui::{ actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action, AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, ElementId, EventEmitter, FocusHandle, FocusableView, HighlightStyle, InteractiveElement, - IntoElement, KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, - Render, SharedString, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View, + IntoElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton, + MouseDownEvent, ParentElement, Pixels, Point, Render, SharedString, Stateful, + StatefulInteractiveElement as _, Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WindowContext, }; use itertools::Itertools; @@ -51,7 +52,8 @@ use workspace::{ ui::{ h_flex, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, FluentBuilder, HighlightedLabel, Icon, IconButton, IconButtonShape, IconName, IconSize, Label, - LabelCommon, ListItem, Selectable, Spacing, StyledExt, StyledTypography, Tooltip, + LabelCommon, ListItem, Scrollbar, ScrollbarState, Selectable, Spacing, StyledExt, + StyledTypography, Tooltip, }, OpenInTerminal, WeakItemHandle, Workspace, }; @@ -116,6 +118,11 @@ pub struct OutlinePanel { cached_entries: Vec, filter_editor: View, mode: ItemsDisplayMode, + show_scrollbar: bool, + vertical_scrollbar_state: ScrollbarState, + horizontal_scrollbar_state: ScrollbarState, + hide_scrollbar_task: Option>, + max_width_item_index: Option, } enum ItemsDisplayMode { @@ -624,6 +631,9 @@ impl OutlinePanel { let focus_handle = cx.focus_handle(); let focus_subscription = cx.on_focus(&focus_handle, Self::focus_in); + let focus_out_subscription = cx.on_focus_out(&focus_handle, |outline_panel, _, cx| { + outline_panel.hide_scrollbar(cx); + }); let workspace_subscription = cx.subscribe( &workspace .weak_handle() @@ -674,6 +684,8 @@ impl OutlinePanel { } }); + let scroll_handle = UniformListScrollHandle::new(); + let mut outline_panel = Self { mode: ItemsDisplayMode::Outline, active: false, @@ -681,7 +693,14 @@ impl OutlinePanel { workspace: workspace_handle, project, fs: workspace.app_state().fs.clone(), - scroll_handle: UniformListScrollHandle::new(), + show_scrollbar: !Self::should_autohide_scrollbar(cx), + hide_scrollbar_task: None, + vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone()) + .parent_view(cx.view()), + horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone()) + .parent_view(cx.view()), + max_width_item_index: None, + scroll_handle, focus_handle, filter_editor, fs_entries: Vec::new(), @@ -705,6 +724,7 @@ impl OutlinePanel { settings_subscription, icons_subscription, focus_subscription, + focus_out_subscription, workspace_subscription, filter_update_subscription, ], @@ -1606,16 +1626,11 @@ impl OutlinePanel { } .unwrap_or_else(empty_icon); - let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx)?; - let excerpt_range = range.context.to_point(&buffer_snapshot); - let label_element = Label::new(format!( - "Lines {}- {}", - excerpt_range.start.row + 1, - excerpt_range.end.row + 1, - )) - .single_line() - .color(color) - .into_any_element(); + let label = self.excerpt_label(buffer_id, range, cx)?; + let label_element = Label::new(label) + .single_line() + .color(color) + .into_any_element(); Some(self.entry_element( PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, range.clone())), @@ -1628,6 +1643,21 @@ impl OutlinePanel { )) } + fn excerpt_label( + &self, + buffer_id: BufferId, + range: &ExcerptRange, + cx: &AppContext, + ) -> Option { + let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx)?; + let excerpt_range = range.context.to_point(&buffer_snapshot); + Some(format!( + "Lines {}- {}", + excerpt_range.start.row + 1, + excerpt_range.end.row + 1, + )) + } + fn render_outline( &self, buffer_id: BufferId, @@ -2793,10 +2823,11 @@ impl OutlinePanel { else { return; }; - let new_cached_entries = new_cached_entries.await; + let (new_cached_entries, max_width_item_index) = new_cached_entries.await; outline_panel .update(&mut cx, |outline_panel, cx| { outline_panel.cached_entries = new_cached_entries; + outline_panel.max_width_item_index = max_width_item_index; if outline_panel.selected_entry.is_invalidated() { if let Some(new_selected_entry) = outline_panel.active_editor().and_then(|active_editor| { @@ -2819,11 +2850,10 @@ impl OutlinePanel { is_singleton: bool, query: Option, cx: &mut ViewContext<'_, Self>, - ) -> Task> { + ) -> Task<(Vec, Option)> { let project = self.project.clone(); cx.spawn(|outline_panel, mut cx| async move { - let mut entries = Vec::new(); - let mut match_candidates = Vec::new(); + let mut generation_state = GenerationState::default(); let Ok(()) = outline_panel.update(&mut cx, |outline_panel, cx| { let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; @@ -2943,8 +2973,7 @@ impl OutlinePanel { folded_dirs, ); outline_panel.push_entry( - &mut entries, - &mut match_candidates, + &mut generation_state, track_matches, new_folded_dirs, folded_depth, @@ -2981,8 +3010,7 @@ impl OutlinePanel { .map_or(true, |parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( - &mut entries, - &mut match_candidates, + &mut generation_state, track_matches, PanelEntry::FoldedDirs(worktree_id, folded_dirs), folded_depth, @@ -3006,8 +3034,7 @@ impl OutlinePanel { .map_or(true, |parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( - &mut entries, - &mut match_candidates, + &mut generation_state, track_matches, PanelEntry::FoldedDirs(worktree_id, folded_dirs), folded_depth, @@ -3042,8 +3069,7 @@ impl OutlinePanel { && (should_add || (query.is_some() && folded_dirs_entry.is_none())) { outline_panel.push_entry( - &mut entries, - &mut match_candidates, + &mut generation_state, track_matches, PanelEntry::Fs(entry.clone()), depth, @@ -3055,8 +3081,7 @@ impl OutlinePanel { ItemsDisplayMode::Search(_) => { if is_singleton || query.is_some() || (should_add && is_expanded) { outline_panel.add_search_entries( - &mut entries, - &mut match_candidates, + &mut generation_state, entry.clone(), depth, query.clone(), @@ -3082,14 +3107,13 @@ impl OutlinePanel { }; if let Some((buffer_id, entry_excerpts)) = excerpts_to_consider { outline_panel.add_excerpt_entries( + &mut generation_state, buffer_id, entry_excerpts, depth, track_matches, is_singleton, query.as_deref(), - &mut entries, - &mut match_candidates, cx, ); } @@ -3098,13 +3122,12 @@ impl OutlinePanel { if is_singleton && matches!(entry, FsEntry::File(..) | FsEntry::ExternalFile(..)) - && !entries.iter().any(|item| { + && !generation_state.entries.iter().any(|item| { matches!(item.entry, PanelEntry::Outline(..) | PanelEntry::Search(_)) }) { outline_panel.push_entry( - &mut entries, - &mut match_candidates, + &mut generation_state, track_matches, PanelEntry::Fs(entry.clone()), 0, @@ -3121,8 +3144,7 @@ impl OutlinePanel { .map_or(true, |parent| parent.expanded); if parent_expanded || query.is_some() { outline_panel.push_entry( - &mut entries, - &mut match_candidates, + &mut generation_state, track_matches, PanelEntry::FoldedDirs(worktree_id, folded_dirs), folded_depth, @@ -3131,15 +3153,20 @@ impl OutlinePanel { } } }) else { - return Vec::new(); + return (Vec::new(), None); }; let Some(query) = query else { - return entries; + return ( + generation_state.entries, + generation_state + .max_width_estimate_and_index + .map(|(_, index)| index), + ); }; let mut matched_ids = match_strings( - &match_candidates, + &generation_state.match_candidates, &query, true, usize::MAX, @@ -3152,7 +3179,7 @@ impl OutlinePanel { .collect::>(); let mut id = 0; - entries.retain_mut(|cached_entry| { + generation_state.entries.retain_mut(|cached_entry| { let retain = match matched_ids.remove(&id) { Some(string_match) => { cached_entry.string_match = Some(string_match); @@ -3164,15 +3191,19 @@ impl OutlinePanel { retain }); - entries + ( + generation_state.entries, + generation_state + .max_width_estimate_and_index + .map(|(_, index)| index), + ) }) } #[allow(clippy::too_many_arguments)] fn push_entry( &self, - entries: &mut Vec, - match_candidates: &mut Vec, + state: &mut GenerationState, track_matches: bool, entry: PanelEntry, depth: usize, @@ -3192,13 +3223,13 @@ impl OutlinePanel { }; if track_matches { - let id = entries.len(); + let id = state.entries.len(); match &entry { PanelEntry::Fs(fs_entry) => { if let Some(file_name) = self.relative_path(fs_entry, cx).as_deref().map(file_name) { - match_candidates.push(StringMatchCandidate { + state.match_candidates.push(StringMatchCandidate { id, string: file_name.to_string(), char_bag: file_name.chars().collect(), @@ -3208,7 +3239,7 @@ impl OutlinePanel { PanelEntry::FoldedDirs(worktree_id, entries) => { let dir_names = self.dir_names_string(entries, *worktree_id, cx); { - match_candidates.push(StringMatchCandidate { + state.match_candidates.push(StringMatchCandidate { id, string: dir_names.clone(), char_bag: dir_names.chars().collect(), @@ -3217,7 +3248,7 @@ impl OutlinePanel { } PanelEntry::Outline(outline_entry) => match outline_entry { OutlineEntry::Outline(_, _, outline) => { - match_candidates.push(StringMatchCandidate { + state.match_candidates.push(StringMatchCandidate { id, string: outline.text.clone(), char_bag: outline.text.chars().collect(), @@ -3226,7 +3257,7 @@ impl OutlinePanel { OutlineEntry::Excerpt(..) => {} }, PanelEntry::Search(new_search_entry) => { - match_candidates.push(StringMatchCandidate { + state.match_candidates.push(StringMatchCandidate { id, char_bag: new_search_entry.render_data.context_text.chars().collect(), string: new_search_entry.render_data.context_text.clone(), @@ -3234,7 +3265,16 @@ impl OutlinePanel { } } } - entries.push(CachedEntry { + + let width_estimate = self.width_estimate(depth, &entry, cx); + if Some(width_estimate) + > state + .max_width_estimate_and_index + .map(|(estimate, _)| estimate) + { + state.max_width_estimate_and_index = Some((width_estimate, state.entries.len())); + } + state.entries.push(CachedEntry { depth, entry, string_match: None, @@ -3369,14 +3409,13 @@ impl OutlinePanel { #[allow(clippy::too_many_arguments)] fn add_excerpt_entries( &self, + state: &mut GenerationState, buffer_id: BufferId, entries_to_add: &[ExcerptId], parent_depth: usize, track_matches: bool, is_singleton: bool, query: Option<&str>, - entries: &mut Vec, - match_candidates: &mut Vec, cx: &mut ViewContext, ) { if let Some(excerpts) = self.excerpts.get(&buffer_id) { @@ -3386,8 +3425,7 @@ impl OutlinePanel { }; let excerpt_depth = parent_depth + 1; self.push_entry( - entries, - match_candidates, + state, track_matches, PanelEntry::Outline(OutlineEntry::Excerpt( buffer_id, @@ -3401,8 +3439,7 @@ impl OutlinePanel { let mut outline_base_depth = excerpt_depth + 1; if is_singleton { outline_base_depth = 0; - entries.clear(); - match_candidates.clear(); + state.clear(); } else if query.is_none() && self .collapsed_entries @@ -3413,8 +3450,7 @@ impl OutlinePanel { for outline in excerpt.iter_outlines() { self.push_entry( - entries, - match_candidates, + state, track_matches, PanelEntry::Outline(OutlineEntry::Outline( buffer_id, @@ -3432,8 +3468,7 @@ impl OutlinePanel { #[allow(clippy::too_many_arguments)] fn add_search_entries( &mut self, - entries: &mut Vec, - match_candidates: &mut Vec, + state: &mut GenerationState, parent_entry: FsEntry, parent_depth: usize, filter_query: Option, @@ -3464,7 +3499,8 @@ impl OutlinePanel { || related_excerpts.contains(&match_range.end.excerpt_id) }); - let previous_search_matches = entries + let previous_search_matches = state + .entries .iter() .skip_while(|entry| { if let PanelEntry::Fs(entry) = &entry.entry { @@ -3519,8 +3555,7 @@ impl OutlinePanel { .collect::>(); for new_search_entry in new_search_entries { self.push_entry( - entries, - match_candidates, + state, filter_query.is_some(), PanelEntry::Search(new_search_entry), depth, @@ -3589,6 +3624,430 @@ impl OutlinePanel { self.autoscroll(cx); cx.notify(); } + + fn render_vertical_scrollbar(&self, cx: &mut ViewContext) -> Option> { + if !Self::should_show_scrollbar(cx) + || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging()) + { + return None; + } + Some( + div() + .occlude() + .id("project-panel-vertical-scroll") + .on_mouse_move(cx.listener(|_, _, cx| { + cx.notify(); + cx.stop_propagation() + })) + .on_hover(|_, cx| { + cx.stop_propagation(); + }) + .on_any_mouse_down(|_, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + cx.listener(|outline_panel, _, cx| { + if !outline_panel.vertical_scrollbar_state.is_dragging() + && !outline_panel.focus_handle.contains_focused(cx) + { + outline_panel.hide_scrollbar(cx); + cx.notify(); + } + + cx.stop_propagation(); + }), + ) + .on_scroll_wheel(cx.listener(|_, _, cx| { + cx.notify(); + })) + .h_full() + .absolute() + .right_1() + .top_1() + .bottom_0() + .w(px(12.)) + .cursor_default() + .children(Scrollbar::vertical(self.vertical_scrollbar_state.clone())), + ) + } + + fn render_horizontal_scrollbar(&self, cx: &mut ViewContext) -> Option> { + if !Self::should_show_scrollbar(cx) + || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging()) + { + return None; + } + + let scroll_handle = self.scroll_handle.0.borrow(); + let longest_item_width = scroll_handle + .last_item_size + .filter(|size| size.contents.width > size.item.width)? + .contents + .width + .0 as f64; + if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 { + return None; + } + + Some( + div() + .occlude() + .id("project-panel-horizontal-scroll") + .on_mouse_move(cx.listener(|_, _, cx| { + cx.notify(); + cx.stop_propagation() + })) + .on_hover(|_, cx| { + cx.stop_propagation(); + }) + .on_any_mouse_down(|_, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + cx.listener(|outline_panel, _, cx| { + if !outline_panel.horizontal_scrollbar_state.is_dragging() + && !outline_panel.focus_handle.contains_focused(cx) + { + outline_panel.hide_scrollbar(cx); + cx.notify(); + } + + cx.stop_propagation(); + }), + ) + .on_scroll_wheel(cx.listener(|_, _, cx| { + cx.notify(); + })) + .w_full() + .absolute() + .right_1() + .left_1() + .bottom_0() + .h(px(12.)) + .cursor_default() + .when(self.width.is_some(), |this| { + this.children(Scrollbar::horizontal( + self.horizontal_scrollbar_state.clone(), + )) + }), + ) + } + + fn should_show_scrollbar(cx: &AppContext) -> bool { + let show = OutlinePanelSettings::get_global(cx) + .scrollbar + .show + .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show); + match show { + ShowScrollbar::Auto => true, + ShowScrollbar::System => true, + ShowScrollbar::Always => true, + ShowScrollbar::Never => false, + } + } + + fn should_autohide_scrollbar(cx: &AppContext) -> bool { + let show = OutlinePanelSettings::get_global(cx) + .scrollbar + .show + .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show); + match show { + ShowScrollbar::Auto => true, + ShowScrollbar::System => cx + .try_global::() + .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0), + ShowScrollbar::Always => false, + ShowScrollbar::Never => true, + } + } + + fn hide_scrollbar(&mut self, cx: &mut ViewContext) { + const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); + if !Self::should_autohide_scrollbar(cx) { + return; + } + self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move { + cx.background_executor() + .timer(SCROLLBAR_SHOW_INTERVAL) + .await; + panel + .update(&mut cx, |panel, cx| { + panel.show_scrollbar = false; + cx.notify(); + }) + .log_err(); + })) + } + + fn width_estimate(&self, depth: usize, entry: &PanelEntry, cx: &AppContext) -> u64 { + let item_text_chars = match entry { + PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => self + .buffer_snapshot_for_id(*buffer_id, cx) + .and_then(|snapshot| { + Some(snapshot.file()?.path().file_name()?.to_string_lossy().len()) + }) + .unwrap_or_default(), + PanelEntry::Fs(FsEntry::Directory(_, directory)) => directory + .path + .file_name() + .map(|name| name.to_string_lossy().len()) + .unwrap_or_default(), + PanelEntry::Fs(FsEntry::File(_, file, _, _)) => file + .path + .file_name() + .map(|name| name.to_string_lossy().len()) + .unwrap_or_default(), + PanelEntry::FoldedDirs(_, dirs) => { + dirs.iter() + .map(|dir| { + dir.path + .file_name() + .map(|name| name.to_string_lossy().len()) + .unwrap_or_default() + }) + .sum::() + + dirs.len().saturating_sub(1) * MAIN_SEPARATOR_STR.len() + } + PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, _, range)) => self + .excerpt_label(*buffer_id, range, cx) + .map(|label| label.len()) + .unwrap_or_default(), + PanelEntry::Outline(OutlineEntry::Outline(_, _, outline)) => outline.text.len(), + PanelEntry::Search(search) => search.render_data.context_text.len(), + }; + + (item_text_chars + depth) as u64 + } + + fn render_main_contents( + &mut self, + query: Option, + show_indent_guides: bool, + indent_size: f32, + cx: &mut ViewContext<'_, Self>, + ) -> Div { + let contents = if self.cached_entries.is_empty() { + let header = if self.updating_fs_entries { + "Loading outlines" + } else if query.is_some() { + "No matches for query" + } else { + "No outlines available" + }; + + v_flex() + .flex_1() + .justify_center() + .size_full() + .child(h_flex().justify_center().child(Label::new(header))) + .when_some(query.clone(), |panel, query| { + panel.child(h_flex().justify_center().child(Label::new(query))) + }) + .child( + h_flex() + .pt(Spacing::Small.rems(cx)) + .justify_center() + .child({ + let keystroke = match self.position(cx) { + DockPosition::Left => { + cx.keystroke_text_for(&workspace::ToggleLeftDock) + } + DockPosition::Bottom => { + cx.keystroke_text_for(&workspace::ToggleBottomDock) + } + DockPosition::Right => { + cx.keystroke_text_for(&workspace::ToggleRightDock) + } + }; + Label::new(format!("Toggle this panel with {keystroke}")) + }), + ) + } else { + let list_contents = { + let items_len = self.cached_entries.len(); + let multi_buffer_snapshot = self + .active_editor() + .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx)); + uniform_list(cx.view().clone(), "entries", items_len, { + move |outline_panel, range, cx| { + let entries = outline_panel.cached_entries.get(range); + entries + .map(|entries| entries.to_vec()) + .unwrap_or_default() + .into_iter() + .filter_map(|cached_entry| match cached_entry.entry { + PanelEntry::Fs(entry) => Some(outline_panel.render_entry( + &entry, + cached_entry.depth, + cached_entry.string_match.as_ref(), + cx, + )), + PanelEntry::FoldedDirs(worktree_id, entries) => { + Some(outline_panel.render_folded_dirs( + worktree_id, + &entries, + cached_entry.depth, + cached_entry.string_match.as_ref(), + cx, + )) + } + PanelEntry::Outline(OutlineEntry::Excerpt( + buffer_id, + excerpt_id, + excerpt, + )) => outline_panel.render_excerpt( + buffer_id, + excerpt_id, + &excerpt, + cached_entry.depth, + cx, + ), + PanelEntry::Outline(OutlineEntry::Outline( + buffer_id, + excerpt_id, + outline, + )) => Some(outline_panel.render_outline( + buffer_id, + excerpt_id, + &outline, + cached_entry.depth, + cached_entry.string_match.as_ref(), + cx, + )), + PanelEntry::Search(SearchEntry { + match_range, + render_data, + kind, + .. + }) => Some(outline_panel.render_search_match( + multi_buffer_snapshot.as_ref(), + &match_range, + &render_data, + kind, + cached_entry.depth, + cached_entry.string_match.as_ref(), + cx, + )), + }) + .collect() + } + }) + .with_sizing_behavior(ListSizingBehavior::Infer) + .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) + .with_width_from_item(self.max_width_item_index) + .track_scroll(self.scroll_handle.clone()) + .when(show_indent_guides, |list| { + list.with_decoration( + ui::indent_guides( + cx.view().clone(), + px(indent_size), + IndentGuideColors::panel(cx), + |outline_panel, range, _| { + let entries = outline_panel.cached_entries.get(range); + if let Some(entries) = entries { + entries.into_iter().map(|item| item.depth).collect() + } else { + smallvec::SmallVec::new() + } + }, + ) + .with_render_fn( + cx.view().clone(), + move |outline_panel, params, _| { + const LEFT_OFFSET: f32 = 14.; + + let indent_size = params.indent_size; + let item_height = params.item_height; + let active_indent_guide_ix = find_active_indent_guide_ix( + outline_panel, + ¶ms.indent_guides, + ); + + params + .indent_guides + .into_iter() + .enumerate() + .map(|(ix, layout)| { + let bounds = Bounds::new( + point( + px(layout.offset.x as f32) * indent_size + + px(LEFT_OFFSET), + px(layout.offset.y as f32) * item_height, + ), + size(px(1.), px(layout.length as f32) * item_height), + ); + ui::RenderedIndentGuide { + bounds, + layout, + is_active: active_indent_guide_ix == Some(ix), + hitbox: None, + } + }) + .collect() + }, + ), + ) + }) + }; + + v_flex() + .flex_shrink() + .size_full() + .child(list_contents.size_full().flex_shrink()) + .children(self.render_vertical_scrollbar(cx)) + .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| { + this.pb_4().child(scrollbar) + }) + } + .children(self.context_menu.as_ref().map(|(menu, position, _)| { + deferred( + anchored() + .position(*position) + .anchor(gpui::AnchorCorner::TopLeft) + .child(menu.clone()), + ) + .with_priority(1) + })); + + v_flex().w_full().flex_1().overflow_hidden().child(contents) + } + + fn render_filter_footer(&mut self, pinned: bool, cx: &mut ViewContext<'_, Self>) -> Div { + v_flex().flex_none().child(horizontal_separator(cx)).child( + h_flex() + .p_2() + .w_full() + .child(self.filter_editor.clone()) + .child( + div().child( + IconButton::new( + "outline-panel-menu", + if pinned { + IconName::Unpin + } else { + IconName::Pin + }, + ) + .tooltip(move |cx| { + Tooltip::text( + if pinned { + "Unpin Outline" + } else { + "Pin Active Outline" + }, + cx, + ) + }) + .shape(IconButtonShape::Square) + .on_click(cx.listener(|outline_panel, _, cx| { + outline_panel.toggle_active_editor_pin(&ToggleActiveEditorPin, cx); + })), + ), + ), + ) + } } fn workspace_active_editor( @@ -3741,17 +4200,34 @@ impl EventEmitter for OutlinePanel {} impl Render for OutlinePanel { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let project = self.project.read(cx); + let (is_local, is_via_ssh) = self + .project + .read_with(cx, |project, _| (project.is_local(), project.is_via_ssh())); let query = self.query(cx); let pinned = self.pinned; let settings = OutlinePanelSettings::get_global(cx); let indent_size = settings.indent_size; let show_indent_guides = settings.indent_guides.show == ShowIndentGuides::Always; - let outline_panel = v_flex() + let search_query = match &self.mode { + ItemsDisplayMode::Search(search_query) => Some(search_query), + _ => None, + }; + + v_flex() .id("outline-panel") .size_full() + .overflow_hidden() .relative() + .on_hover(cx.listener(|this, hovered, cx| { + if *hovered { + this.show_scrollbar = true; + this.hide_scrollbar_task.take(); + cx.notify(); + } else if !this.focus_handle.contains_focused(cx) { + this.hide_scrollbar(cx); + } + })) .key_context(self.dispatch_context(cx)) .on_action(cx.listener(Self::open)) .on_action(cx.listener(Self::cancel)) @@ -3769,10 +4245,10 @@ impl Render for OutlinePanel { .on_action(cx.listener(Self::toggle_active_editor_pin)) .on_action(cx.listener(Self::unfold_directory)) .on_action(cx.listener(Self::fold_directory)) - .when(project.is_local(), |el| { + .when(is_local, |el| { el.on_action(cx.listener(Self::reveal_in_finder)) }) - .when(project.is_local() || project.is_via_ssh(), |el| { + .when(is_local || is_via_ssh, |el| { el.on_action(cx.listener(Self::open_in_terminal)) }) .on_mouse_down( @@ -3785,229 +4261,20 @@ impl Render for OutlinePanel { } }), ) - .track_focus(&self.focus_handle(cx)); - - if self.cached_entries.is_empty() { - let header = if self.updating_fs_entries { - "Loading outlines" - } else if query.is_some() { - "No matches for query" - } else { - "No outlines available" - }; - - outline_panel.child( - v_flex() - .justify_center() - .size_full() - .child(h_flex().justify_center().child(Label::new(header))) - .when_some(query.clone(), |panel, query| { - panel.child(h_flex().justify_center().child(Label::new(query))) - }) - .child( - h_flex() - .pt(Spacing::Small.rems(cx)) - .justify_center() - .child({ - let keystroke = match self.position(cx) { - DockPosition::Left => { - cx.keystroke_text_for(&workspace::ToggleLeftDock) - } - DockPosition::Bottom => { - cx.keystroke_text_for(&workspace::ToggleBottomDock) - } - DockPosition::Right => { - cx.keystroke_text_for(&workspace::ToggleRightDock) - } - }; - Label::new(format!("Toggle this panel with {keystroke}")) - }), - ), - ) - } else { - let search_query = match &self.mode { - ItemsDisplayMode::Search(search_query) => Some(search_query), - _ => None, - }; - outline_panel - .when_some(search_query, |outline_panel, search_state| { - outline_panel.child( - div() - .mx_2() - .child( - Label::new(format!("Searching: '{}'", search_state.query)) - .color(Color::Muted), - ) - .child(horizontal_separator(cx)), - ) - }) - .child({ - let items_len = self.cached_entries.len(); - let multi_buffer_snapshot = self - .active_editor() - .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx)); - uniform_list(cx.view().clone(), "entries", items_len, { - move |outline_panel, range, cx| { - let entries = outline_panel.cached_entries.get(range); - entries - .map(|entries| entries.to_vec()) - .unwrap_or_default() - .into_iter() - .filter_map(|cached_entry| match cached_entry.entry { - PanelEntry::Fs(entry) => Some(outline_panel.render_entry( - &entry, - cached_entry.depth, - cached_entry.string_match.as_ref(), - cx, - )), - PanelEntry::FoldedDirs(worktree_id, entries) => { - Some(outline_panel.render_folded_dirs( - worktree_id, - &entries, - cached_entry.depth, - cached_entry.string_match.as_ref(), - cx, - )) - } - PanelEntry::Outline(OutlineEntry::Excerpt( - buffer_id, - excerpt_id, - excerpt, - )) => outline_panel.render_excerpt( - buffer_id, - excerpt_id, - &excerpt, - cached_entry.depth, - cx, - ), - PanelEntry::Outline(OutlineEntry::Outline( - buffer_id, - excerpt_id, - outline, - )) => Some(outline_panel.render_outline( - buffer_id, - excerpt_id, - &outline, - cached_entry.depth, - cached_entry.string_match.as_ref(), - cx, - )), - PanelEntry::Search(SearchEntry { - match_range, - render_data, - kind, - .. - }) => Some(outline_panel.render_search_match( - multi_buffer_snapshot.as_ref(), - &match_range, - &render_data, - kind, - cached_entry.depth, - cached_entry.string_match.as_ref(), - cx, - )), - }) - .collect() - } - }) - .size_full() - .track_scroll(self.scroll_handle.clone()) - .when(show_indent_guides, |list| { - list.with_decoration( - ui::indent_guides( - cx.view().clone(), - px(indent_size), - IndentGuideColors::panel(cx), - |outline_panel, range, _| { - let entries = outline_panel.cached_entries.get(range); - if let Some(entries) = entries { - entries.into_iter().map(|item| item.depth).collect() - } else { - smallvec::SmallVec::new() - } - }, - ) - .with_render_fn( - cx.view().clone(), - move |outline_panel, params, _| { - const LEFT_OFFSET: f32 = 14.; - - let indent_size = params.indent_size; - let item_height = params.item_height; - let active_indent_guide_ix = find_active_indent_guide_ix( - outline_panel, - ¶ms.indent_guides, - ); - - params - .indent_guides - .into_iter() - .enumerate() - .map(|(ix, layout)| { - let bounds = Bounds::new( - point( - px(layout.offset.x as f32) * indent_size - + px(LEFT_OFFSET), - px(layout.offset.y as f32) * item_height, - ), - size( - px(1.), - px(layout.length as f32) * item_height, - ), - ); - ui::RenderedIndentGuide { - bounds, - layout, - is_active: active_indent_guide_ix == Some(ix), - hitbox: None, - } - }) - .collect() - }, - ), + .track_focus(&self.focus_handle(cx)) + .when_some(search_query, |outline_panel, search_state| { + outline_panel.child( + v_flex() + .child( + Label::new(format!("Searching: '{}'", search_state.query)) + .color(Color::Muted) + .mx_2(), ) - }) - }) - } - .children(self.context_menu.as_ref().map(|(menu, position, _)| { - deferred( - anchored() - .position(*position) - .anchor(gpui::AnchorCorner::TopLeft) - .child(menu.clone()), - ) - .with_priority(1) - })) - .child( - v_flex().child(horizontal_separator(cx)).child( - h_flex().p_2().child(self.filter_editor.clone()).child( - div().child( - IconButton::new( - "outline-panel-menu", - if pinned { - IconName::Unpin - } else { - IconName::Pin - }, - ) - .tooltip(move |cx| { - Tooltip::text( - if pinned { - "Unpin Outline" - } else { - "Pin Active Outline" - }, - cx, - ) - }) - .shape(IconButtonShape::Square) - .on_click(cx.listener(|outline_panel, _, cx| { - outline_panel.toggle_active_editor_pin(&ToggleActiveEditorPin, cx); - })), - ), - ), - ), - ) + .child(horizontal_separator(cx)), + ) + }) + .child(self.render_main_contents(query, show_indent_guides, indent_size, cx)) + .child(self.render_filter_footer(pinned, cx)) } } @@ -4108,6 +4375,21 @@ fn horizontal_separator(cx: &mut WindowContext) -> Div { div().mx_2().border_primary(cx).border_t_1() } +#[derive(Debug, Default)] +struct GenerationState { + entries: Vec, + match_candidates: Vec, + max_width_estimate_and_index: Option<(u64, usize)>, +} + +impl GenerationState { + fn clear(&mut self) { + self.entries.clear(); + self.match_candidates.clear(); + self.max_width_estimate_and_index = None; + } +} + #[cfg(test)] mod tests { use gpui::{TestAppContext, VisualTestContext, WindowHandle}; diff --git a/crates/outline_panel/src/outline_panel_settings.rs b/crates/outline_panel/src/outline_panel_settings.rs index d658a55793..2759424c6a 100644 --- a/crates/outline_panel/src/outline_panel_settings.rs +++ b/crates/outline_panel/src/outline_panel_settings.rs @@ -1,3 +1,4 @@ +use editor::ShowScrollbar; use gpui::Pixels; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -29,6 +30,23 @@ pub struct OutlinePanelSettings { pub indent_guides: IndentGuidesSettings, pub auto_reveal_entries: bool, pub auto_fold_dirs: bool, + pub scrollbar: ScrollbarSettings, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct ScrollbarSettings { + /// When to show the scrollbar in the project panel. + /// + /// Default: inherits editor scrollbar settings + pub show: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct ScrollbarSettingsContent { + /// When to show the scrollbar in the project panel. + /// + /// Default: inherits editor scrollbar settings + pub show: Option>, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -85,6 +103,8 @@ pub struct OutlinePanelSettingsContent { pub auto_fold_dirs: Option, /// Settings related to indent guides in the outline panel. pub indent_guides: Option, + /// Scrollbar-related settings + pub scrollbar: Option, } impl Settings for OutlinePanelSettings { diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index e1c4f698a5..d8105b4537 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -2271,6 +2271,9 @@ Run the `theme selector: toggle` action in the command palette to see a current "auto_fold_dirs": true, "indent_guides": { "show": "always" + }, + "scrollbar": { + "show": null } } ```