project panel: Add indent guides (#18260)

Bennet Bo Fenner created

See #12673



https://github.com/user-attachments/assets/94079afc-a851-4206-9c9b-4fad3542334e



TODO:
- [x] Make active indent guides work for autofolded directories
- [x] Figure out which theme colors to use
- [x] Fix horizontal scrolling
- [x] Make indent guides easier to click
- [x] Fix selected background flashing when hovering over entry/indent
guide
- [x] Docs

Release Notes:

- Added indent guides to the project panel

Change summary

Cargo.lock                                         |   1 
assets/settings/default.json                       |   2 
crates/gpui/src/elements/uniform_list.rs           |  58 +
crates/project_panel/Cargo.toml                    |   1 
crates/project_panel/src/project_panel.rs          | 325 +++++++++-
crates/project_panel/src/project_panel_settings.rs |   5 
crates/storybook/src/stories/indent_guides.rs      |  83 ++
crates/theme/src/default_colors.rs                 |   6 
crates/theme/src/fallback_themes.rs                |   3 
crates/theme/src/schema.rs                         |  21 
crates/theme/src/styles/colors.rs                  |   3 
crates/ui/src/components.rs                        |   2 
crates/ui/src/components/indent_guides.rs          | 504 ++++++++++++++++
13 files changed, 974 insertions(+), 40 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8515,6 +8515,7 @@ dependencies = [
  "serde_derive",
  "serde_json",
  "settings",
+ "smallvec",
  "theme",
  "ui",
  "util",

assets/settings/default.json 🔗

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

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

@@ -48,6 +48,7 @@ where
         item_count,
         item_to_measure_index: 0,
         render_items: Box::new(render_range),
+        decorations: Vec::new(),
         interactivity: Interactivity {
             element_id: Some(id),
             base_style: Box::new(base_style),
@@ -69,6 +70,7 @@ pub struct UniformList {
     item_to_measure_index: usize,
     render_items:
         Box<dyn for<'a> Fn(Range<usize>, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>,
+    decorations: Vec<Box<dyn UniformListDecoration>>,
     interactivity: Interactivity,
     scroll_handle: Option<UniformListScrollHandle>,
     sizing_behavior: ListSizingBehavior,
@@ -78,6 +80,7 @@ pub struct UniformList {
 /// Frame state used by the [UniformList].
 pub struct UniformListFrameState {
     items: SmallVec<[AnyElement; 32]>,
+    decorations: SmallVec<[AnyElement; 1]>,
 }
 
 /// A handle for controlling the scroll position of a uniform list.
@@ -185,6 +188,7 @@ impl Element for UniformList {
             layout_id,
             UniformListFrameState {
                 items: SmallVec::new(),
+                decorations: SmallVec::new(),
             },
         )
     }
@@ -292,9 +296,10 @@ impl Element for UniformList {
                         ..cmp::min(last_visible_element_ix, self.item_count);
 
                     let mut items = (self.render_items)(visible_range.clone(), cx);
+
                     let content_mask = ContentMask { bounds };
                     cx.with_content_mask(Some(content_mask), |cx| {
-                        for (mut item, ix) in items.into_iter().zip(visible_range) {
+                        for (mut item, ix) in items.into_iter().zip(visible_range.clone()) {
                             let item_origin = padded_bounds.origin
                                 + point(
                                     if can_scroll_horizontally {
@@ -317,6 +322,34 @@ impl Element for UniformList {
                             item.prepaint_at(item_origin, cx);
                             frame_state.items.push(item);
                         }
+
+                        let bounds = Bounds::new(
+                            padded_bounds.origin
+                                + point(
+                                    if can_scroll_horizontally {
+                                        scroll_offset.x + padding.left
+                                    } else {
+                                        scroll_offset.x
+                                    },
+                                    scroll_offset.y + padding.top,
+                                ),
+                            padded_bounds.size,
+                        );
+                        for decoration in &self.decorations {
+                            let mut decoration = decoration.as_ref().compute(
+                                visible_range.clone(),
+                                bounds,
+                                item_height,
+                                cx,
+                            );
+                            let available_space = size(
+                                AvailableSpace::Definite(bounds.size.width),
+                                AvailableSpace::Definite(bounds.size.height),
+                            );
+                            decoration.layout_as_root(available_space, cx);
+                            decoration.prepaint_at(bounds.origin, cx);
+                            frame_state.decorations.push(decoration);
+                        }
                     });
                 }
 
@@ -338,6 +371,9 @@ impl Element for UniformList {
                 for item in &mut request_layout.items {
                     item.paint(cx);
                 }
+                for decoration in &mut request_layout.decorations {
+                    decoration.paint(cx);
+                }
             })
     }
 }
@@ -350,6 +386,20 @@ impl IntoElement for UniformList {
     }
 }
 
+/// A decoration for a [`UniformList`]. This can be used for various things,
+/// such as rendering indent guides, or other visual effects.
+pub trait UniformListDecoration {
+    /// Compute the decoration element, given the visible range of list items,
+    /// the bounds of the list, and the height of each item.
+    fn compute(
+        &self,
+        visible_range: Range<usize>,
+        bounds: Bounds<Pixels>,
+        item_height: Pixels,
+        cx: &mut WindowContext,
+    ) -> AnyElement;
+}
+
 impl UniformList {
     /// Selects a specific list item for measurement.
     pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
@@ -382,6 +432,12 @@ impl UniformList {
         self
     }
 
+    /// Adds a decoration element to the list.
+    pub fn with_decoration(mut self, decoration: impl UniformListDecoration + 'static) -> Self {
+        self.decorations.push(Box::new(decoration));
+        self
+    }
+
     fn measure_item(&self, list_width: Option<Pixels>, cx: &mut WindowContext) -> Size<Pixels> {
         if self.item_count == 0 {
             return Size::default();

crates/project_panel/Cargo.toml 🔗

@@ -30,6 +30,7 @@ serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true
 settings.workspace = true
+smallvec.workspace = true
 theme.workspace = true
 ui.workspace = true
 util.workspace = true

crates/project_panel/src/project_panel.rs 🔗

@@ -16,12 +16,13 @@ use anyhow::{anyhow, Context as _, Result};
 use collections::{hash_map, BTreeSet, HashMap};
 use git::repository::GitFileStatus;
 use gpui::{
-    actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
-    AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent,
-    EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext,
-    ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton, MouseDownEvent,
-    ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task,
-    UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext,
+    actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action,
+    AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent,
+    Div, DragMoveEvent, EventEmitter, ExternalPaths, FocusHandle, FocusableView,
+    InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model,
+    MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful,
+    Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _,
+    WeakView, WindowContext,
 };
 use indexmap::IndexMap;
 use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
@@ -31,6 +32,7 @@ use project::{
 };
 use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
 use serde::{Deserialize, Serialize};
+use smallvec::SmallVec;
 use std::{
     cell::OnceCell,
     collections::HashSet,
@@ -41,7 +43,10 @@ use std::{
     time::Duration,
 };
 use theme::ThemeSettings;
-use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem, Tooltip};
+use ui::{
+    prelude::*, v_flex, ContextMenu, Icon, IndentGuideColors, IndentGuideLayout, KeyBinding, Label,
+    ListItem, Tooltip,
+};
 use util::{maybe, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
@@ -654,42 +659,52 @@ impl ProjectPanel {
     }
 
     fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
-        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
-            if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
-                if folded_ancestors.current_ancestor_depth + 1
-                    < folded_ancestors.max_ancestor_depth()
-                {
-                    folded_ancestors.current_ancestor_depth += 1;
-                    cx.notify();
-                    return;
-                }
+        let Some((worktree, entry)) = self.selected_entry_handle(cx) else {
+            return;
+        };
+        self.collapse_entry(entry.clone(), worktree, cx)
+    }
+
+    fn collapse_entry(
+        &mut self,
+        entry: Entry,
+        worktree: Model<Worktree>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let worktree = worktree.read(cx);
+        if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
+            if folded_ancestors.current_ancestor_depth + 1 < folded_ancestors.max_ancestor_depth() {
+                folded_ancestors.current_ancestor_depth += 1;
+                cx.notify();
+                return;
             }
-            let worktree_id = worktree.id();
-            let expanded_dir_ids =
-                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
-                    expanded_dir_ids
-                } else {
-                    return;
-                };
+        }
+        let worktree_id = worktree.id();
+        let expanded_dir_ids =
+            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
+                expanded_dir_ids
+            } else {
+                return;
+            };
 
-            loop {
-                let entry_id = entry.id;
-                match expanded_dir_ids.binary_search(&entry_id) {
-                    Ok(ix) => {
-                        expanded_dir_ids.remove(ix);
-                        self.update_visible_entries(Some((worktree_id, entry_id)), cx);
-                        cx.notify();
+        let mut entry = &entry;
+        loop {
+            let entry_id = entry.id;
+            match expanded_dir_ids.binary_search(&entry_id) {
+                Ok(ix) => {
+                    expanded_dir_ids.remove(ix);
+                    self.update_visible_entries(Some((worktree_id, entry_id)), cx);
+                    cx.notify();
+                    break;
+                }
+                Err(_) => {
+                    if let Some(parent_entry) =
+                        entry.path.parent().and_then(|p| worktree.entry_for_path(p))
+                    {
+                        entry = parent_entry;
+                    } else {
                         break;
                     }
-                    Err(_) => {
-                        if let Some(parent_entry) =
-                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
-                        {
-                            entry = parent_entry;
-                        } else {
-                            break;
-                        }
-                    }
                 }
             }
         }
@@ -1727,6 +1742,7 @@ impl ProjectPanel {
             .copied()
             .unwrap_or(id)
     }
+
     pub fn selected_entry<'a>(
         &self,
         cx: &'a AppContext,
@@ -2144,6 +2160,74 @@ impl ProjectPanel {
         }
     }
 
+    fn index_for_entry(
+        &self,
+        entry_id: ProjectEntryId,
+        worktree_id: WorktreeId,
+    ) -> Option<(usize, usize, usize)> {
+        let mut worktree_ix = 0;
+        let mut total_ix = 0;
+        for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
+            if worktree_id != *current_worktree_id {
+                total_ix += visible_worktree_entries.len();
+                worktree_ix += 1;
+                continue;
+            }
+
+            return visible_worktree_entries
+                .iter()
+                .enumerate()
+                .find(|(_, entry)| entry.id == entry_id)
+                .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
+        }
+        None
+    }
+
+    fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, &Entry)> {
+        let mut offset = 0;
+        for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
+            if visible_worktree_entries.len() > offset + index {
+                return visible_worktree_entries
+                    .get(index)
+                    .map(|entry| (*worktree_id, entry));
+            }
+            offset += visible_worktree_entries.len();
+        }
+        None
+    }
+
+    fn iter_visible_entries(
+        &self,
+        range: Range<usize>,
+        cx: &mut ViewContext<ProjectPanel>,
+        mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut ViewContext<ProjectPanel>),
+    ) {
+        let mut ix = 0;
+        for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
+            if ix >= range.end {
+                return;
+            }
+
+            if ix + visible_worktree_entries.len() <= range.start {
+                ix += visible_worktree_entries.len();
+                continue;
+            }
+
+            let end_ix = range.end.min(ix + visible_worktree_entries.len());
+            let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
+            let entries = entries_paths.get_or_init(|| {
+                visible_worktree_entries
+                    .iter()
+                    .map(|e| (e.path.clone()))
+                    .collect()
+            });
+            for entry in visible_worktree_entries[entry_range].iter() {
+                callback(entry, entries, cx);
+            }
+            ix = end_ix;
+        }
+    }
+
     fn for_each_visible_entry(
         &self,
         range: Range<usize>,
@@ -2816,6 +2900,70 @@ impl ProjectPanel {
             cx.notify();
         }
     }
+
+    fn find_active_indent_guide(
+        &self,
+        indent_guides: &[IndentGuideLayout],
+        cx: &AppContext,
+    ) -> Option<usize> {
+        let (worktree, entry) = self.selected_entry(cx)?;
+
+        // Find the parent entry of the indent guide, this will either be the
+        // expanded folder we have selected, or the parent of the currently
+        // selected file/collapsed directory
+        let mut entry = entry;
+        loop {
+            let is_expanded_dir = entry.is_dir()
+                && self
+                    .expanded_dir_ids
+                    .get(&worktree.id())
+                    .map(|ids| ids.binary_search(&entry.id).is_ok())
+                    .unwrap_or(false);
+            if is_expanded_dir {
+                break;
+            }
+            entry = worktree.entry_for_path(&entry.path.parent()?)?;
+        }
+
+        let (active_indent_range, depth) = {
+            let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
+            let child_paths = &self.visible_entries[worktree_ix].1;
+            let mut child_count = 0;
+            let depth = entry.path.ancestors().count();
+            while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
+                if entry.path.ancestors().count() <= depth {
+                    break;
+                }
+                child_count += 1;
+            }
+
+            let start = ix + 1;
+            let end = start + child_count;
+
+            let (_, entries, paths) = &self.visible_entries[worktree_ix];
+            let visible_worktree_entries =
+                paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
+
+            // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
+            let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
+            (start..end, depth)
+        };
+
+        let candidates = indent_guides
+            .iter()
+            .enumerate()
+            .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
+
+        for (i, indent) in candidates {
+            // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
+            if active_indent_range.start <= indent.offset.y + indent.length
+                && indent.offset.y <= active_indent_range.end
+            {
+                return Some(i);
+            }
+        }
+        None
+    }
 }
 
 fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
