project panel: Add indent guides (#18260)

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
This commit is contained in:
Bennet Bo Fenner 2024-10-24 13:07:20 +02:00 committed by GitHub
parent e040b200bc
commit 4214ed927f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 975 additions and 41 deletions

View file

@ -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

View file

@ -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 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 Some((worktree, entry)) = self.selected_entry_handle(cx) else {
return;
};
self.collapse_entry(entry.clone(), worktree, cx)
}
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();
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 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)

View file

@ -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.