project_panel: Add `hide_root` when only one folder in the project (#25289)

Angelk90 and Smit Barmase created

Closes #24188

Todo:
- [x] Hide root when only one worktree
- [x] Basic tests
- [x] Docs
- [x] Fix `select_first` + tests
- [x] Fix auto collapse dir + tests
- [x] Fix file / dir creation + tests
- [x] Fix root rename case

| Show root | Hide root |
|--------|--------|
| <img width="272" alt="Screenshot 2025-02-20 alle 22 35 55"
src="https://github.com/user-attachments/assets/361d93c7-e1ad-4419-a5f4-be62c9632807"
/> | <img width="269" alt="Screenshot 2025-02-20 alle 22 36 11"
src="https://github.com/user-attachments/assets/62011f76-a24b-4297-9734-f5c3b9f75760"
/> |
| <img width="275" alt="Screenshot 2025-02-20 alle 22 56 33"
src="https://github.com/user-attachments/assets/77e7e6e6-3dfe-4e88-b4b0-b620cb809d2b"
/> | <img width="267" alt="Screenshot 2025-02-20 alle 22 55 53"
src="https://github.com/user-attachments/assets/fa1099c8-7ed0-45ef-a7cf-aeb54b8283b1"
/> |


Release Notes:

- Added support to hide the root entry of the Project Panel when there’s
only one folder in the project. This can be enabled by setting
`hide_root` to `true` in the `project_panel` config.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>

Change summary

assets/settings/default.json                       |   4 
crates/project_panel/src/project_panel.rs          | 104 ++-
crates/project_panel/src/project_panel_settings.rs |   5 
crates/project_panel/src/project_panel_tests.rs    | 524 ++++++++++++++++
docs/src/configuring-zed.md                        |   3 
5 files changed, 605 insertions(+), 35 deletions(-)

Detailed changes

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

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<Self>) {
-        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)
     }

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<ShowDiagnostics>,
     /// Settings related to indent guides in the project panel.
     pub indent_guides: Option<IndentGuidesSettingsContent>,
+    /// Whether to hide the root entry when only one folder is open in the window.
+    ///
+    /// Default: false
+    pub hide_root: Option<bool>,
 }
 
 impl Settings for ProjectPanelSettings {

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<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
     let path = path.as_ref();
     panel.update(cx, |panel, cx| {

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
   }
 }
 ```