@@ -60,11 +60,15 @@ pub struct ProjectPanel {
scroll_handle: UniformListScrollHandle,
focus_handle: FocusHandle,
visible_entries: Vec<(WorktreeId, Vec<Entry>, OnceCell<HashSet<Arc<Path>>>)>,
+ /// Maps from leaf project entry ID to the currently selected ancestor.
+ /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
+ /// project entries (and all non-leaf nodes are guaranteed to be directories).
+ ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
last_worktree_root_id: Option<ProjectEntryId>,
last_external_paths_drag_over_entry: Option<ProjectEntryId>,
expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
unfolded_dir_ids: HashSet<ProjectEntryId>,
- // Currently selected entry in a file tree
+ // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
selection: Option<SelectedEntry>,
marked_entries: BTreeSet<SelectedEntry>,
context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
@@ -96,7 +100,7 @@ enum ClipboardEntry {
}
#[derive(Debug, PartialEq, Eq, Clone)]
-pub struct EntryDetails {
+struct EntryDetails {
filename: String,
icon: Option<SharedString>,
path: Arc<Path>,
@@ -111,18 +115,19 @@ pub struct EntryDetails {
is_cut: bool,
git_status: Option<GitFileStatus>,
is_private: bool,
+ is_auto_folded: bool,
worktree_id: WorktreeId,
canonical_path: Option<Box<Path>>,
}
#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
-pub struct Delete {
+struct Delete {
#[serde(default)]
pub skip_prompt: bool,
}
#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
-pub struct Trash {
+struct Trash {
#[serde(default)]
pub skip_prompt: bool,
}
@@ -155,6 +160,18 @@ actions!(
]
);
+#[derive(Debug, Default)]
+struct FoldedAncestors {
+ current_ancestor_depth: usize,
+ ancestors: Vec<ProjectEntryId>,
+}
+
+impl FoldedAncestors {
+ fn max_ancestor_depth(&self) -> usize {
+ self.ancestors.len()
+ }
+}
+
pub fn init_settings(cx: &mut AppContext) {
ProjectPanelSettings::register(cx);
}
@@ -277,6 +294,7 @@ impl ProjectPanel {
scroll_handle: UniformListScrollHandle::new(),
focus_handle,
visible_entries: Default::default(),
+ ancestors: Default::default(),
last_worktree_root_id: Default::default(),
last_external_paths_drag_over_entry: None,
expanded_dir_ids: Default::default(),
@@ -457,7 +475,7 @@ impl ProjectPanel {
entry_id,
});
- if let Some((worktree, entry)) = self.selected_entry(cx) {
+ if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
let is_root = Some(entry) == worktree.root_entry();
let is_dir = entry.is_dir();
@@ -583,6 +601,13 @@ impl ProjectPanel {
fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
if let Some((worktree, entry)) = self.selected_entry(cx) {
+ if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
+ if folded_ancestors.current_ancestor_depth > 0 {
+ folded_ancestors.current_ancestor_depth -= 1;
+ cx.notify();
+ return;
+ }
+ }
if entry.is_dir() {
let worktree_id = worktree.id();
let entry_id = entry.id;
@@ -611,6 +636,13 @@ 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 < 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) {
@@ -943,6 +975,17 @@ impl ProjectPanel {
}
}
+ fn unflatten_entry_id(&self, leaf_entry_id: ProjectEntryId) -> ProjectEntryId {
+ if let Some(ancestors) = self.ancestors.get(&leaf_entry_id) {
+ ancestors
+ .ancestors
+ .get(ancestors.current_ancestor_depth)
+ .copied()
+ .unwrap_or(leaf_entry_id)
+ } else {
+ leaf_entry_id
+ }
+ }
fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
if let Some(SelectedEntry {
worktree_id,
@@ -950,6 +993,7 @@ impl ProjectPanel {
}) = self.selection
{
if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
+ let entry_id = self.unflatten_entry_id(entry_id);
if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
self.edit_state = Some(EditState {
worktree_id,
@@ -1161,7 +1205,7 @@ impl ProjectPanel {
}
fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
- if let Some((worktree, entry)) = self.selected_entry(cx) {
+ if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
if let Some(parent) = entry.path.parent() {
if let Some(parent_entry) = worktree.entry_for_path(parent) {
self.selection = Some(SelectedEntry {
@@ -1447,13 +1491,13 @@ impl ProjectPanel {
}
fn reveal_in_finder(&mut self, _: &RevealInFileManager, cx: &mut ViewContext<Self>) {
- if let Some((worktree, entry)) = self.selected_entry(cx) {
+ if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
cx.reveal_path(&worktree.abs_path().join(&entry.path));
}
}
fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
- if let Some((worktree, entry)) = self.selected_entry(cx) {
+ if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
let abs_path = worktree.abs_path().join(&entry.path);
let working_directory = if entry.is_dir() {
Some(abs_path)
@@ -1476,7 +1520,7 @@ impl ProjectPanel {
_: &NewSearchInDirectory,
cx: &mut ViewContext<Self>,
) {
- if let Some((worktree, entry)) = self.selected_entry(cx) {
+ if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
if entry.is_dir() {
let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
let dir_path = if include_root {
@@ -1596,15 +1640,36 @@ impl ProjectPanel {
// Returns list of entries that should be affected by an operation.
// When currently selected entry is not marked, it's treated as the only marked entry.
fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
- let Some(selection) = self.selection else {
+ let Some(mut selection) = self.selection else {
return Default::default();
};
if self.marked_entries.contains(&selection) {
- self.marked_entries.clone()
+ self.marked_entries
+ .iter()
+ .copied()
+ .map(|mut entry| {
+ entry.entry_id = self.resolve_entry(entry.entry_id);
+ entry
+ })
+ .collect()
} else {
+ selection.entry_id = self.resolve_entry(selection.entry_id);
BTreeSet::from_iter([selection])
}
}
+
+ fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
+ self.ancestors
+ .get(&id)
+ .and_then(|ancestors| {
+ if ancestors.current_ancestor_depth == 0 {
+ return None;
+ }
+ ancestors.ancestors.get(ancestors.current_ancestor_depth)
+ })
+ .copied()
+ .unwrap_or(id)
+ }
pub fn selected_entry<'a>(
&self,
cx: &'a AppContext,
@@ -1613,6 +1678,21 @@ impl ProjectPanel {
Some((worktree.read(cx), entry))
}
+ /// Compared to selected_entry, this function resolves to the currently
+ /// selected subentry if dir auto-folding is enabled.
+ fn selected_sub_entry<'a>(
+ &self,
+ cx: &'a AppContext,
+ ) -> Option<(&'a Worktree, &'a project::Entry)> {
+ let (worktree, mut entry) = self.selected_entry_handle(cx)?;
+
+ let worktree = worktree.read(cx);
+ let resolved_id = self.resolve_entry(entry.id);
+ if resolved_id != entry.id {
+ entry = worktree.entry_for_id(resolved_id)?;
+ }
+ Some((worktree, entry))
+ }
fn selected_entry_handle<'a>(
&self,
cx: &'a AppContext,
@@ -1655,6 +1735,7 @@ impl ProjectPanel {
.and_then(|worktree| worktree.read(cx).root_entry())
.map(|entry| entry.id);
+ let old_ancestors = std::mem::take(&mut self.ancestors);
self.visible_entries.clear();
for worktree in project.visible_worktrees(cx) {
let snapshot = worktree.read(cx).snapshot();
@@ -1688,25 +1769,42 @@ impl ProjectPanel {
let mut visible_worktree_entries = Vec::new();
let mut entry_iter = snapshot.entries(true, 0);
+ let mut auto_folded_ancestors = vec![];
while let Some(entry) = entry_iter.entry() {
- if auto_collapse_dirs
- && entry.kind.is_dir()
- && !self.unfolded_dir_ids.contains(&entry.id)
- {
- if let Some(root_path) = snapshot.root_entry() {
- let mut child_entries = snapshot.child_entries(&entry.path);
- if let Some(child) = child_entries.next() {
- if entry.path != root_path.path
- && child_entries.next().is_none()
- && child.kind.is_dir()
- {
- entry_iter.advance();
- continue;
+ if auto_collapse_dirs && entry.kind.is_dir() {
+ auto_folded_ancestors.push(entry.id);
+ if !self.unfolded_dir_ids.contains(&entry.id) {
+ if let Some(root_path) = snapshot.root_entry() {
+ let mut child_entries = snapshot.child_entries(&entry.path);
+ if let Some(child) = child_entries.next() {
+ if entry.path != root_path.path
+ && child_entries.next().is_none()
+ && child.kind.is_dir()
+ {
+ entry_iter.advance();
+
+ continue;
+ }
}
}
}
+ let depth = old_ancestors
+ .get(&entry.id)
+ .map(|ancestor| ancestor.current_ancestor_depth)
+ .unwrap_or_default();
+ let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
+ if ancestors.len() > 1 {
+ ancestors.reverse();
+ self.ancestors.insert(
+ entry.id,
+ FoldedAncestors {
+ current_ancestor_depth: depth,
+ ancestors,
+ },
+ );
+ }
}
-
+ auto_folded_ancestors.clear();
visible_worktree_entries.push(entry.clone());
if Some(entry.id) == new_entry_parent_id {
visible_worktree_entries.push(Entry {
@@ -1999,6 +2097,7 @@ impl ProjectPanel {
.map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
git_status: status,
is_private: entry.is_private,
+ is_auto_folded: difference > 1,
worktree_id: *worktree_id,
canonical_path: entry.canonical_path.clone(),
};
@@ -2008,6 +2107,15 @@ impl ProjectPanel {
entry.id == NEW_ENTRY_ID
} else {
entry.id == edit_state.entry_id
+ || self
+ .ancestors
+ .get(&entry.id)
+ .is_some_and(|auto_folded_dirs| {
+ auto_folded_dirs
+ .ancestors
+ .iter()
+ .any(|entry_id| *entry_id == edit_state.entry_id)
+ })
};
if is_edited_entry {
@@ -2102,6 +2210,7 @@ impl ProjectPanel {
active_selection: selection,
marked_selections: selections,
};
+ let is_auto_folded = details.is_auto_folded;
div()
.id(entry_id.to_proto() as usize)
.on_drag_move::<ExternalPaths>(cx.listener(
@@ -2202,11 +2311,78 @@ impl ProjectPanel {
if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
h_flex().h_6().w_full().child(editor.clone())
} else {
- h_flex().h_6().child(
- Label::new(file_name)
- .single_line()
- .color(filename_text_color),
- )
+ h_flex().h_6().map(|this| {
+ if is_auto_folded && is_active {
+ let folded_ancestors = self.ancestors.get(&entry_id).unwrap();
+ let Some(part_to_highlight) = Path::new(&file_name)
+ .ancestors()
+ .nth(folded_ancestors.current_ancestor_depth)
+ else {
+ return this;
+ };
+
+ let suffix = Path::new(&file_name)
+ .strip_prefix(part_to_highlight)
+ .ok()
+ .filter(|suffix| !suffix.as_os_str().is_empty());
+ let prefix = part_to_highlight
+ .parent()
+ .filter(|prefix| !prefix.as_os_str().is_empty());
+ let Some(part_to_highlight) = part_to_highlight
+ .file_name()
+ .and_then(|name| name.to_str().map(String::from))
+ else {
+ return this;
+ };
+
+ this.children(prefix.and_then(|prefix| {
+ Some(
+ h_flex()
+ .child(
+ Label::new(prefix.to_str().map(String::from)?)
+ .single_line()
+ .color(filename_text_color),
+ )
+ .child(
+ Label::new(std::path::MAIN_SEPARATOR_STR)
+ .single_line()
+ .color(filename_text_color),
+ ),
+ )
+ }))
+ .child(
+ Label::new(part_to_highlight)
+ .single_line()
+ .color(filename_text_color)
+ .underline(true),
+ )
+ .children(
+ suffix.and_then(|suffix| {
+ Some(
+ h_flex()
+ .child(
+ Label::new(std::path::MAIN_SEPARATOR_STR)
+ .single_line()
+ .color(filename_text_color),
+ )
+ .child(
+ Label::new(
+ suffix.to_str().map(String::from)?,
+ )
+ .single_line()
+ .color(filename_text_color),
+ ),
+ )
+ }),
+ )
+ } else {
+ this.child(
+ Label::new(file_name)
+ .single_line()
+ .color(filename_text_color),
+ )
+ }
+ })
}
.ml_1(),
)
@@ -2551,7 +2727,7 @@ impl Render for ProjectPanel {
.child(
uniform_list(cx.view().clone(), "entries", items_count, {
|this, range, cx| {
- let mut items = Vec::new();
+ let mut items = Vec::with_capacity(range.end - range.start);
this.for_each_visible_entry(range, cx, |id, details, cx| {
items.push(this.render_entry(id, details, cx));
});
@@ -1,4 +1,4 @@
-use gpui::{relative, AnyElement, FontWeight, StyleRefinement, Styled};
+use gpui::{relative, AnyElement, FontWeight, StyleRefinement, Styled, UnderlineStyle};
use settings::Settings;
use smallvec::SmallVec;
use theme::ThemeSettings;
@@ -42,6 +42,9 @@ pub trait LabelCommon {
/// Sets the italic property of the label.
fn italic(self, italic: bool) -> Self;
+ /// Sets the underline property of the label
+ fn underline(self, underline: bool) -> Self;
+
/// Sets the alpha property of the label, overwriting the alpha value of the color.
fn alpha(self, alpha: f32) -> Self;
}
@@ -57,6 +60,7 @@ pub struct LabelLike {
italic: bool,
children: SmallVec<[AnyElement; 2]>,
alpha: Option<f32>,
+ underline: bool,
}
impl Default for LabelLike {
@@ -77,6 +81,7 @@ impl LabelLike {
italic: false,
children: SmallVec::new(),
alpha: None,
+ underline: false,
}
}
}
@@ -123,6 +128,11 @@ impl LabelCommon for LabelLike {
self
}
+ fn underline(mut self, underline: bool) -> Self {
+ self.underline = underline;
+ self
+ }
+
fn alpha(mut self, alpha: f32) -> Self {
self.alpha = Some(alpha);
self
@@ -165,6 +175,16 @@ impl RenderOnce for LabelLike {
this.line_height(relative(1.))
})
.when(self.italic, |this| this.italic())
+ .when(self.underline, |mut this| {
+ this.text_style()
+ .get_or_insert_with(Default::default)
+ .underline = Some(UnderlineStyle {
+ thickness: px(1.),
+ color: None,
+ wavy: false,
+ });
+ this
+ })
.text_color(color)
.font_weight(self.weight.unwrap_or(settings.ui_font.weight))
.children(self.children)