Add support of auto folded directories (#7674)

Yury Abykhodau created

Added support of auto collapsed directories, for example when directory
has only one directory inside we should display it as dir1/dir2 (#6935
). Please feel free to propose better solutions, as I am new in Rust

Demo:
https://streamable.com/seo3n9

Release Notes:

- Added support for auto-collapsing directories.

Change summary

assets/settings/default.json                       |   5 
crates/project_panel/src/project_panel.rs          | 358 ++++++++++++++-
crates/project_panel/src/project_panel_settings.rs |   6 
3 files changed, 328 insertions(+), 41 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -187,7 +187,10 @@
     // Whether to reveal it in the project panel automatically,
     // when a corresponding project entry becomes active.
     // Gitignored entries are never auto revealed.
-    "auto_reveal_entries": true
+    "auto_reveal_entries": true,
+    /// Whether to fold directories automatically
+    /// when directory has only one directory inside.
+    "auto_fold_dirs": true
   },
   "collaboration_panel": {
     // Whether to show the collaboration panel button in the status bar.

crates/project_panel/src/project_panel.rs 🔗

@@ -19,11 +19,18 @@ use gpui::{
 use menu::{Confirm, SelectNext, SelectPrev};
 use project::{
     repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
-    Worktree, WorktreeId,
+    Snapshot, Worktree, WorktreeId,
 };
 use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
 use serde::{Deserialize, Serialize};
-use std::{cmp::Ordering, ffi::OsStr, ops::Range, path::Path, sync::Arc};
+use std::{
+    cmp::Ordering,
+    collections::HashSet,
+    ffi::OsStr,
+    ops::Range,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 use theme::ThemeSettings;
 use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem};
 use unicase::UniCase;
@@ -45,6 +52,7 @@ pub struct ProjectPanel {
     visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
     last_worktree_root_id: Option<ProjectEntryId>,
     expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
+    unfolded_dir_ids: HashSet<ProjectEntryId>,
     selection: Option<Selection>,
     context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
     edit_state: Option<EditState>,
@@ -120,6 +128,8 @@ actions!(
         Open,
         ToggleFocus,
         NewSearchInDirectory,
+        UnfoldDirectory,
+        FoldDirectory,
     ]
 );
 
@@ -231,6 +241,7 @@ impl ProjectPanel {
                 visible_entries: Default::default(),
                 last_worktree_root_id: Default::default(),
                 expanded_dir_ids: Default::default(),
+                unfolded_dir_ids: Default::default(),
                 selection: None,
                 edit_state: None,
                 context_menu: None,
@@ -389,8 +400,11 @@ impl ProjectPanel {
         });
 
         if let Some((worktree, entry)) = self.selected_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();
+            let is_foldable = self.is_foldable(entry, worktree) && auto_fold_dirs;
+            let is_unfoldable = self.is_unfoldable(entry, worktree) && auto_fold_dirs;
             let worktree_id = worktree.id();
             let is_local = project.is_local();
             let is_read_only = project.is_read_only();
@@ -441,6 +455,12 @@ impl ProjectPanel {
                             menu.action("Open in Terminal", Box::new(OpenInTerminal))
                                 .action("Search Inside", Box::new(NewSearchInDirectory))
                         })
+                        .when(is_unfoldable, |menu| {
+                            menu.action("Unfold Directory", Box::new(UnfoldDirectory))
+                        })
+                        .when(is_foldable, |menu| {
+                            menu.action("Fold Directory", Box::new(FoldDirectory))
+                        })
                         .separator()
                         .action("Rename", Box::new(Rename))
                         .when(!is_root, |menu| menu.action("Delete", Box::new(Delete)))
@@ -459,6 +479,35 @@ impl ProjectPanel {
         cx.notify();
     }
 
+    fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
+        if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
+            return false;
+        }
+
+        if let Some(parent_path) = entry.path.parent() {
+            let children_count = worktree
+                .entries(false)
+                .filter(|e| e.path.parent() == Some(parent_path))
+                .count();
+
+            return children_count <= 1;
+        };
+        false
+    }
+
+    fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
+        if !entry.is_dir() {
+            return false;
+        }
+
+        let children_count: Vec<&Entry> = worktree // children count for unfolded dirs
+            .entries(true)
+            .filter(|e| e.path.parent() == Some(&entry.path))
+            .collect();
+
+        children_count.len() <= 1 && (children_count.is_empty() || children_count[0].is_dir())
+    }
+
     fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
         if let Some((worktree, entry)) = self.selected_entry(cx) {
             if entry.is_dir() {
@@ -817,6 +866,53 @@ impl ProjectPanel {
         });
     }
 