@@ -2831,6 +2979,8 @@ impl Render for ProjectPanel {
     fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
         let has_worktree = !self.visible_entries.is_empty();
         let project = self.project.read(cx);
+        let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
+        let indent_guides = ProjectPanelSettings::get_global(cx).indent_guides;
         let is_local = project.is_local();
 
         if has_worktree {
@@ -2934,6 +3084,103 @@ impl Render for ProjectPanel {
                             items
                         }
                     })
+                    .when(indent_guides, |list| {
+                        list.with_decoration(
+                            ui::indent_guides(
+                                cx.view().clone(),
+                                px(indent_size),
+                                IndentGuideColors::panel(cx),
+                                |this, range, cx| {
+                                    let mut items =
+                                        SmallVec::with_capacity(range.end - range.start);
+                                    this.iter_visible_entries(range, cx, |entry, entries, _| {
+                                        let (depth, _) =
+                                            Self::calculate_depth_and_difference(entry, entries);
+                                        items.push(depth);
+                                    });
+                                    items
+                                },
+                            )
+                            .on_click(cx.listener(
+                                |this, active_indent_guide: &IndentGuideLayout, cx| {
+                                    if cx.modifiers().secondary() {
+                                        let ix = active_indent_guide.offset.y;
+                                        let Some((target_entry, worktree)) = maybe!({
+                                            let (worktree_id, entry) = this.entry_at_index(ix)?;
+                                            let worktree = this
+                                                .project
+                                                .read(cx)
+                                                .worktree_for_id(worktree_id, cx)?;
+                                            let target_entry = worktree
+                                                .read(cx)
+                                                .entry_for_path(&entry.path.parent()?)?;
+                                            Some((target_entry, worktree))
+                                        }) else {
+                                            return;
+                                        };
+
+                                        this.collapse_entry(target_entry.clone(), worktree, cx);
+                                    }
+                                },
+                            ))
+                            .with_render_fn(
+                                cx.view().clone(),
+                                move |this, params, cx| {
+                                    const LEFT_OFFSET: f32 = 14.;
+                                    const PADDING_Y: f32 = 4.;
+                                    const HITBOX_OVERDRAW: f32 = 3.;
+
+                                    let active_indent_guide_index =
+                                        this.find_active_indent_guide(&params.indent_guides, cx);
+
+                                    let indent_size = params.indent_size;
+                                    let item_height = params.item_height;
+
+                                    params
+                                        .indent_guides
+                                        .into_iter()
+                                        .enumerate()
+                                        .map(|(idx, layout)| {
+                                            let offset = if layout.continues_offscreen {
+                                                px(0.)
+                                            } else {
+                                                px(PADDING_Y)
+                                            };
+                                            let bounds = Bounds::new(
+                                                point(
+                                                    px(layout.offset.x as f32) * indent_size
+                                                        + px(LEFT_OFFSET),
+                                                    px(layout.offset.y as f32) * item_height
+                                                        + offset,
+                                                ),
+                                                size(
+                                                    px(1.),
+                                                    px(layout.length as f32) * item_height
+                                                        - px(offset.0 * 2.),
+                                                ),
+                                            );
+                                            ui::RenderedIndentGuide {
+                                                bounds,
+                                                layout,
+                                                is_active: Some(idx) == active_indent_guide_index,
+                                                hitbox: Some(Bounds::new(
+                                                    point(
+                                                        bounds.origin.x - px(HITBOX_OVERDRAW),
+                                                        bounds.origin.y,
+                                                    ),
+                                                    size(
+                                                        bounds.size.width
+                                                            + px(2. * HITBOX_OVERDRAW),
+                                                        bounds.size.height,
+                                                    ),
+                                                )),
+                                            }
+                                        })
+                                        .collect()
+                                },
+                            ),
+                        )
+                    })
                     .size_full()
                     .with_sizing_behavior(ListSizingBehavior::Infer)
                     .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -20,6 +20,7 @@ pub struct ProjectPanelSettings {
     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,
     pub scrollbar: ScrollbarSettings,
@@ -71,6 +72,10 @@ pub struct ProjectPanelSettingsContent {
     ///
     /// Default: 20
     pub indent_size: Option<f32>,
+    /// Whether to show indent guides in the project panel.
+    ///
+    /// Default: true
+    pub indent_guides: Option<bool>,
     /// Whether to reveal it in the project panel automatically,
     /// when a corresponding project entry becomes active.
     /// Gitignored entries are never auto revealed.

crates/storybook/src/stories/indent_guides.rs 🔗

@@ -0,0 +1,83 @@
+use std::fmt::format;
+
+use gpui::{
+    colors, div, prelude::*, uniform_list, DefaultColor, DefaultThemeAppearance, Hsla, Render,
+    View, ViewContext, WindowContext,
+};
+use story::Story;
+use strum::IntoEnumIterator;
+use ui::{
+    h_flex, px, v_flex, AbsoluteLength, ActiveTheme, Color, DefiniteLength, Label, LabelCommon,
+};
+
+const LENGTH: usize = 100;
+
+pub struct IndentGuidesStory {
+    depths: Vec<usize>,
+}
+
+impl IndentGuidesStory {
+    pub fn view(cx: &mut WindowContext) -> View<Self> {
+        let mut depths = Vec::new();
+        depths.push(0);
+        depths.push(1);
+        depths.push(2);
+        for _ in 0..LENGTH - 6 {
+            depths.push(3);
+        }
+        depths.push(2);
+        depths.push(1);
+        depths.push(0);
+
+        cx.new_view(|_cx| Self { depths })
+    }
+}
+
+impl Render for IndentGuidesStory {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        Story::container()
+            .child(Story::title("Indent guides"))
+            .child(
+                v_flex().size_full().child(
+                    uniform_list(
+                        cx.view().clone(),
+                        "some-list",
+                        self.depths.len(),
+                        |this, range, cx| {
+                            this.depths
+                                .iter()
+                                .enumerate()
+                                .skip(range.start)
+                                .take(range.end - range.start)
+                                .map(|(i, depth)| {
+                                    div()
+                                        .pl(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(
+                                            16. * (*depth as f32),
+                                        ))))
+                                        .child(Label::new(format!("Item {}", i)).color(Color::Info))
+                                })
+                                .collect()
+                        },
+                    )
+                    .with_sizing_behavior(gpui::ListSizingBehavior::Infer)
+                    .with_decoration(ui::indent_guides(
+                        cx.view().clone(),
+                        px(16.),
+                        ui::IndentGuideColors {
+                            default: Color::Info.color(cx),
+                            hovered: Color::Accent.color(cx),
+                            active: Color::Accent.color(cx),
+                        },
+                        |this, range, cx| {
+                            this.depths
+                                .iter()
+                                .skip(range.start)
+                                .take(range.end - range.start)
+                                .cloned()
+                                .collect()
+                        },
+                    )),
+                ),
+            )
+    }
+}

