Fix Auto folded dirs performance issues (#8556)

Yury Abykhodau and Mikayla created

Fixed auto folded dirs which caused significant performance issues #8476
(#7674)

Moved from iterating over snapshot entries to use `child_entries`
function from `worktree.rs` by making it public

@maxbrunsfeld 

Release Notes:

- Fixed a bug where project panel settings changes would not be applied
immediately.
- Added a `project_panel.auto_fold_dirs` setting which collapses the
nesting in the project panel when there is a chain of folders containing
a single folder.
<img width="288" alt="Screenshot 2024-04-12 at 11 10 58β€―AM"
src="https://github.com/zed-industries/zed/assets/2280405/efd61e75-026c-464d-ba4d-90db5f68bad3">

---------

Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

assets/settings/default.json                       |   5 
crates/project_panel/src/project_panel.rs          | 318 +++++++++++++++
crates/project_panel/src/project_panel_settings.rs |  10 
crates/worktree/src/worktree.rs                    |   4 
4 files changed, 324 insertions(+), 13 deletions(-)

Detailed changes

assets/settings/default.json πŸ”—

@@ -214,7 +214,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 a directory has only one directory inside.
+    "auto_fold_dirs": false
   },
   "collaboration_panel": {
     // Whether to show the collaboration panel button in the status bar.

crates/project_panel/src/project_panel.rs πŸ”—

@@ -1,6 +1,6 @@
 mod project_panel_settings;
 use client::{ErrorCode, ErrorExt};
-use settings::Settings;
+use settings::{Settings, SettingsStore};
 
 use db::kvp::KEY_VALUE_STORE;
 use editor::{actions::Cancel, items::entry_git_aware_label_color, scroll::Autoscroll, Editor};
@@ -24,6 +24,7 @@ use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
 use serde::{Deserialize, Serialize};
 use std::{
     cmp::Ordering,
+    collections::HashSet,
     ffi::OsStr,
     ops::Range,
     path::{Path, PathBuf},
@@ -50,6 +51,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>,
@@ -133,6 +135,8 @@ actions!(
         OpenPermanent,
         ToggleFocus,
         NewSearchInDirectory,
+        UnfoldDirectory,
+        FoldDirectory,
     ]
 );
 
@@ -235,6 +239,16 @@ impl ProjectPanel {
             })
             .detach();
 
+            let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
+            cx.observe_global::<SettingsStore>(move |_, cx| {
+                let new_settings = *ProjectPanelSettings::get_global(cx);
+                if project_panel_settings != new_settings {
+                    project_panel_settings = new_settings;
+                    cx.notify();
+                }
+            })
+            .detach();
+
             let mut this = Self {
                 project: project.clone(),
                 fs: workspace.app_state().fs.clone(),
@@ -243,6 +257,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,
@@ -403,8 +418,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 = auto_fold_dirs && self.is_foldable(entry, worktree);
+            let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
             let worktree_id = worktree.id();
             let is_local = project.is_local();
             let is_read_only = project.is_read_only();
@@ -430,6 +448,12 @@ impl ProjectPanel {
                                 menu.separator()
                                     .action("Find in Folder…", 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("Cut", Box::new(Cut))
                             .action("Copy", Box::new(Copy))
@@ -482,6 +506,37 @@ 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 snapshot = worktree.snapshot();
+            let mut child_entries = snapshot.child_entries(&parent_path);
+            if let Some(child) = child_entries.next() {
+                if child_entries.next().is_none() {
+                    return child.kind.is_dir();
+                }
+            }
+        };
+        false
+    }
+
+    fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
+        if entry.is_dir() {
+            let snapshot = worktree.snapshot();
+
+            let mut child_entries = snapshot.child_entries(&entry.path);
+            if let Some(child) = child_entries.next() {
+                if child_entries.next().is_none() {
+                    return child.kind.is_dir();
+                }
+            }
+        }
+        false
+    }
+
     fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
         if let Some((worktree, entry)) = self.selected_entry(cx) {
             if entry.is_dir() {
@@ -859,6 +914,59 @@ 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 snapshot = worktree.snapshot();
+            let mut parent_path = entry.path.parent();
+            while let Some(path) = parent_path {
+                if let Some(parent_entry) = worktree.entry_for_path(path) {
+                    let mut children_iter = snapshot.child_entries(path);
+
+                    if children_iter.by_ref().take(2).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 snapshot = worktree.snapshot();
+            let mut path = &*entry.path;
+            loop {
+                let mut child_entries_iter = snapshot.child_entries(path);
+                if let Some(child) = child_entries_iter.next() {
+                    if child_entries_iter.next().is_none() && child.is_dir() {
+                        self.unfolded_dir_ids.remove(&child.id);
+                        path = &*child.path;
+                    } else {
+                        break;
+                    }
+                } else {
+                    break;
+                }
+            }
+
+            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, _) =
@@ -1153,6 +1261,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)
@@ -1194,8 +1303,25 @@ 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)
+                {
+                    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;
+                            }
+                        }
+                    }
+                }
+
                 visible_worktree_entries.push(entry.clone());
                 if Some(entry.id) == new_entry_parent_id {
                     visible_worktree_entries.push(Entry {
@@ -1367,16 +1493,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,
@@ -1420,6 +1562,45 @@ impl ProjectPanel {
         }
     }
 
+    fn calculate_depth_and_difference(
+        entry: &Entry,
+        visible_worktree_entries: &Vec<Entry>,
+    ) -> (usize, usize) {
+        let visible_worktree_paths: HashSet<Arc<Path>> = visible_worktree_entries
+            .iter()
+            .map(|e| e.path.clone())
+            .collect();
+
+        let (depth, difference) = entry
+            .path
+            .ancestors()
+            .skip(1) // Skip the entry itself
+            .find_map(|ancestor| {
+                if visible_worktree_paths.contains(ancestor) {
+                    let parent_entry = visible_worktree_entries
+                        .iter()
+                        .find(|&e| &*e.path == ancestor)
+                        .unwrap();
+
+                    let entry_path_components_count = entry.path.components().count();
+                    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_paths.contains(*ancestor))
+                        .count();
+                    Some((depth + 1, difference))
+                } else {
+                    None
+                }
+            })
+            .unwrap_or((0, 0));
+
+        (depth, difference)
+    }
+
     fn render_entry(
         &self,
         entry_id: ProjectEntryId,
@@ -1572,6 +1753,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))
@@ -1983,6 +2166,125 @@ 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);
+        cx.update(|cx| {
+            let settings = *ProjectPanelSettings::get_global(cx);
+            ProjectPanelSettings::override_global(
+                ProjectPanelSettings {
+                    auto_fold_dirs: true,
+                    ..settings
+                },
+                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);

crates/project_panel/src/project_panel_settings.rs πŸ”—

@@ -4,14 +4,14 @@ use schemars::JsonSchema;
 use serde_derive::{Deserialize, Serialize};
 use settings::{Settings, SettingsSources};
 
-#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Copy, PartialEq)]
 #[serde(rename_all = "snake_case")]
 pub enum ProjectPanelDockPosition {
     Left,
     Right,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
 pub struct ProjectPanelSettings {
     pub default_width: Pixels,
     pub dock: ProjectPanelDockPosition,
@@ -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: false
+    pub auto_fold_dirs: Option<bool>,
 }
 
 impl Settings for ProjectPanelSettings {

crates/worktree/src/worktree.rs πŸ”—

@@ -2079,7 +2079,7 @@ impl Snapshot {
             .map(|entry| &entry.path)
     }
 
-    fn child_entries<'a>(&'a self, parent_path: &'a Path) -> ChildEntriesIter<'a> {
+    pub fn child_entries<'a>(&'a self, parent_path: &'a Path) -> ChildEntriesIter<'a> {
         let mut cursor = self.entries_by_path.cursor();
         cursor.seek(&TraversalTarget::Path(parent_path), Bias::Right, &());
         let traversal = Traversal {
@@ -4706,7 +4706,7 @@ impl<'a, 'b> SeekTarget<'a, EntrySummary, (TraversalProgress<'a>, GitStatuses)>
     }
 }
 
-struct ChildEntriesIter<'a> {
+pub struct ChildEntriesIter<'a> {
     parent_path: &'a Path,
     traversal: Traversal<'a>,
 }