project panel: select autofolded entries (#17520)

Piotr Osiewicz created

Closes #17252

Release Notes:

- Intermediate auto-folded project entries can now be selected and acted
upon (removed, renamed, cut, pasted).

Change summary

crates/project_panel/src/project_panel.rs           | 238 +++++++++++++-
crates/ui/src/components/label/highlighted_label.rs |   5 
crates/ui/src/components/label/label.rs             |   5 
crates/ui/src/components/label/label_like.rs        |  22 +
4 files changed, 238 insertions(+), 32 deletions(-)

Detailed changes

crates/project_panel/src/project_panel.rs 🔗

@@ -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));
                             });

crates/ui/src/components/label/highlighted_label.rs 🔗

@@ -58,6 +58,11 @@ impl LabelCommon for HighlightedLabel {
         self.base = self.base.alpha(alpha);
         self
     }
+
+    fn underline(mut self, underline: bool) -> Self {
+        self.base = self.base.underline(underline);
+        self
+    }
 }
 
 pub fn highlight_ranges(

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

@@ -170,6 +170,11 @@ impl LabelCommon for Label {
         self.base = self.base.alpha(alpha);
         self
     }
+
+    fn underline(mut self, underline: bool) -> Self {
+        self.base = self.base.underline(underline);
+        self
+    }
 }
 
 impl RenderOnce for Label {

crates/ui/src/components/label/label_like.rs 🔗

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