crates/theme/src/default_colors.rs 🔗

@@ -59,6 +59,9 @@ impl ThemeColors {
             search_match_background: neutral().light().step_5(),
             panel_background: neutral().light().step_2(),
             panel_focused_border: blue().light().step_5(),
+            panel_indent_guide: neutral().light_alpha().step_5(),
+            panel_indent_guide_hover: neutral().light_alpha().step_6(),
+            panel_indent_guide_active: neutral().light_alpha().step_6(),
             pane_focused_border: blue().light().step_5(),
             pane_group_border: neutral().light().step_6(),
             scrollbar_thumb_background: neutral().light_alpha().step_3(),
@@ -162,6 +165,9 @@ impl ThemeColors {
             search_match_background: neutral().dark().step_5(),
             panel_background: neutral().dark().step_2(),
             panel_focused_border: blue().dark().step_5(),
+            panel_indent_guide: neutral().dark_alpha().step_4(),
+            panel_indent_guide_hover: neutral().dark_alpha().step_6(),
+            panel_indent_guide_active: neutral().dark_alpha().step_6(),
             pane_focused_border: blue().dark().step_5(),
             pane_group_border: neutral().dark().step_6(),
             scrollbar_thumb_background: neutral().dark_alpha().step_3(),

crates/theme/src/fallback_themes.rs 🔗

@@ -136,6 +136,9 @@ pub(crate) fn zed_default_dark() -> Theme {
                 terminal_ansi_dim_white: crate::neutral().dark().step_10(),
                 panel_background: bg,
                 panel_focused_border: blue,
+                panel_indent_guide: hsla(228. / 360., 8. / 100., 25. / 100., 1.),
+                panel_indent_guide_hover: hsla(225. / 360., 13. / 100., 12. / 100., 1.),
+                panel_indent_guide_active: hsla(225. / 360., 13. / 100., 12. / 100., 1.),
                 pane_focused_border: blue,
                 pane_group_border: hsla(225. / 360., 13. / 100., 12. / 100., 1.),
                 scrollbar_thumb_background: gpui::transparent_black(),

crates/theme/src/schema.rs 🔗

@@ -322,6 +322,15 @@ pub struct ThemeColorsContent {
     #[serde(rename = "panel.focused_border")]
     pub panel_focused_border: Option<String>,
 
+    #[serde(rename = "panel.indent_guide")]
+    pub panel_indent_guide: Option<String>,
+
+    #[serde(rename = "panel.indent_guide_hover")]
+    pub panel_indent_guide_hover: Option<String>,
+
+    #[serde(rename = "panel.indent_guide_active")]
+    pub panel_indent_guide_active: Option<String>,
+
     #[serde(rename = "pane.focused_border")]
     pub pane_focused_border: Option<String>,
 
@@ -710,6 +719,18 @@ impl ThemeColorsContent {
                 .panel_focused_border
                 .as_ref()
                 .and_then(|color| try_parse_color(color).ok()),
+            panel_indent_guide: self
+                .panel_indent_guide
+                .as_ref()
+                .and_then(|color| try_parse_color(color).ok()),
+            panel_indent_guide_hover: self
+                .panel_indent_guide_hover
+                .as_ref()
+                .and_then(|color| try_parse_color(color).ok()),
+            panel_indent_guide_active: self
+                .panel_indent_guide_active
+                .as_ref()
+                .and_then(|color| try_parse_color(color).ok()),
             pane_focused_border: self
                 .pane_focused_border
                 .as_ref()

crates/theme/src/styles/colors.rs 🔗

@@ -123,6 +123,9 @@ pub struct ThemeColors {
     pub search_match_background: Hsla,
     pub panel_background: Hsla,
     pub panel_focused_border: Hsla,
+    pub panel_indent_guide: Hsla,
+    pub panel_indent_guide_hover: Hsla,
+    pub panel_indent_guide_active: Hsla,
     pub pane_focused_border: Hsla,
     pub pane_group_border: Hsla,
     /// The color of the scrollbar thumb.

crates/ui/src/components.rs 🔗

@@ -8,6 +8,7 @@ mod dropdown_menu;
 mod facepile;
 mod icon;
 mod image;
+mod indent_guides;
 mod indicator;
 mod keybinding;
 mod label;
@@ -40,6 +41,7 @@ pub use dropdown_menu::*;
 pub use facepile::*;
 pub use icon::*;
 pub use image::*;
+pub use indent_guides::*;
 pub use indicator::*;
 pub use keybinding::*;
 pub use label::*;

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

@@ -0,0 +1,504 @@
+#![allow(missing_docs)]
+use std::{cmp::Ordering, ops::Range, rc::Rc};
+
+use gpui::{
+    fill, point, size, AnyElement, AppContext, Bounds, Hsla, Point, UniformListDecoration, View,
+};
+use smallvec::SmallVec;
+
+use crate::prelude::*;
+
+/// Represents the colors used for different states of indent guides.
+#[derive(Debug, Clone)]
+pub struct IndentGuideColors {
+    /// The color of the indent guide when it's neither active nor hovered.
+    pub default: Hsla,
+    /// The color of the indent guide when it's hovered.
+    pub hover: Hsla,
+    /// The color of the indent guide when it's active.
+    pub active: Hsla,
+}
+
+impl IndentGuideColors {
+    /// Returns the indent guide colors that should be used for panels.
+    pub fn panel(cx: &AppContext) -> Self {
+        Self {
+            default: cx.theme().colors().panel_indent_guide,
+            hover: cx.theme().colors().panel_indent_guide_hover,
+            active: cx.theme().colors().panel_indent_guide_active,
+        }
+    }
+}
+
+pub struct IndentGuides {
+    colors: IndentGuideColors,
+    indent_size: Pixels,
+    compute_indents_fn: Box<dyn Fn(Range<usize>, &mut WindowContext) -> SmallVec<[usize; 64]>>,
+    render_fn: Option<
+        Box<
+            dyn Fn(
+                RenderIndentGuideParams,
+                &mut WindowContext,
+            ) -> SmallVec<[RenderedIndentGuide; 12]>,
+        >,
+    >,
+    on_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>>,
+}
+
+pub fn indent_guides<V: Render>(
+    view: View<V>,
+    indent_size: Pixels,
+    colors: IndentGuideColors,
+    compute_indents_fn: impl Fn(&mut V, Range<usize>, &mut ViewContext<V>) -> SmallVec<[usize; 64]>
+        + 'static,
+) -> IndentGuides {
+    let compute_indents_fn = Box::new(move |range, cx: &mut WindowContext| {
+        view.update(cx, |this, cx| compute_indents_fn(this, range, cx))
+    });
+    IndentGuides {
+        colors,
+        indent_size,
+        compute_indents_fn,
+        render_fn: None,
+        on_click: None,
+    }
+}
+
+impl IndentGuides {
+    /// Sets the callback that will be called when the user clicks on an indent guide.
+    pub fn on_click(
+        mut self,
+        on_click: impl Fn(&IndentGuideLayout, &mut WindowContext) + 'static,
+    ) -> Self {
+        self.on_click = Some(Rc::new(on_click));
+        self
+    }
+
+    /// Sets a custom callback that will be called when the indent guides need to be rendered.
+    pub fn with_render_fn<V: Render>(
+        mut self,
+        view: View<V>,
+        render_fn: impl Fn(
+                &mut V,
+                RenderIndentGuideParams,
+                &mut WindowContext,
+            ) -> SmallVec<[RenderedIndentGuide; 12]>
+            + 'static,
+    ) -> Self {
+        let render_fn = move |params, cx: &mut WindowContext| {
+            view.update(cx, |this, cx| render_fn(this, params, cx))
+        };
+        self.render_fn = Some(Box::new(render_fn));
+        self
+    }
+}
+
+/// Parameters for rendering indent guides.
+pub struct RenderIndentGuideParams {
+    /// The calculated layouts for the indent guides to be rendered.
+    pub indent_guides: SmallVec<[IndentGuideLayout; 12]>,
+    /// The size of each indentation level in pixels.
+    pub indent_size: Pixels,
+    /// The height of each item in pixels.
+    pub item_height: Pixels,
+}
+
+/// Represents a rendered indent guide with its visual properties and interaction areas.
+pub struct RenderedIndentGuide {
+    /// The bounds of the rendered indent guide in pixels.
+    pub bounds: Bounds<Pixels>,
+    /// The layout information for the indent guide.
+    pub layout: IndentGuideLayout,
+    /// Indicates whether the indent guide is currently active.
+    pub is_active: bool,
+    /// Can be used to customize the hitbox of the indent guide,
+    /// if this is set to `None`, the bounds of the indent guide will be used.
+    pub hitbox: Option<Bounds<Pixels>>,
+}
+
+/// Represents the layout information for an indent guide.
+#[derive(Debug, PartialEq, Eq, Hash)]
+pub struct IndentGuideLayout {
+    /// The starting position of the indent guide, where x is the indentation level
+    /// and y is the starting row.
+    pub offset: Point<usize>,
+    /// The length of the indent guide in rows.
+    pub length: usize,
+    /// Indicates whether the indent guide continues beyond the visible bounds.
+    pub continues_offscreen: bool,
+}
+
+/// Implements the necessary functionality for rendering indent guides inside a uniform list.
+mod uniform_list {
+    use gpui::{DispatchPhase, Hitbox, MouseButton, MouseDownEvent, MouseMoveEvent};
+
+    use super::*;
+
+    impl UniformListDecoration for IndentGuides {
+        fn compute(
+            &self,
+            visible_range: Range<usize>,
+            bounds: Bounds<Pixels>,
+            item_height: Pixels,
+            cx: &mut WindowContext,
+        ) -> AnyElement {
+            let mut visible_range = visible_range.clone();
+            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,
+                includes_trailing_indent,
+            );
+            let mut indent_guides = if let Some(ref custom_render) = self.render_fn {
+                let params = RenderIndentGuideParams {
+                    indent_guides,
+                    indent_size: self.indent_size,
+                    item_height,
+                };
+                custom_render(params, cx)
+            } else {
+                indent_guides
+                    .into_iter()
+                    .map(|layout| RenderedIndentGuide {
+                        bounds: Bounds::new(
+                            point(
+                                px(layout.offset.x as f32) * self.indent_size,
+                                px(layout.offset.y as f32) * item_height,
+                            ),
+                            size(px(1.), px(layout.length as f32) * item_height),
+                        ),
+                        layout,
+                        is_active: false,
+                        hitbox: None,
+                    })
+                    .collect()
+            };
+            for guide in &mut indent_guides {
+                guide.bounds.origin += bounds.origin;
+                if let Some(hitbox) = guide.hitbox.as_mut() {
+                    hitbox.origin += bounds.origin;
+                }
+            }
+
+            let indent_guides = IndentGuidesElement {
+                indent_guides: Rc::new(indent_guides),
+                colors: self.colors.clone(),
+                on_hovered_indent_guide_click: self.on_click.clone(),
+            };
+            indent_guides.into_any_element()
+        }
+    }
+
+    struct IndentGuidesElement {
+        colors: IndentGuideColors,
+        indent_guides: Rc<SmallVec<[RenderedIndentGuide; 12]>>,
+        on_hovered_indent_guide_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>>,
+    }
+
+    struct IndentGuidesElementPrepaintState {
+        hitboxes: SmallVec<[Hitbox; 12]>,
+    }
+
+    impl Element for IndentGuidesElement {
+        type RequestLayoutState = ();
+        type PrepaintState = IndentGuidesElementPrepaintState;
+
+        fn id(&self) -> Option<ElementId> {
+            None
+        }
+
+        fn request_layout(
+            &mut self,
+            _id: Option<&gpui::GlobalElementId>,
+            cx: &mut WindowContext,
+        ) -> (gpui::LayoutId, Self::RequestLayoutState) {
+            (cx.request_layout(gpui::Style::default(), []), ())
+        }
+
+        fn prepaint(
+            &mut self,
+            _id: Option<&gpui::GlobalElementId>,
+            _bounds: Bounds<Pixels>,
+            _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));
+            }
+            Self::PrepaintState { hitboxes }
+        }
+
+        fn paint(
+            &mut self,
+            _id: Option<&gpui::GlobalElementId>,
+            _bounds: Bounds<Pixels>,
+            _request_layout: &mut Self::RequestLayoutState,
+            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;
+                                }
+                            }
+
+                            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);
+
+                            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) {
+                            hovered_hitbox_id = Some(hitbox.id);
+                            break;
+                        }
+                    }
+                    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) => {}
+                        }
+                    }
+                }
+            });
+        }
+    }
+
+    impl IntoElement for IndentGuidesElement {
+        type Element = Self;
+
+        fn into_element(self) -> Self::Element {
+            self
+        }
+    }
+}
+
+fn compute_indent_guides(
+    indents: &[usize],
+    offset: usize,
+    includes_trailing_indent: bool,
+) -> SmallVec<[IndentGuideLayout; 12]> {
+    let mut indent_guides = SmallVec::<[IndentGuideLayout; 12]>::new();
+    let mut indent_stack = SmallVec::<[IndentGuideLayout; 8]>::new();
+
+    let mut min_depth = usize::MAX;
+    for (row, &depth) in indents.iter().enumerate() {
+        if includes_trailing_indent && row == indents.len() - 1 {
+            continue;
+        }
+
+        let current_row = row + offset;
+        let current_depth = indent_stack.len();
+        if depth < min_depth {
+            min_depth = depth;
+        }
+
+        match depth.cmp(&current_depth) {
+            Ordering::Less => {
+                for _ in 0..(current_depth - depth) {
+                    if let Some(guide) = indent_stack.pop() {
+                        indent_guides.push(guide);
+                    }
+                }
+            }
+            Ordering::Greater => {
+                for new_depth in current_depth..depth {
+                    indent_stack.push(IndentGuideLayout {
+                        offset: Point::new(new_depth, current_row),
+                        length: current_row,
+                        continues_offscreen: false,
+                    });
+                }
+            }
+            _ => {}
+        }
+
+        for indent in indent_stack.iter_mut() {
+            indent.length = current_row - indent.offset.y + 1;
+        }
+    }
+
+    indent_guides.extend(indent_stack);
+
+    for guide in indent_guides.iter_mut() {
+        if includes_trailing_indent
+            && guide.offset.y + guide.length == offset + indents.len().saturating_sub(1)
+        {
+            guide.continues_offscreen = indents
+                .last()
+                .map(|last_indent| guide.offset.x < *last_indent)
+                .unwrap_or(false);
+        }
+    }
+
+    indent_guides
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_compute_indent_guides() {
+        fn assert_compute_indent_guides(
+            input: &[usize],
+            offset: usize,
+            includes_trailing_indent: bool,
+            expected: Vec<IndentGuideLayout>,
+        ) {
+            use std::collections::HashSet;
+            assert_eq!(
+                compute_indent_guides(input, offset, includes_trailing_indent)
+                    .into_vec()
+                    .into_iter()
+                    .collect::<HashSet<_>>(),
+                expected.into_iter().collect::<HashSet<_>>(),
+            );
+        }
+
+        assert_compute_indent_guides(
+            &[0, 1, 2, 2, 1, 0],
+            0,
+            false,
+            vec![
+                IndentGuideLayout {
+                    offset: Point::new(0, 1),
+                    length: 4,
+                    continues_offscreen: false,
+                },
+                IndentGuideLayout {
+                    offset: Point::new(1, 2),
+                    length: 2,
+                    continues_offscreen: false,
+                },
+            ],
+        );
+
+        assert_compute_indent_guides(
+            &[2, 2, 2, 1, 1],
+            0,
+            false,
+            vec![
+                IndentGuideLayout {
+                    offset: Point::new(0, 0),
+                    length: 5,
+                    continues_offscreen: false,
+                },
+                IndentGuideLayout {
+                    offset: Point::new(1, 0),
+                    length: 3,
+                    continues_offscreen: false,
+                },
+            ],
+        );
+
+        assert_compute_indent_guides(
+            &[1, 2, 3, 2, 1],
+            0,
+            false,
+            vec![
+                IndentGuideLayout {
+                    offset: Point::new(0, 0),
+                    length: 5,
+                    continues_offscreen: false,
+                },
+                IndentGuideLayout {
+                    offset: Point::new(1, 1),
+                    length: 3,
+                    continues_offscreen: false,
+                },
+                IndentGuideLayout {
+                    offset: Point::new(2, 2),
+                    length: 1,
+                    continues_offscreen: false,
+                },
+            ],
+        );
+
+        assert_compute_indent_guides(
+            &[0, 1, 0],
+            0,
+            true,
+            vec![IndentGuideLayout {
+                offset: Point::new(0, 1),
+                length: 1,
+                continues_offscreen: false,
+            }],
+        );
+
+        assert_compute_indent_guides(
+            &[0, 1, 1],
+            0,
+            true,
+            vec![IndentGuideLayout {
+                offset: Point::new(0, 1),
+                length: 1,
+                continues_offscreen: true,
+            }],
+        );
+        assert_compute_indent_guides(
+            &[0, 1, 2],
+            0,
+            true,
+            vec![IndentGuideLayout {
+                offset: Point::new(0, 1),
+                length: 1,
+                continues_offscreen: true,
+            }],
+        );
+    }
+}