outline panel: Add indent guides (#19719)

Bennet Bo Fenner created

See #12673

| File | Search |
|--------|--------|
| <img width="302" alt="image"
src="https://github.com/user-attachments/assets/44b8d5f9-8446-41b5-8c0f-e438050f0ac9">
| <img width="301" alt="image"
src="https://github.com/user-attachments/assets/a2e6f77b-6d3b-4f1c-8fcb-16bd35274807">
|



Release Notes:

- Added indent guides to the outline panel

Change summary

Cargo.lock                                         |   2 
assets/settings/default.json                       |   2 
crates/gpui/src/elements/uniform_list.rs           |   2 
crates/outline_panel/Cargo.toml                    |   2 
crates/outline_panel/src/outline_panel.rs          | 122 ++++++++++
crates/outline_panel/src/outline_panel_settings.rs |   5 
crates/ui/src/components/indent_guides.rs          | 176 +++++++++------
docs/src/configuring-zed.md                        |   1 
8 files changed, 231 insertions(+), 81 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7728,8 +7728,10 @@ dependencies = [
  "serde",
  "serde_json",
  "settings",
+ "smallvec",
  "smol",
  "theme",
+ "ui",
  "util",
  "workspace",
  "worktree",

assets/settings/default.json 🔗

@@ -388,6 +388,8 @@
     "git_status": true,
     // Amount of indentation for nested items.
     "indent_size": 20,
+    // Whether to show indent guides in the outline panel.
+    "indent_guides": true,
     // Whether to reveal it in the outline panel automatically,
     // when a corresponding outline entry becomes active.
     // Gitignored entries are never auto revealed.

crates/gpui/src/elements/uniform_list.rs 🔗

@@ -340,6 +340,7 @@ impl Element for UniformList {
                                 visible_range.clone(),
                                 bounds,
                                 item_height,
+                                self.item_count,
                                 cx,
                             );
                             let available_space = size(
@@ -396,6 +397,7 @@ pub trait UniformListDecoration {
         visible_range: Range<usize>,
         bounds: Bounds<Pixels>,
         item_height: Pixels,
+        item_count: usize,
         cx: &mut WindowContext,
     ) -> AnyElement;
 }

crates/outline_panel/Cargo.toml 🔗

@@ -30,8 +30,10 @@ search.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
+smallvec.workspace = true
 smol.workspace = true
 theme.workspace = true
+ui.workspace = true
 util.workspace = true
 worktree.workspace = true
 workspace.workspace = true

crates/outline_panel/src/outline_panel.rs 🔗

@@ -24,12 +24,12 @@ use editor::{
 use file_icons::FileIcons;
 use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
 use gpui::{
-    actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
-    AppContext, AssetSource, AsyncWindowContext, 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, ViewContext,
-    VisualContext, WeakView, WindowContext,
+    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,
+    ViewContext, VisualContext, WeakView, WindowContext,
 };
 use itertools::Itertools;
 use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
@@ -42,6 +42,7 @@ use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use smol::channel;
 use theme::{SyntaxTheme, ThemeSettings};
+use ui::{IndentGuideColors, IndentGuideLayout};
 use util::{debug_panic, RangeExt, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
@@ -254,14 +255,14 @@ impl SearchState {
 #[derive(Debug)]
 enum SelectedEntry {
     Invalidated(Option<PanelEntry>),
-    Valid(PanelEntry),
+    Valid(PanelEntry, usize),
     None,
 }
 
 impl SelectedEntry {
     fn invalidate(&mut self) {
         match std::mem::replace(self, SelectedEntry::None) {
-            Self::Valid(entry) => *self = Self::Invalidated(Some(entry)),
+            Self::Valid(entry, _) => *self = Self::Invalidated(Some(entry)),
             Self::None => *self = Self::Invalidated(None),
             other => *self = other,
         }
@@ -3568,7 +3569,7 @@ impl OutlinePanel {
     fn selected_entry(&self) -> Option<&PanelEntry> {
         match &self.selected_entry {
             SelectedEntry::Invalidated(entry) => entry.as_ref(),
-            SelectedEntry::Valid(entry) => Some(entry),
+            SelectedEntry::Valid(entry, _) => Some(entry),
             SelectedEntry::None => None,
         }
     }
@@ -3577,7 +3578,16 @@ impl OutlinePanel {
         if focus {
             self.focus_handle.focus(cx);
         }
-        self.selected_entry = SelectedEntry::Valid(entry);
+        let ix = self
+            .cached_entries
+            .iter()
+            .enumerate()
+            .find(|(_, cached_entry)| &cached_entry.entry == &entry)
+            .map(|(i, _)| i)
+            .unwrap_or_default();
+
+        self.selected_entry = SelectedEntry::Valid(entry, ix);
+
         self.autoscroll(cx);
         cx.notify();
     }
@@ -3736,6 +3746,9 @@ impl Render for OutlinePanel {
         let project = self.project.read(cx);
         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;
 
         let outline_panel = v_flex()
             .id("outline-panel")
@@ -3901,6 +3914,61 @@ impl Render for OutlinePanel {
                     })
                     .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,
+                                        &params.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()
+                                },
+                            ),
+                        )
+                    })
                 })
         }
         .children(self.context_menu.as_ref().map(|(menu, position, _)| {
@@ -3945,6 +4013,40 @@ impl Render for OutlinePanel {
     }
 }
 
+fn find_active_indent_guide_ix(
+    outline_panel: &OutlinePanel,
+    candidates: &[IndentGuideLayout],
+) -> Option<usize> {
+    let SelectedEntry::Valid(_, target_ix) = &outline_panel.selected_entry else {
+        return None;
+    };
+    let target_depth = outline_panel
+        .cached_entries
+        .get(*target_ix)
+        .map(|cached_entry| cached_entry.depth)?;
+
+    let (target_ix, target_depth) = if let Some(target_depth) = outline_panel
+        .cached_entries
+        .get(target_ix + 1)
+        .filter(|cached_entry| cached_entry.depth > target_depth)
+        .map(|entry| entry.depth)
+    {
+        (target_ix + 1, target_depth.saturating_sub(1))
+    } else {
+        (*target_ix, target_depth.saturating_sub(1))
+    };
+
+    candidates
+        .iter()
+        .enumerate()
+        .find(|(_, guide)| {
+            guide.offset.y <= target_ix
+                && target_ix < guide.offset.y + guide.length
+                && guide.offset.x == target_depth
+        })
+        .map(|(ix, _)| ix)
+}
+
 fn subscribe_for_editor_events(
     editor: &View<Editor>,
     cx: &mut ViewContext<OutlinePanel>,

crates/outline_panel/src/outline_panel_settings.rs 🔗

@@ -19,6 +19,7 @@ pub struct OutlinePanelSettings {
     pub folder_icons: bool,
     pub git_status: bool,
     pub indent_size: f32,
+    pub indent_guides: bool,
     pub auto_reveal_entries: bool,
     pub auto_fold_dirs: bool,
 }
@@ -53,6 +54,10 @@ pub struct OutlinePanelSettingsContent {
     ///
     /// Default: 20
     pub indent_size: Option<f32>,
+    /// Whether to show indent guides in the outline panel.
+    ///
+    /// Default: true
+    pub indent_guides: Option<bool>,
     /// Whether to reveal it in the outline panel automatically,
     /// when a corresponding project entry becomes active.
     /// Gitignored entries are never auto revealed.

crates/ui/src/components/indent_guides.rs 🔗

@@ -140,13 +140,18 @@ mod uniform_list {
             visible_range: Range<usize>,
             bounds: Bounds<Pixels>,
             item_height: Pixels,
+            item_count: usize,
             cx: &mut WindowContext,
         ) -> AnyElement {
             let mut visible_range = visible_range.clone();
-            visible_range.end += 1;
+            let includes_trailing_indent = visible_range.end < item_count;
+            // Check if we have entries after the visible range,
+            // if so extend the visible range so we can fetch a trailing indent,
+            // which is needed to compute indent guides correctly.
+            if includes_trailing_indent {
+                visible_range.end += 1;
+            }
             let visible_entries = &(self.compute_indents_fn)(visible_range.clone(), cx);
-            // Check if we have an additional indent that is outside of the visible range
-            let includes_trailing_indent = visible_entries.len() == visible_range.len();
             let indent_guides = compute_indent_guides(
                 &visible_entries,
                 visible_range.start,
@@ -198,8 +203,12 @@ mod uniform_list {
         on_hovered_indent_guide_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>>,
     }
 
-    struct IndentGuidesElementPrepaintState {
-        hitboxes: SmallVec<[Hitbox; 12]>,
+    enum IndentGuidesElementPrepaintState {
+        Static,
+        Interactive {
+            hitboxes: Rc<SmallVec<[Hitbox; 12]>>,
+            on_hovered_indent_guide_click: Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>,
+        },
     }
 
     impl Element for IndentGuidesElement {
@@ -225,11 +234,21 @@ mod uniform_list {
             _request_layout: &mut Self::RequestLayoutState,
             cx: &mut WindowContext,
         ) -> Self::PrepaintState {
-            let mut hitboxes = SmallVec::new();
-            for guide in self.indent_guides.as_ref().iter() {
-                hitboxes.push(cx.insert_hitbox(guide.hitbox.unwrap_or(guide.bounds), false));
+            if let Some(on_hovered_indent_guide_click) = self.on_hovered_indent_guide_click.clone()
+            {
+                let hitboxes = self
+                    .indent_guides
+                    .as_ref()
+                    .iter()
+                    .map(|guide| cx.insert_hitbox(guide.hitbox.unwrap_or(guide.bounds), false))
+                    .collect();
+                Self::PrepaintState::Interactive {
+                    hitboxes: Rc::new(hitboxes),
+                    on_hovered_indent_guide_click,
+                }
+            } else {
+                Self::PrepaintState::Static
             }
-            Self::PrepaintState { hitboxes }
         }
 
         fn paint(
@@ -240,81 +259,96 @@ mod uniform_list {
             prepaint: &mut Self::PrepaintState,
             cx: &mut WindowContext,
         ) {
-            let callback = self.on_hovered_indent_guide_click.clone();
-            if let Some(callback) = callback {
-                cx.on_mouse_event({
-                    let hitboxes = prepaint.hitboxes.clone();
-                    let indent_guides = self.indent_guides.clone();
-                    move |event: &MouseDownEvent, phase, cx| {
-                        if phase == DispatchPhase::Bubble && event.button == MouseButton::Left {
-                            let mut active_hitbox_ix = None;
-                            for (i, hitbox) in hitboxes.iter().enumerate() {
-                                if hitbox.is_hovered(cx) {
-                                    active_hitbox_ix = Some(i);
-                                    break;
+            match prepaint {
+                IndentGuidesElementPrepaintState::Static => {
+                    for indent_guide in self.indent_guides.as_ref() {
+                        let fill_color = if indent_guide.is_active {
+                            self.colors.active
+                        } else {
+                            self.colors.default
+                        };
+
+                        cx.paint_quad(fill(indent_guide.bounds, fill_color));
+                    }
+                }
+                IndentGuidesElementPrepaintState::Interactive {
+                    hitboxes,
+                    on_hovered_indent_guide_click,
+                } => {
+                    cx.on_mouse_event({
+                        let hitboxes = hitboxes.clone();
+                        let indent_guides = self.indent_guides.clone();
+                        let on_hovered_indent_guide_click = on_hovered_indent_guide_click.clone();
+                        move |event: &MouseDownEvent, phase, cx| {
+                            if phase == DispatchPhase::Bubble && event.button == MouseButton::Left {
+                                let mut active_hitbox_ix = None;
+                                for (i, hitbox) in hitboxes.iter().enumerate() {
+                                    if hitbox.is_hovered(cx) {
+                                        active_hitbox_ix = Some(i);
+                                        break;
+                                    }
                                 }
-                            }
 
-                            let Some(active_hitbox_ix) = active_hitbox_ix else {
-                                return;
-                            };
+                                let Some(active_hitbox_ix) = active_hitbox_ix else {
+                                    return;
+                                };
 
-                            let active_indent_guide = &indent_guides[active_hitbox_ix].layout;
-                            callback(active_indent_guide, cx);
+                                let active_indent_guide = &indent_guides[active_hitbox_ix].layout;
+                                on_hovered_indent_guide_click(active_indent_guide, cx);
 
-                            cx.stop_propagation();
-                            cx.prevent_default();
+                                cx.stop_propagation();
+                                cx.prevent_default();
+                            }
                         }
-                    }
-                });
-            }
-
-            let mut hovered_hitbox_id = None;
-            for (i, hitbox) in prepaint.hitboxes.iter().enumerate() {
-                cx.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox);
-                let indent_guide = &self.indent_guides[i];
-                let fill_color = if hitbox.is_hovered(cx) {
-                    hovered_hitbox_id = Some(hitbox.id);
-                    self.colors.hover
-                } else if indent_guide.is_active {
-                    self.colors.active
-                } else {
-                    self.colors.default
-                };
-
-                cx.paint_quad(fill(indent_guide.bounds, fill_color));
-            }
-
-            cx.on_mouse_event({
-                let prev_hovered_hitbox_id = hovered_hitbox_id;
-                let hitboxes = prepaint.hitboxes.clone();
-                move |_: &MouseMoveEvent, phase, cx| {
+                    });
                     let mut hovered_hitbox_id = None;
-                    for hitbox in &hitboxes {
-                        if hitbox.is_hovered(cx) {
+                    for (i, hitbox) in hitboxes.iter().enumerate() {
+                        cx.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox);
+                        let indent_guide = &self.indent_guides[i];
+                        let fill_color = if hitbox.is_hovered(cx) {
                             hovered_hitbox_id = Some(hitbox.id);
-                            break;
-                        }
+                            self.colors.hover
+                        } else if indent_guide.is_active {
+                            self.colors.active
+                        } else {
+                            self.colors.default
+                        };
+
+                        cx.paint_quad(fill(indent_guide.bounds, fill_color));
                     }
-                    if phase == DispatchPhase::Capture {
-                        // If the hovered hitbox has changed, we need to re-paint the indent guides.
-                        match (prev_hovered_hitbox_id, hovered_hitbox_id) {
-                            (Some(prev_id), Some(id)) => {
-                                if prev_id != id {
-                                    cx.refresh();
+
+                    cx.on_mouse_event({
+                        let prev_hovered_hitbox_id = hovered_hitbox_id;
+                        let hitboxes = hitboxes.clone();
+                        move |_: &MouseMoveEvent, phase, cx| {
+                            let mut hovered_hitbox_id = None;
+                            for hitbox in hitboxes.as_ref() {
+                                if hitbox.is_hovered(cx) {
+                                    hovered_hitbox_id = Some(hitbox.id);
+                                    break;
                                 }
                             }
-                            (None, Some(_)) => {
-                                cx.refresh();
-                            }
-                            (Some(_), None) => {
-                                cx.refresh();
+                            if phase == DispatchPhase::Capture {
+                                // If the hovered hitbox has changed, we need to re-paint the indent guides.
+                                match (prev_hovered_hitbox_id, hovered_hitbox_id) {
+                                    (Some(prev_id), Some(id)) => {
+                                        if prev_id != id {
+                                            cx.refresh();
+                                        }
+                                    }
+                                    (None, Some(_)) => {
+                                        cx.refresh();
+                                    }
+                                    (Some(_), None) => {
+                                        cx.refresh();
+                                    }
+                                    (None, None) => {}
+                                }
                             }
-                            (None, None) => {}
                         }
-                    }
+                    });
                 }
-            });
+            }
         }
     }
 

docs/src/configuring-zed.md 🔗

@@ -2237,6 +2237,7 @@ Run the `theme selector: toggle` action in the command palette to see a current
   "folder_icons": true,
   "git_status": true,
   "indent_size": 20,
+  "indent_guides": true,
   "auto_reveal_entries": true,
   "auto_fold_dirs": true,
 }