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:
parent
e040b200bc
commit
4214ed927f
13 changed files with 975 additions and 41 deletions
|
@ -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
|
||||
|
|
|
@ -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(¶ms.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)
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue