diff --git a/assets/settings/default.json b/assets/settings/default.json index ea2e6ff99ef4b6..3cc556b9f11bec 100644 --- a/assets/settings/default.json +++ b/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. diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 3744a36c00f61e..413d422557f2af 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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)>, last_worktree_root_id: Option, expanded_dir_ids: HashMap>, + unfolded_dir_ids: HashSet, selection: Option, context_menu: Option<(View, Point, Subscription)>, edit_state: Option, @@ -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) { 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) { + 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) { + 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) { 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, ) { + 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::() + .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, + ) -> (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", ] ); diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 5285684891e2b2..275119d755142b 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/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, + /// Whether to fold directories automatically + /// when directory has only one directory inside. + /// + /// Default: true + pub auto_fold_dirs: Option, } impl Settings for ProjectPanelSettings {