diff --git a/assets/settings/default.json b/assets/settings/default.json index a486b2a50d..4f04a7abdf 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -604,7 +604,9 @@ // 2. Never show indent guides: // "never" "show": "always" - } + }, + // Whether to hide the root entry when only one folder is open in the window. + "hide_root": false }, "outline_panel": { // Whether to show the outline 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 ed27b11e8a..7effb96ac0 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -464,6 +464,9 @@ impl ProjectPanel { if project_panel_settings.hide_gitignore != new_settings.hide_gitignore { this.update_visible_entries(None, cx); } + if project_panel_settings.hide_root != new_settings.hide_root { + this.update_visible_entries(None, cx); + } project_panel_settings = new_settings; this.update_diagnostics(cx); cx.notify(); @@ -768,6 +771,12 @@ impl ProjectPanel { let is_remote = project.is_via_collab(); let is_local = project.is_local(); + let settings = ProjectPanelSettings::get_global(cx); + let visible_worktrees_count = project.visible_worktrees(cx).count(); + let should_hide_rename = is_root + && (cfg!(target_os = "windows") + || (settings.hide_root && visible_worktrees_count == 1)); + let context_menu = ContextMenu::build(window, cx, |menu, _, _| { menu.context(self.focus_handle.clone()).map(|menu| { if is_read_only { @@ -817,7 +826,7 @@ impl ProjectPanel { Box::new(zed_actions::workspace::CopyRelativePath), ) .separator() - .when(!is_root || !cfg!(target_os = "windows"), |menu| { + .when(!should_hide_rename, |menu| { menu.action("Rename", Box::new(Rename)) }) .when(!is_root & !is_remote, |menu| { @@ -1538,6 +1547,16 @@ impl ProjectPanel { if Some(entry) == worktree.read(cx).root_entry() { return; } + + if Some(entry) == worktree.read(cx).root_entry() { + let settings = ProjectPanelSettings::get_global(cx); + let visible_worktrees_count = + self.project.read(cx).visible_worktrees(cx).count(); + if settings.hide_root && visible_worktrees_count == 1 { + return; + } + } + self.edit_state = Some(EditState { worktree_id, entry_id: sub_entry_id, @@ -2106,19 +2125,11 @@ impl ProjectPanel { } fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context) { - let worktree = self - .visible_entries - .first() - .and_then(|(worktree_id, _, _)| { - self.project.read(cx).worktree_for_id(*worktree_id, cx) - }); - if let Some(worktree) = worktree { - let worktree = worktree.read(cx); - let worktree_id = worktree.id(); - if let Some(root_entry) = worktree.root_entry() { + if let Some((worktree_id, visible_worktree_entries, _)) = self.visible_entries.first() { + if let Some(entry) = visible_worktree_entries.first() { let selection = SelectedEntry { - worktree_id, - entry_id: root_entry.id, + worktree_id: *worktree_id, + entry_id: entry.id, }; self.selection = Some(selection); if window.modifiers().shift { @@ -2771,6 +2782,31 @@ impl ProjectPanel { Some(()) } + fn create_new_git_entry( + parent_entry: &Entry, + git_summary: GitSummary, + new_entry_kind: EntryKind, + ) -> GitEntry { + GitEntry { + entry: Entry { + id: NEW_ENTRY_ID, + kind: new_entry_kind, + path: parent_entry.path.join("\0").into(), + inode: 0, + mtime: parent_entry.mtime, + size: parent_entry.size, + is_ignored: parent_entry.is_ignored, + is_external: false, + is_private: false, + is_always_included: parent_entry.is_always_included, + canonical_path: parent_entry.canonical_path.clone(), + char_bag: parent_entry.char_bag, + is_fifo: parent_entry.is_fifo, + }, + git_summary, + } + } + fn update_visible_entries( &mut self, new_selected_entry: Option<(WorktreeId, ProjectEntryId)>, @@ -2790,7 +2826,10 @@ impl ProjectPanel { let old_ancestors = std::mem::take(&mut self.ancestors); self.visible_entries.clear(); let mut max_width_item = None; - for worktree in project.visible_worktrees(cx) { + + let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect(); + let hide_root = settings.hide_root && visible_worktrees.len() == 1; + for worktree in visible_worktrees { let worktree_snapshot = worktree.read(cx).snapshot(); let worktree_id = worktree_snapshot.id(); @@ -2825,6 +2864,18 @@ impl ProjectPanel { GitTraversal::new(&repo_snapshots, worktree_snapshot.entries(true, 0)); let mut auto_folded_ancestors = vec![]; while let Some(entry) = entry_iter.entry() { + if hide_root && Some(entry.entry) == worktree.read(cx).root_entry() { + if new_entry_parent_id == Some(entry.id) { + visible_worktree_entries.push(Self::create_new_git_entry( + &entry.entry, + entry.git_summary, + new_entry_kind, + )); + new_entry_parent_id = None; + } + 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) { @@ -2878,24 +2929,11 @@ impl ProjectPanel { false }; if precedes_new_entry && (!hide_gitignore || !entry.is_ignored) { - visible_worktree_entries.push(GitEntry { - entry: Entry { - id: NEW_ENTRY_ID, - kind: new_entry_kind, - path: entry.path.join("\0").into(), - inode: 0, - mtime: entry.mtime, - size: entry.size, - is_ignored: entry.is_ignored, - is_external: false, - is_private: false, - is_always_included: entry.is_always_included, - canonical_path: entry.canonical_path.clone(), - char_bag: entry.char_bag, - is_fifo: entry.is_fifo, - }, - git_summary: entry.git_summary, - }); + visible_worktree_entries.push(Self::create_new_git_entry( + &entry.entry, + entry.git_summary, + new_entry_kind, + )); } let worktree_abs_path = worktree.read(cx).abs_path(); let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() { @@ -3729,7 +3767,7 @@ impl ProjectPanel { None } }) - .unwrap_or((0, 0)); + .unwrap_or_else(|| (0, entry.path.components().count())); (depth, difference) } diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 54b4a4840a..31f4a21b09 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -44,6 +44,7 @@ pub struct ProjectPanelSettings { pub auto_fold_dirs: bool, pub scrollbar: ScrollbarSettings, pub show_diagnostics: ShowDiagnostics, + pub hide_root: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -145,6 +146,10 @@ pub struct ProjectPanelSettingsContent { pub show_diagnostics: Option, /// Settings related to indent guides in the project panel. pub indent_guides: Option, + /// Whether to hide the root entry when only one folder is open in the window. + /// + /// Default: false + pub hide_root: Option, } impl Settings for ProjectPanelSettings { diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 9a1eda72d9..9604755d1e 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -309,6 +309,7 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) { ) .await; + // Test 1: Multiple worktrees with auto_fold_dirs = true let project = Project::test( fs.clone(), [path!("/root1").as_ref(), path!("/root2").as_ref()], @@ -392,6 +393,66 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) { separator!(" file_1.java"), ] ); + + // Test 2: Single worktree with auto_fold_dirs = true and hide_root = true + { + let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, 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, + hide_root: true, + ..settings + }, + cx, + ); + }); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[separator!("> dir_1/nested_dir_1/nested_dir_2/nested_dir_3")], + "Single worktree with hide_root=true should hide root and show auto-folded paths" + ); + + 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), + &[ + separator!("v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected"), + separator!(" > nested_dir_4/nested_dir_5"), + separator!(" file_a.java"), + separator!(" file_b.java"), + separator!(" file_c.java"), + ], + "Expanded auto-folded path with hidden root should show contents without root prefix" + ); + + 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), + &[ + separator!("v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"), + separator!(" v nested_dir_4/nested_dir_5 <== selected"), + separator!(" file_d.java"), + separator!(" file_a.java"), + separator!(" file_b.java"), + separator!(" file_c.java"), + ], + "Nested expansion with hidden root should maintain proper indentation" + ); + } } #[gpui::test(iterations = 30)] @@ -2475,6 +2536,7 @@ async fn test_select_directory(cx: &mut gpui::TestAppContext) { ] ); } + #[gpui::test] async fn test_select_first_last(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); @@ -2543,6 +2605,46 @@ async fn test_select_first_last(cx: &mut gpui::TestAppContext) { " file_2.py <== selected", ] ); + + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "> dir_1", + "> zdir_2", + " file_1.py", + " file_2.py", + ], + "With hide_root=true, root should be hidden" + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_first(&SelectFirst, window, cx) + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "> dir_1 <== selected", + "> zdir_2", + " file_1.py", + " file_2.py", + ], + "With hide_root=true, first entry should be dir_1, not the hidden root" + ); } #[gpui::test] @@ -2789,6 +2891,101 @@ async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + "dir1": { "file1.txt": "content" }, + "file2.txt": "content", + }), + ) + .await; + fs.insert_tree("/root2", json!({ "file3.txt": "content" })) + .await; + + // Test 1: Single worktree, hide_root=true - rename should be blocked + { + let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + panel.update(cx, |panel, cx| { + let project = panel.project.read(cx); + let worktree = project.visible_worktrees(cx).next().unwrap(); + let root_entry = worktree.read(cx).root_entry().unwrap(); + panel.selection = Some(SelectedEntry { + worktree_id: worktree.read(cx).id(), + entry_id: root_entry.id, + }); + }); + + panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); + + assert!( + panel.read_with(cx, |panel, _| panel.edit_state.is_none()), + "Rename should be blocked when hide_root=true with single worktree" + ); + } + + // Test 2: Multiple worktrees, hide_root=true - rename should work + { + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + select_path(&panel, "root1", cx); + panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); + + #[cfg(target_os = "windows")] + assert!( + panel.read_with(cx, |panel, _| panel.edit_state.is_none()), + "Rename should be blocked on Windows even with multiple worktrees" + ); + + #[cfg(not(target_os = "windows"))] + { + assert!( + panel.read_with(cx, |panel, _| panel.edit_state.is_some()), + "Rename should work with multiple worktrees on non-Windows when hide_root=true" + ); + panel.update_in(cx, |panel, window, cx| { + panel.cancel(&menu::Cancel, window, cx) + }); + } + } +} + #[gpui::test] async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); @@ -5098,6 +5295,155 @@ async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + path!("/root"), + json!({ + "existing_dir": { + "existing_file.txt": "", + }, + "existing_file.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + + let panel = workspace + .update(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + .unwrap(); + + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "> existing_dir", + " existing_file.txt", + ], + "Initial state with hide_root=true, root should be hidden and nothing selected" + ); + + panel.update(cx, |panel, _| { + assert!( + panel.selection.is_none(), + "Should have no selection initially" + ); + }); + + // Test 1: Create new file when no entry is selected + panel.update_in(cx, |panel, window, cx| { + panel.new_file(&NewFile, window, cx); + }); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "> existing_dir", + " [EDITOR: ''] <== selected", + " existing_file.txt", + ], + "Editor should appear at root level when hide_root=true and no selection" + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + editor.set_text("new_file_at_root.txt", window, cx) + }); + panel.confirm_edit(window, cx).unwrap() + }); + confirm.await.unwrap(); + + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "> existing_dir", + " existing_file.txt", + " new_file_at_root.txt <== selected <== marked", + ], + "New file should be created at root level and visible without root prefix" + ); + + assert!( + fs.is_file(Path::new("/root/new_file_at_root.txt")).await, + "File should be created in the actual root directory" + ); + + // Test 2: Create new directory when no entry is selected + panel.update(cx, |panel, _| { + panel.selection = None; + }); + + panel.update_in(cx, |panel, window, cx| { + panel.new_directory(&NewDirectory, window, cx); + }); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "> [EDITOR: ''] <== selected", + "> existing_dir", + " existing_file.txt", + " new_file_at_root.txt", + ], + "Directory editor should appear at root level when hide_root=true and no selection" + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + editor.set_text("new_dir_at_root", window, cx) + }); + panel.confirm_edit(window, cx).unwrap() + }); + confirm.await.unwrap(); + + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "> existing_dir", + "v new_dir_at_root <== selected", + " existing_file.txt", + " new_file_at_root.txt", + ], + "New directory should be created at root level and visible without root prefix" + ); + + assert!( + fs.is_dir(Path::new("/root/new_dir_at_root")).await, + "Directory should be created in the actual root directory" + ); +} + #[gpui::test] async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -5297,6 +5643,184 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) }); } +#[gpui::test] +async fn test_hide_root(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + "dir1": { + "file1.txt": "content", + "file2.txt": "content", + }, + "dir2": { + "file3.txt": "content", + }, + "file4.txt": "content", + }), + ) + .await; + + fs.insert_tree( + "/root2", + json!({ + "dir3": { + "file5.txt": "content", + }, + "file6.txt": "content", + }), + ) + .await; + + // Test 1: Single worktree with hide_root = false + { + let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: false, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > dir1", + " > dir2", + " file4.txt", + ], + "With hide_root=false and single worktree, root should be visible" + ); + } + + // Test 2: Single worktree with hide_root = true + { + let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + // Set hide_root to true + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["> dir1", "> dir2", " file4.txt",], + "With hide_root=true and single worktree, root should be hidden" + ); + + // Test expanding directories still works without root + toggle_expand_dir(&panel, "root1/dir1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v dir1 <== selected", + " file1.txt", + " file2.txt", + "> dir2", + " file4.txt", + ], + "Should be able to expand directories even when root is hidden" + ); + } + + // Test 3: Multiple worktrees with hide_root = true + { + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + // Set hide_root to true + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > dir1", + " > dir2", + " file4.txt", + "v root2", + " > dir3", + " file6.txt", + ], + "With hide_root=true and multiple worktrees, roots should still be visible" + ); + } + + // Test 4: Multiple worktrees with hide_root = false + { + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: false, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > dir1", + " > dir2", + " file4.txt", + "v root2", + " > dir3", + " file6.txt", + ], + "With hide_root=false and multiple worktrees, roots should be visible" + ); + } +} + fn select_path(panel: &Entity, path: impl AsRef, cx: &mut VisualTestContext) { let path = path.as_ref(); panel.update(cx, |panel, cx| { diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index e383e31b2d..4587a70ac1 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -3098,7 +3098,8 @@ Run the `theme selector: toggle` action in the command palette to see a current "show_diagnostics": "all", "indent_guides": { "show": "always" - } + }, + "hide_root": false } } ```