From 8bca9cea26df5decdfd5b4221502942390945f20 Mon Sep 17 00:00:00 2001 From: Yury Abykhodau <88387714+ABckh@users.noreply.github.com> Date: Fri, 12 Apr 2024 21:26:26 +0300 Subject: [PATCH] Fix Auto folded dirs performance issues (#8556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2024-04-12 at 11 10 58 AM --------- Co-authored-by: Mikayla --- assets/settings/default.json | 5 +- crates/project_panel/src/project_panel.rs | 318 +++++++++++++++++- .../src/project_panel_settings.rs | 10 +- crates/worktree/src/worktree.rs | 4 +- 4 files changed, 324 insertions(+), 13 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 6bf04d322927c86aad4b4c0c2494aaa900f7a278..4657057d06a35ce13f797afb1393e0a6ee1cb7bc 100644 --- a/assets/settings/default.json +++ b/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. diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index a201c6cf36f204580c00e96d8e6d74eeb6db119f..6e0aeb870cc08f31ff384cf46e1b20db153c1662 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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)>, last_worktree_root_id: Option, expanded_dir_ids: HashMap>, + unfolded_dir_ids: HashSet, selection: Option, context_menu: Option<(View, Point, Subscription)>, edit_state: Option, @@ -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::(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) { 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) { + 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) { + 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) { 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, ) { + 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::() + .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, + ) -> (usize, usize) { + let visible_worktree_paths: HashSet> = 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); diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index e23d152a8f6772d8ada18e137a674753006735f9..500f33be411c6708c9db3decb5812b3b787c95c0 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/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, + /// Whether to fold directories automatically + /// when directory has only one directory inside. + /// + /// Default: false + pub auto_fold_dirs: Option, } impl Settings for ProjectPanelSettings { diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index a3990ecfc629a5ee48ff533b47721bb9ecfc4d11..72e1b049be9aac7e20328f37e038b3f086baf3cc 100644 --- a/crates/worktree/src/worktree.rs +++ b/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>, }