+    fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
+        if let Some((worktree, entry)) = self.selected_entry(cx) {
+            self.unfolded_dir_ids.insert(entry.id);
+
+            let mut parent_path = entry.path.parent();
+            while let Some(path) = parent_path {
+                if let Some(parent_entry) = worktree.entry_for_path(path) {
+                    let children_count = worktree
+                        .entries(true)
+                        .filter(|e| e.path.parent() == Some(path))
+                        .count();
+
+                    if children_count > 1 {
+                        break;
+                    }
+
+                    self.unfolded_dir_ids.insert(parent_entry.id);
+                    parent_path = path.parent();
+                } else {
+                    break;
+                }
+            }
+
+            self.update_visible_entries(None, cx);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
+
+    fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext<Self>) {
+        if let Some((worktree, entry)) = self.selected_entry(cx) {
+            self.unfolded_dir_ids.remove(&entry.id);
+
+            let children = worktree
+                .entries(true)
+                .filter(|e| e.path.starts_with(&entry.path) && e.path != entry.path);
+
+            for child in children {
+                self.unfolded_dir_ids.remove(&child.id);
+            }
+
+            self.update_visible_entries(None, cx);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
+
     fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
         if let Some(selection) = self.selection {
             let (mut worktree_ix, mut entry_ix, _) =
@@ -1100,6 +1196,7 @@ impl ProjectPanel {
         new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
         cx: &mut ViewContext<Self>,
     ) {
+        let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
         let project = self.project.read(cx);
         self.last_worktree_root_id = project
             .visible_worktrees(cx)
@@ -1141,8 +1238,19 @@ impl ProjectPanel {
 
             let mut visible_worktree_entries = Vec::new();
             let mut entry_iter = snapshot.entries(true);
-
             while let Some(entry) = entry_iter.entry() {
+                if auto_collapse_dirs
+                    && entry.kind.is_dir()
+                    && !self.unfolded_dir_ids.contains(&entry.id)
+                {
+                    let is_omitted = ProjectPanel::should_omit_entry(snapshot.clone(), entry);
+
+                    if is_omitted {
+                        entry_iter.advance();
+                        continue;
+                    }
+                }
+
                 visible_worktree_entries.push(entry.clone());
                 if Some(entry.id) == new_entry_parent_id {
                     visible_worktree_entries.push(Entry {
@@ -1205,6 +1313,22 @@ impl ProjectPanel {
         }
     }
 
+    fn should_omit_entry(snapshot: Snapshot, entry: &Entry) -> bool {
+        if let Some(root_path) = snapshot.root_entry() {
+            if entry.path == root_path.path {
+                return false;
+            }
+        }
+
+        let children: Vec<&Entry> = snapshot
+            .entries(true)
+            .into_iter()
+            .filter(|e| e.path.parent() == Some(&entry.path))
+            .collect();
+
+        children.len() == 1 && children[0].kind.is_dir()
+    }
+
     fn expand_entry(
         &mut self,
         worktree_id: WorktreeId,
@@ -1294,16 +1418,32 @@ impl ProjectPanel {
                         }
                     };
 
-                    let mut details = EntryDetails {
-                        filename: entry
+                    let (depth, difference) = ProjectPanel::calculate_depth_and_difference(
+                        entry,
+                        visible_worktree_entries,
+                    );
+
+                    let filename = match difference {
+                        diff if diff > 1 => entry
                             .path
-                            .file_name()
-                            .unwrap_or(root_name)
-                            .to_string_lossy()
+                            .iter()
+                            .skip(entry.path.components().count() - diff)
+                            .collect::<PathBuf>()
+                            .to_str()
+                            .unwrap_or_default()
                             .to_string(),
+                        _ => entry
+                            .path
+                            .file_name()
+                            .map(|name| name.to_string_lossy().into_owned())
+                            .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
+                    };
+
+                    let mut details = EntryDetails {
+                        filename,
                         icon,
                         path: entry.path.clone(),
-                        depth: entry.path.components().count(),
+                        depth,
                         kind: entry.kind,
                         is_ignored: entry.is_ignored,
                         is_expanded,
@@ -1347,6 +1487,40 @@ impl ProjectPanel {
         }
     }
 
+    fn calculate_depth_and_difference(
+        entry: &Entry,
+        visible_worktree_entries: &Vec<Entry>,
+    ) -> (usize, usize) {
+        let entry_path_components_count = entry.path.components().count();
+        let (depth, difference) = entry
+            .path
+            .ancestors()
+            .skip(1) // Skip the entry itself
+            .find_map(|ancestor| {
+                visible_worktree_entries
+                    .iter()
+                    .find(|&e| *e.path == *ancestor)
+                    .map(|parent_entry| {
+                        let parent_path_components_count = parent_entry.path.components().count();
+                        let difference = entry_path_components_count - parent_path_components_count;
+                        let depth = parent_entry
+                            .path
+                            .ancestors()
+                            .skip(1)
+                            .filter(|ancestor| {
+                                visible_worktree_entries
+                                    .iter()
+                                    .any(|e| *e.path == **ancestor)
+                            })
+                            .count();
+                        (depth + 1, difference)
+                    })
+            })
+            .unwrap_or((0, 0));
+
+        (depth, difference)
+    }
+
     fn render_entry(
         &self,
         entry_id: ProjectEntryId,
@@ -1503,6 +1677,8 @@ impl Render for ProjectPanel {
                 .on_action(cx.listener(Self::copy_path))
                 .on_action(cx.listener(Self::copy_relative_path))
                 .on_action(cx.listener(Self::new_search_in_directory))
+                .on_action(cx.listener(Self::unfold_directory))
+                .on_action(cx.listener(Self::fold_directory))
                 .when(!project.is_read_only(), |el| {
                     el.on_action(cx.listener(Self::new_file))
                         .on_action(cx.listener(Self::new_directory))
@@ -1853,7 +2029,7 @@ mod tests {
             &[
                 "v root1",
                 "    > a",
-                "    > b",
+                "    > b/3",
                 "    > C",
                 "      .dockerignore",
                 "v root2",
@@ -1862,14 +2038,14 @@ mod tests {
             ]
         );
 
-        toggle_expand_dir(&panel, "root1/b", cx);
+        toggle_expand_dir(&panel, "root1/b/3", cx);
         assert_eq!(
             visible_entries_as_strings(&panel, 0..50, cx),
             &[
                 "v root1",
                 "    > a",
-                "    v b  <== selected",
-                "        > 3",
+                "    v b/3  <== selected",
+                "          Q",
                 "    > C",
                 "      .dockerignore",
                 "v root2",
@@ -1884,8 +2060,8 @@ mod tests {
             &[
                 "v root1",
                 "    > a",
-                "    v b",
-                "        > 3",
+                "    v b/3",
+                "          Q",
                 "    > C",
                 "      .dockerignore",
                 "v root2",
@@ -1900,8 +2076,8 @@ mod tests {
             &[
                 "v root1",
                 "    > a",
-                "    v b",
-                "        > 3",
+                "    v b/3",
+                "          Q",
                 "    > C",
                 "      .dockerignore",
                 "v root2",
@@ -1911,6 +2087,115 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor().clone());
+        fs.insert_tree(
+            "/root1",
+            json!({
+                "dir_1": {
+                    "nested_dir_1": {
+                        "nested_dir_2": {
+                            "nested_dir_3": {
+                                "file_a.java": "// File contents",
+                                "file_b.java": "// File contents",
+                                "file_c.java": "// File contents",
+                                "nested_dir_4": {
+                                    "nested_dir_5": {
+                                        "file_d.java": "// File contents",
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }),
+        )
+        .await;
+        fs.insert_tree(
+            "/root2",
+            json!({
+                "dir_2": {
+                    "file_1.java": "// File contents",
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
+        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+        let panel = workspace
+            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
+            .unwrap();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
+                "v root2",
+                "    > dir_2",
+            ]
+        );
+
+        toggle_expand_dir(
+            &panel,
+            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
+            cx,
+        );
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
+                "        > nested_dir_4/nested_dir_5",
+                "          file_a.java",
+                "          file_b.java",
+                "          file_c.java",
+                "v root2",
+                "    > dir_2",
+            ]
+        );
+
+        toggle_expand_dir(
+            &panel,
+            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
+            cx,
+        );
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
+                "        v nested_dir_4/nested_dir_5  <== selected",
+                "              file_d.java",
+                "          file_a.java",
+                "          file_b.java",
+                "          file_c.java",
+                "v root2",
+                "    > dir_2",
+            ]
+        );
+        toggle_expand_dir(&panel, "root2/dir_2", cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
+                "        v nested_dir_4/nested_dir_5",
+                "              file_d.java",
+                "          file_a.java",
+                "          file_b.java",
+                "          file_c.java",
+                "v root2",
+                "    v dir_2  <== selected",
+                "          file_1.java",
+            ]
+        );
+    }
+
     #[gpui::test(iterations = 30)]
     async fn test_editing_files(cx: &mut gpui::TestAppContext) {
         init_test(cx);
@@ -2389,9 +2674,8 @@ mod tests {
                 "    > .git",
                 "    > a",
                 "    > b",
-                "    v bdir1",
-                "        v dir2",
-                "              the-new-filename  <== selected",
+                "    v bdir1/dir2",
+                "          the-new-filename  <== selected",
                 "    > C",
                 "      .dockerignore",
                 "v root2",
@@ -2525,7 +2809,6 @@ mod tests {
             "Directories inside pasted directory should have an entry"
         );
 
-        toggle_expand_dir(&panel, "root/b", cx);
         toggle_expand_dir(&panel, "root/b/a", cx);
         toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
 
@@ -2535,13 +2818,12 @@ mod tests {
                 //
                 "v root",
                 "    > a",
-                "    v b",
-                "        v a",
-                "            v inner_dir  <== selected",
-                "                  four.txt",
-                "                  three.txt",
-                "              one.txt",
-                "              two.txt",
+                "    v b/a",
+                "        v inner_dir  <== selected",
+                "              four.txt",
+                "              three.txt",
+                "          one.txt",
+                "          two.txt",
             ]
         );
 
@@ -2558,13 +2840,12 @@ mod tests {
                 "    > a",
                 "    > a copy",
                 "    > a copy 1",
-                "    v b",
-                "        v a",
-                "            v inner_dir",
-                "                  four.txt",
-                "                  three.txt",
-                "              one.txt",
-                "              two.txt"
+                "    v b/a",
+                "        v inner_dir",
+                "              four.txt",
+                "              three.txt",
+                "          one.txt",
+                "          two.txt"
             ]
         );
     }
@@ -2856,18 +3137,15 @@ mod tests {
 
         panel.update(cx, |panel, cx| panel.open(&Open, cx));
         cx.executor().run_until_parked();
-        select_path(&panel, "project_root/dir_1", cx);
-        panel.update(cx, |panel, cx| panel.open(&Open, cx));
         select_path(&panel, "project_root/dir_1/nested_dir", cx);
         panel.update(cx, |panel, cx| panel.open(&Open, cx));
-        panel.update(cx, |panel, cx| panel.open(&Open, cx));
         cx.executor().run_until_parked();
         assert_eq!(
             visible_entries_as_strings(&panel, 0..10, cx),
             &[
                 "v project_root",
-                "    v dir_1",
-                "        > nested_dir  <== selected",
+                "    v dir_1/nested_dir  <== selected",
+                "          file_a.py",
                 "      file_1.py",
             ]
         );

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -20,6 +20,7 @@ pub struct ProjectPanelSettings {
     pub git_status: bool,
     pub indent_size: f32,
     pub auto_reveal_entries: bool,
+    pub auto_fold_dirs: bool,
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
@@ -54,6 +55,11 @@ pub struct ProjectPanelSettingsContent {
     ///
     /// Default: true
     pub auto_reveal_entries: Option<bool>,
+    /// Whether to fold directories automatically
+    /// when directory has only one directory inside.
+    ///
+    /// Default: true
+    pub auto_fold_dirs: Option<bool>,
 }
 
 impl Settings for ProjectPanelSettings {