Improve project panel autoreveal ergonomics (#3604)

Kirill Bulatov created

Part of
https://linear.app/zed-industries/issue/Z-1386/add-equivalent-setting-to-vscodes-auto-reveal-exclude
Deals with https://github.com/zed-industries/community/issues/800

* add a `project_panel::auto_reveal_entries` config entry to allow
disabling auto reveal in project panel (auto reveal is enabled by
default)
* add a `pane::RevealInProjectPanel` action (and a pane tab context menu
entry) to manually reveal any file entry
* stop auto revealing gitignored directories at all

We can add the auto reveal exclude globs later, if needed, but let's try
to keep the config simpler and start with a more minimalist approach.

Release Notes:

- Improved project panel auto reveal mechanics: gitignored files are not
auto revealed anymore; a `auto_reveal_entries = true` config option for
`project_panel` is added; a `pane::RevealInProjectPanel` action and a
corresponding buffer tab context menu were added

Change summary

assets/settings/default.json                        |   6 
crates/editor2/src/items.rs                         |   2 
crates/project/src/project.rs                       |   1 
crates/project2/src/project2.rs                     |   1 
crates/project_panel/src/project_panel.rs           | 495 ++++++++++++++
crates/project_panel/src/project_panel_settings.rs  |   2 
crates/project_panel2/src/project_panel.rs          | 495 ++++++++++++++
crates/project_panel2/src/project_panel_settings.rs |   2 
crates/workspace/src/pane.rs                        | 160 ++--
crates/workspace2/src/pane.rs                       |  53 +
crates/zed2/src/zed2.rs                             |   1 
11 files changed, 1,132 insertions(+), 86 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -143,7 +143,11 @@
     // Whether to show the git status in the project panel.
     "git_status": true,
     // Amount of indentation for nested items.
-    "indent_size": 20
+    "indent_size": 20,
+    // 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
   },
   "collaboration_panel": {
     // Whether to show the collaboration panel button in the status bar.

crates/editor2/src/items.rs 🔗

@@ -1189,7 +1189,7 @@ impl CursorPosition {
 impl Render for CursorPosition {
     type Element = Div;
 
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
+    fn render(&mut self, _: &mut ViewContext<Self>) -> Self::Element {
         div().when_some(self.position, |el, position| {
             let mut text = format!(
                 "{}{FILE_ROW_COLUMN_DELIMITER}{}",

crates/project/src/project.rs 🔗

@@ -301,6 +301,7 @@ pub enum Event {
     CollaboratorJoined(proto::PeerId),
     CollaboratorLeft(proto::PeerId),
     RefreshInlayHints,
+    RevealInProjectPanel(ProjectEntryId),
 }
 
 pub enum LanguageServerState {

crates/project2/src/project2.rs 🔗

@@ -299,6 +299,7 @@ pub enum Event {
     CollaboratorJoined(proto::PeerId),
     CollaboratorLeft(proto::PeerId),
     RefreshInlayHints,
+    RevealInProjectPanel(ProjectEntryId),
 }
 
 pub enum LanguageServerState {

crates/project_panel/src/project_panel.rs 🔗

@@ -199,14 +199,14 @@ impl ProjectPanel {
             .detach();
             cx.subscribe(&project, |this, project, event, cx| match event {
                 project::Event::ActiveEntryChanged(Some(entry_id)) => {
-                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
-                    {
-                        this.expand_entry(worktree_id, *entry_id, cx);
-                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
-                        this.autoscroll(cx);
-                        cx.notify();
+                    if settings::get::<ProjectPanelSettings>(cx).auto_reveal_entries {
+                        this.reveal_entry(project, *entry_id, true, cx);
                     }
                 }
+                project::Event::RevealInProjectPanel(entry_id) => {
+                    this.reveal_entry(project, *entry_id, false, cx);
+                    cx.emit(Event::ActivatePanel);
+                }
                 project::Event::ActivateProjectPanel => {
                     cx.emit(Event::ActivatePanel);
                 }
@@ -1531,6 +1531,31 @@ impl ProjectPanel {
         .with_cursor_style(CursorStyle::PointingHand)
         .into_any_named("project panel entry")
     }
+
+    fn reveal_entry(
+        &mut self,
+        project: ModelHandle<Project>,
+        entry_id: ProjectEntryId,
+        skip_ignored: bool,
+        cx: &mut ViewContext<'_, '_, ProjectPanel>,
+    ) {
+        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
+            let worktree = worktree.read(cx);
+            if skip_ignored
+                && worktree
+                    .entry_for_id(entry_id)
+                    .map_or(true, |entry| entry.is_ignored)
+            {
+                return;
+            }
+
+            let worktree_id = worktree.id();
+            self.expand_entry(worktree_id, entry_id, cx);
+            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
 }
 
 impl View for ProjectPanel {
@@ -2956,6 +2981,445 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
+        init_test_with_editor(cx);
+        cx.update(|cx| {
+            cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
+                    project_settings.file_scan_exclusions = Some(Vec::new());
+                });
+                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
+                    project_panel_settings.auto_reveal_entries = Some(false)
+                });
+            })
+        });
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/project_root",
+            json!({
+                ".git": {},
+                ".gitignore": "**/gitignored_dir",
+                "dir_1": {
+                    "file_1.py": "# File 1_1 contents",
+                    "file_2.py": "# File 1_2 contents",
+                    "file_3.py": "# File 1_3 contents",
+                    "gitignored_dir": {
+                        "file_a.py": "# File contents",
+                        "file_b.py": "# File contents",
+                        "file_c.py": "# File contents",
+                    },
+                },
+                "dir_2": {
+                    "file_1.py": "# File 2_1 contents",
+                    "file_2.py": "# File 2_2 contents",
+                    "file_3.py": "# File 2_3 contents",
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
+        let workspace = cx
+            .add_window(|cx| Workspace::test_new(project.clone(), cx))
+            .root(cx);
+        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
+
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    > dir_1",
+                "    > dir_2",
+                "      .gitignore",
+            ]
+        );
+
+        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
+            .expect("dir 1 file is not ignored and should have an entry");
+        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
+            .expect("dir 2 file is not ignored and should have an entry");
+        let gitignored_dir_file =
+            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
+        assert_eq!(
+            gitignored_dir_file, None,
+            "File in the gitignored dir should not have an entry before its dir is toggled"
+        );
+
+        toggle_expand_dir(&panel, "project_root/dir_1", cx);
+        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        v gitignored_dir  <== selected",
+                "              file_a.py",
+                "              file_b.py",
+                "              file_c.py",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "Should show gitignored dir file list in the project panel"
+        );
+        let gitignored_dir_file =
+            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
+                .expect("after gitignored dir got opened, a file entry should be present");
+
+        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
+        toggle_expand_dir(&panel, "project_root/dir_1", cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    > dir_1  <== selected",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "Should hide all dir contents again and prepare for the auto reveal test"
+        );
+
+        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
+            panel.update(cx, |panel, cx| {
+                panel.project.update(cx, |_, cx| {
+                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
+                })
+            });
+            cx.foreground().run_until_parked();
+            assert_eq!(
+                visible_entries_as_strings(&panel, 0..20, cx),
+                &[
+                    "v project_root",
+                    "    > .git",
+                    "    > dir_1  <== selected",
+                    "    > dir_2",
+                    "      .gitignore",
+                ],
+                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
+            );
+        }
+
+        cx.update(|cx| {
+            cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
+                    project_panel_settings.auto_reveal_entries = Some(true)
+                });
+            })
+        });
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
+            })
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        > gitignored_dir",
+                "          file_1.py  <== selected",
+                "          file_2.py",
+                "          file_3.py",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
+            })
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        > gitignored_dir",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    v dir_2",
+                "          file_1.py  <== selected",
+                "          file_2.py",
+                "          file_3.py",
+                "      .gitignore",
+            ],
+            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::ActiveEntryChanged(Some(
+                    gitignored_dir_file,
+                )))
+            })
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        > gitignored_dir",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    v dir_2",
+                "          file_1.py  <== selected",
+                "          file_2.py",
+                "          file_3.py",
+                "      .gitignore",
+            ],
+            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
+            })
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        v gitignored_dir",
+                "              file_a.py  <== selected",
+                "              file_b.py",
+                "              file_c.py",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    v dir_2",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "      .gitignore",
+            ],
+            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
+        init_test_with_editor(cx);
+        cx.update(|cx| {
+            cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
+                    project_settings.file_scan_exclusions = Some(Vec::new());
+                });
+                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
+                    project_panel_settings.auto_reveal_entries = Some(false)
+                });
+            })
+        });
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/project_root",
+            json!({
+                ".git": {},
+                ".gitignore": "**/gitignored_dir",
+                "dir_1": {
+                    "file_1.py": "# File 1_1 contents",
+                    "file_2.py": "# File 1_2 contents",
+                    "file_3.py": "# File 1_3 contents",
+                    "gitignored_dir": {
+                        "file_a.py": "# File contents",
+                        "file_b.py": "# File contents",
+                        "file_c.py": "# File contents",
+                    },
+                },
+                "dir_2": {
+                    "file_1.py": "# File 2_1 contents",
+                    "file_2.py": "# File 2_2 contents",
+                    "file_3.py": "# File 2_3 contents",
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
+        let workspace = cx
+            .add_window(|cx| Workspace::test_new(project.clone(), cx))
+            .root(cx);
+        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
+
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    > dir_1",
+                "    > dir_2",
+                "      .gitignore",
+            ]
+        );
+
+        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
+            .expect("dir 1 file is not ignored and should have an entry");
+        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
+            .expect("dir 2 file is not ignored and should have an entry");
+        let gitignored_dir_file =
+            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
+        assert_eq!(
+            gitignored_dir_file, None,
+            "File in the gitignored dir should not have an entry before its dir is toggled"
+        );
+
+        toggle_expand_dir(&panel, "project_root/dir_1", cx);
+        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        v gitignored_dir  <== selected",
+                "              file_a.py",
+                "              file_b.py",
+                "              file_c.py",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "Should show gitignored dir file list in the project panel"
+        );
+        let gitignored_dir_file =
+            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
+                .expect("after gitignored dir got opened, a file entry should be present");
+
+        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
+        toggle_expand_dir(&panel, "project_root/dir_1", cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    > dir_1  <== selected",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "Should hide all dir contents again and prepare for the explicit reveal test"
+        );
+
+        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
+            panel.update(cx, |panel, cx| {
+                panel.project.update(cx, |_, cx| {
+                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
+                })
+            });
+            cx.foreground().run_until_parked();
+            assert_eq!(
+                visible_entries_as_strings(&panel, 0..20, cx),
+                &[
+                    "v project_root",
+                    "    > .git",
+                    "    > dir_1  <== selected",
+                    "    > dir_2",
+                    "      .gitignore",
+                ],
+                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
+            );
+        }
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
+            })
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        > gitignored_dir",
+                "          file_1.py  <== selected",
+                "          file_2.py",
+                "          file_3.py",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
+            })
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        > gitignored_dir",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    v dir_2",
+                "          file_1.py  <== selected",
+                "          file_2.py",
+                "          file_3.py",
+                "      .gitignore",
+            ],
+            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
+            })
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        v gitignored_dir",
+                "              file_a.py  <== selected",
+                "              file_b.py",
+                "              file_c.py",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    v dir_2",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "      .gitignore",
+            ],
+            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
+        );
+    }
+
     fn toggle_expand_dir(
         panel: &ViewHandle<ProjectPanel>,
         path: impl AsRef<Path>,
@@ -2993,10 +3457,27 @@ mod tests {
                     return;
                 }
             }
-            panic!("no worktree for path {:?}", path);
+            panic!("no worktree for path {path:?}");
         });
     }
 
+    fn find_project_entry(
+        panel: &ViewHandle<ProjectPanel>,
+        path: impl AsRef<Path>,
+        cx: &mut TestAppContext,
+    ) -> Option<ProjectEntryId> {
+        let path = path.as_ref();
+        panel.update(cx, |panel, cx| {
+            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
+                let worktree = worktree.read(cx);
+                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
+                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
+                }
+            }
+            panic!("no worktree for path {path:?}");
+        })
+    }
+
     fn visible_entries_as_strings(
         panel: &ViewHandle<ProjectPanel>,
         range: Range<usize>,

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -18,6 +18,7 @@ pub struct ProjectPanelSettings {
     pub folder_icons: bool,
     pub git_status: bool,
     pub indent_size: f32,
+    pub auto_reveal_entries: bool,
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
@@ -28,6 +29,7 @@ pub struct ProjectPanelSettingsContent {
     pub folder_icons: Option<bool>,
     pub git_status: Option<bool>,
     pub indent_size: Option<f32>,
+    pub auto_reveal_entries: Option<bool>,
 }
 
 impl Setting for ProjectPanelSettings {

crates/project_panel2/src/project_panel.rs 🔗

@@ -184,14 +184,14 @@ impl ProjectPanel {
 
             cx.subscribe(&project, |this, project, event, cx| match event {
                 project::Event::ActiveEntryChanged(Some(entry_id)) => {
-                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
-                    {
-                        this.expand_entry(worktree_id, *entry_id, cx);
-                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
-                        this.autoscroll(cx);
-                        cx.notify();
+                    if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
+                        this.reveal_entry(project, *entry_id, true, cx);
                     }
                 }
+                project::Event::RevealInProjectPanel(entry_id) => {
+                    this.reveal_entry(project, *entry_id, false, cx);
+                    cx.emit(Event::ActivatePanel);
+                }
                 project::Event::ActivateProjectPanel => {
                     cx.emit(Event::ActivatePanel);
                 }
@@ -1456,6 +1456,31 @@ impl ProjectPanel {
         dispatch_context.add(identifier);
         dispatch_context
     }
+
+    fn reveal_entry(
+        &mut self,
+        project: Model<Project>,
+        entry_id: ProjectEntryId,
+        skip_ignored: bool,
+        cx: &mut ViewContext<'_, ProjectPanel>,
+    ) {
+        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
+            let worktree = worktree.read(cx);
+            if skip_ignored
+                && worktree
+                    .entry_for_id(entry_id)
+                    .map_or(true, |entry| entry.is_ignored)
+            {
+                return;
+            }
+
+            let worktree_id = worktree.id();
+            self.expand_entry(worktree_id, entry_id, cx);
+            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
 }
 
 impl Render for ProjectPanel {
@@ -2876,6 +2901,447 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
+        init_test_with_editor(cx);
+        cx.update(|cx| {
+            cx.update_global::<SettingsStore, _>(|store, cx| {
+                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
+                    project_settings.file_scan_exclusions = Some(Vec::new());
+                });
+                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
+                    project_panel_settings.auto_reveal_entries = Some(false)
+                });
+            })
+        });
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            "/project_root",
+            json!({
+                ".git": {},
+                ".gitignore": "**/gitignored_dir",
+                "dir_1": {
+                    "file_1.py": "# File 1_1 contents",
+                    "file_2.py": "# File 1_2 contents",
+                    "file_3.py": "# File 1_3 contents",
+                    "gitignored_dir": {
+                        "file_a.py": "# File contents",
+                        "file_b.py": "# File contents",
+                        "file_c.py": "# File contents",
+                    },
+                },
+                "dir_2": {
+                    "file_1.py": "# File 2_1 contents",
+                    "file_2.py": "# File 2_2 contents",
+                    "file_3.py": "# File 2_3 contents",
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/project_root".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..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    > dir_1",
+                "    > dir_2",
+                "      .gitignore",
+            ]
+        );
+
+        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
+            .expect("dir 1 file is not ignored and should have an entry");
+        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
+            .expect("dir 2 file is not ignored and should have an entry");
+        let gitignored_dir_file =
+            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
+        assert_eq!(
+            gitignored_dir_file, None,
+            "File in the gitignored dir should not have an entry before its dir is toggled"
+        );
+
+        toggle_expand_dir(&panel, "project_root/dir_1", cx);
+        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
+        cx.executor().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        v gitignored_dir  <== selected",
+                "              file_a.py",
+                "              file_b.py",
+                "              file_c.py",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "Should show gitignored dir file list in the project panel"
+        );
+        let gitignored_dir_file =
+            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
+                .expect("after gitignored dir got opened, a file entry should be present");
+
+        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
+        toggle_expand_dir(&panel, "project_root/dir_1", cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    > dir_1  <== selected",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "Should hide all dir contents again and prepare for the auto reveal test"
+        );
+
+        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
+            panel.update(cx, |panel, cx| {
+                panel.project.update(cx, |_, cx| {
+                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
+                })
+            });
+            cx.run_until_parked();
+            assert_eq!(
+                visible_entries_as_strings(&panel, 0..20, cx),
+                &[
+                    "v project_root",
+                    "    > .git",
+                    "    > dir_1  <== selected",
+                    "    > dir_2",
+                    "      .gitignore",
+                ],
+                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
+            );
+        }
+
+        cx.update(|cx| {
+            cx.update_global::<SettingsStore, _>(|store, cx| {
+                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
+                    project_panel_settings.auto_reveal_entries = Some(true)
+                });
+            })
+        });
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
+            })
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        > gitignored_dir",
+                "          file_1.py  <== selected",
+                "          file_2.py",
+                "          file_3.py",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
+            })
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        > gitignored_dir",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    v dir_2",
+                "          file_1.py  <== selected",
+                "          file_2.py",
+                "          file_3.py",
+                "      .gitignore",
+            ],
+            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::ActiveEntryChanged(Some(
+                    gitignored_dir_file,
+                )))
+            })
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        > gitignored_dir",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    v dir_2",
+                "          file_1.py  <== selected",
+                "          file_2.py",
+                "          file_3.py",
+                "      .gitignore",
+            ],
+            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
+            })
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        v gitignored_dir",
+                "              file_a.py  <== selected",
+                "              file_b.py",
+                "              file_c.py",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    v dir_2",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "      .gitignore",
+            ],
+            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
+        init_test_with_editor(cx);
+        cx.update(|cx| {
+            cx.update_global::<SettingsStore, _>(|store, cx| {
+                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
+                    project_settings.file_scan_exclusions = Some(Vec::new());
+                });
+                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
+                    project_panel_settings.auto_reveal_entries = Some(false)
+                });
+            })
+        });
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            "/project_root",
+            json!({
+                ".git": {},
+                ".gitignore": "**/gitignored_dir",
+                "dir_1": {
+                    "file_1.py": "# File 1_1 contents",
+                    "file_2.py": "# File 1_2 contents",
+                    "file_3.py": "# File 1_3 contents",
+                    "gitignored_dir": {
+                        "file_a.py": "# File contents",
+                        "file_b.py": "# File contents",
+                        "file_c.py": "# File contents",
+                    },
+                },
+                "dir_2": {
+                    "file_1.py": "# File 2_1 contents",
+                    "file_2.py": "# File 2_2 contents",
+                    "file_3.py": "# File 2_3 contents",
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/project_root".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..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    > dir_1",
+                "    > dir_2",
+                "      .gitignore",
+            ]
+        );
+
+        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
+            .expect("dir 1 file is not ignored and should have an entry");
+        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
+            .expect("dir 2 file is not ignored and should have an entry");
+        let gitignored_dir_file =
+            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
+        assert_eq!(
+            gitignored_dir_file, None,
+            "File in the gitignored dir should not have an entry before its dir is toggled"
+        );
+
+        toggle_expand_dir(&panel, "project_root/dir_1", cx);
+        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
+        cx.run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        v gitignored_dir  <== selected",
+                "              file_a.py",
+                "              file_b.py",
+                "              file_c.py",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "Should show gitignored dir file list in the project panel"
+        );
+        let gitignored_dir_file =
+            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
+                .expect("after gitignored dir got opened, a file entry should be present");
+
+        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
+        toggle_expand_dir(&panel, "project_root/dir_1", cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    > dir_1  <== selected",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "Should hide all dir contents again and prepare for the explicit reveal test"
+        );
+
+        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
+            panel.update(cx, |panel, cx| {
+                panel.project.update(cx, |_, cx| {
+                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
+                })
+            });
+            cx.run_until_parked();
+            assert_eq!(
+                visible_entries_as_strings(&panel, 0..20, cx),
+                &[
+                    "v project_root",
+                    "    > .git",
+                    "    > dir_1  <== selected",
+                    "    > dir_2",
+                    "      .gitignore",
+                ],
+                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
+            );
+        }
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
+            })
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        > gitignored_dir",
+                "          file_1.py  <== selected",
+                "          file_2.py",
+                "          file_3.py",
+                "    > dir_2",
+                "      .gitignore",
+            ],
+            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
+            })
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        > gitignored_dir",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    v dir_2",
+                "          file_1.py  <== selected",
+                "          file_2.py",
+                "          file_3.py",
+                "      .gitignore",
+            ],
+            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.project.update(cx, |_, cx| {
+                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
+            })
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..20, cx),
+            &[
+                "v project_root",
+                "    > .git",
+                "    v dir_1",
+                "        v gitignored_dir",
+                "              file_a.py  <== selected",
+                "              file_b.py",
+                "              file_c.py",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "    v dir_2",
+                "          file_1.py",
+                "          file_2.py",
+                "          file_3.py",
+                "      .gitignore",
+            ],
+            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
+        );
+    }
+
     fn toggle_expand_dir(
         panel: &View<ProjectPanel>,
         path: impl AsRef<Path>,
@@ -2913,6 +3379,23 @@ mod tests {
         });
     }
 
+    fn find_project_entry(
+        panel: &View<ProjectPanel>,
+        path: impl AsRef<Path>,
+        cx: &mut VisualTestContext,
+    ) -> Option<ProjectEntryId> {
+        let path = path.as_ref();
+        panel.update(cx, |panel, cx| {
+            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
+                let worktree = worktree.read(cx);
+                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
+                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
+                }
+            }
+            panic!("no worktree for path {path:?}");
+        })
+    }
+
     fn visible_entries_as_strings(
         panel: &View<ProjectPanel>,
         range: Range<usize>,

crates/project_panel2/src/project_panel_settings.rs 🔗

@@ -18,6 +18,7 @@ pub struct ProjectPanelSettings {
     pub folder_icons: bool,
     pub git_status: bool,
     pub indent_size: f32,
+    pub auto_reveal_entries: bool,
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
@@ -28,6 +29,7 @@ pub struct ProjectPanelSettingsContent {
     pub folder_icons: Option<bool>,
     pub git_status: Option<bool>,
     pub indent_size: Option<f32>,
+    pub auto_reveal_entries: Option<bool>,
 }
 
 impl Settings for ProjectPanelSettings {

crates/workspace/src/pane.rs 🔗

@@ -96,6 +96,12 @@ pub struct CloseAllItems {
     pub save_intent: Option<SaveIntent>,
 }
 
+#[derive(Clone, PartialEq, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RevealInProjectPanel {
+    pub entry_id: u64,
+}
+
 actions!(
     pane,
     [
@@ -116,7 +122,15 @@ actions!(
     ]
 );
 
-impl_actions!(pane, [ActivateItem, CloseActiveItem, CloseAllItems]);
+impl_actions!(
+    pane,
+    [
+        ActivateItem,
+        CloseActiveItem,
+        CloseAllItems,
+        RevealInProjectPanel,
+    ]
+);
 
 const MAX_NAVIGATION_HISTORY_LEN: usize = 1024;
 
@@ -146,6 +160,13 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(|pane: &mut Pane, _: &SplitUp, cx| pane.split(SplitDirection::Up, cx));
     cx.add_action(|pane: &mut Pane, _: &SplitRight, cx| pane.split(SplitDirection::Right, cx));
     cx.add_action(|pane: &mut Pane, _: &SplitDown, cx| pane.split(SplitDirection::Down, cx));
+    cx.add_action(|pane: &mut Pane, action: &RevealInProjectPanel, cx| {
+        pane.project.update(cx, |_, cx| {
+            cx.emit(project::Event::RevealInProjectPanel(
+                ProjectEntryId::from_proto(action.entry_id),
+            ))
+        })
+    });
 }
 
 #[derive(Debug)]
@@ -1232,80 +1253,87 @@ impl Pane {
         cx: &mut ViewContext<Self>,
     ) {
         let active_item_id = self.items[self.active_item_index].id();
+        let single_entry_to_resolve =
+            self.items()
+                .find(|i| i.id() == target_item_id)
+                .and_then(|i| {
+                    let item_entries = i.project_entry_ids(cx);
+                    if item_entries.len() == 1 {
+                        Some(item_entries[0])
+                    } else {
+                        None
+                    }
+                });
         let is_active_item = target_item_id == active_item_id;
         let target_pane = cx.weak_handle();
 
         // The `CloseInactiveItems` action should really be called "CloseOthers" and the behaviour should be dynamically based on the tab the action is ran on.  Currently, this is a weird action because you can run it on a non-active tab and it will close everything by the actual active tab
-
         self.tab_context_menu.update(cx, |menu, cx| {
-            menu.show(
-                position,
-                AnchorCorner::TopLeft,
-                if is_active_item {
-                    vec![
-                        ContextMenuItem::action(
-                            "Close Active Item",
-                            CloseActiveItem { save_intent: None },
-                        ),
-                        ContextMenuItem::action("Close Inactive Items", CloseInactiveItems),
-                        ContextMenuItem::action("Close Clean Items", CloseCleanItems),
-                        ContextMenuItem::action("Close Items To The Left", CloseItemsToTheLeft),
-                        ContextMenuItem::action("Close Items To The Right", CloseItemsToTheRight),
-                        ContextMenuItem::action(
-                            "Close All Items",
-                            CloseAllItems { save_intent: None },
-                        ),
-                    ]
-                } else {
-                    // In the case of the user right clicking on a non-active tab, for some item-closing commands, we need to provide the id of the tab, for the others, we can reuse the existing command.
-                    vec![
-                        ContextMenuItem::handler("Close Inactive Item", {
-                            let pane = target_pane.clone();
-                            move |cx| {
-                                if let Some(pane) = pane.upgrade(cx) {
-                                    pane.update(cx, |pane, cx| {
-                                        pane.close_item_by_id(
-                                            target_item_id,
-                                            SaveIntent::Close,
-                                            cx,
-                                        )
+            let mut menu_items = if is_active_item {
+                vec![
+                    ContextMenuItem::action(
+                        "Close Active Item",
+                        CloseActiveItem { save_intent: None },
+                    ),
+                    ContextMenuItem::action("Close Inactive Items", CloseInactiveItems),
+                    ContextMenuItem::action("Close Clean Items", CloseCleanItems),
+                    ContextMenuItem::action("Close Items To The Left", CloseItemsToTheLeft),
+                    ContextMenuItem::action("Close Items To The Right", CloseItemsToTheRight),
+                    ContextMenuItem::action("Close All Items", CloseAllItems { save_intent: None }),
+                ]
+            } else {
+                // In the case of the user right clicking on a non-active tab, for some item-closing commands, we need to provide the id of the tab, for the others, we can reuse the existing command.
+                vec![
+                    ContextMenuItem::handler("Close Inactive Item", {
+                        let pane = target_pane.clone();
+                        move |cx| {
+                            if let Some(pane) = pane.upgrade(cx) {
+                                pane.update(cx, |pane, cx| {
+                                    pane.close_item_by_id(target_item_id, SaveIntent::Close, cx)
                                         .detach_and_log_err(cx);
-                                    })
-                                }
+                                })
                             }
-                        }),
-                        ContextMenuItem::action("Close Inactive Items", CloseInactiveItems),
-                        ContextMenuItem::action("Close Clean Items", CloseCleanItems),
-                        ContextMenuItem::handler("Close Items To The Left", {
-                            let pane = target_pane.clone();
-                            move |cx| {
-                                if let Some(pane) = pane.upgrade(cx) {
-                                    pane.update(cx, |pane, cx| {
-                                        pane.close_items_to_the_left_by_id(target_item_id, cx)
-                                            .detach_and_log_err(cx);
-                                    })
-                                }
+                        }
+                    }),
+                    ContextMenuItem::action("Close Inactive Items", CloseInactiveItems),
+                    ContextMenuItem::action("Close Clean Items", CloseCleanItems),
+                    ContextMenuItem::handler("Close Items To The Left", {
+                        let pane = target_pane.clone();
+                        move |cx| {
+                            if let Some(pane) = pane.upgrade(cx) {
+                                pane.update(cx, |pane, cx| {
+                                    pane.close_items_to_the_left_by_id(target_item_id, cx)
+                                        .detach_and_log_err(cx);
+                                })
                             }
-                        }),
-                        ContextMenuItem::handler("Close Items To The Right", {
-                            let pane = target_pane.clone();
-                            move |cx| {
-                                if let Some(pane) = pane.upgrade(cx) {
-                                    pane.update(cx, |pane, cx| {
-                                        pane.close_items_to_the_right_by_id(target_item_id, cx)
-                                            .detach_and_log_err(cx);
-                                    })
-                                }
+                        }
+                    }),
+                    ContextMenuItem::handler("Close Items To The Right", {
+                        let pane = target_pane.clone();
+                        move |cx| {
+                            if let Some(pane) = pane.upgrade(cx) {
+                                pane.update(cx, |pane, cx| {
+                                    pane.close_items_to_the_right_by_id(target_item_id, cx)
+                                        .detach_and_log_err(cx);
+                                })
                             }
-                        }),
-                        ContextMenuItem::action(
-                            "Close All Items",
-                            CloseAllItems { save_intent: None },
-                        ),
-                    ]
-                },
-                cx,
-            );
+                        }
+                    }),
+                    ContextMenuItem::action("Close All Items", CloseAllItems { save_intent: None }),
+                ]
+            };
+
+            if let Some(entry) = single_entry_to_resolve {
+                menu_items.push(ContextMenuItem::Separator);
+                menu_items.push(ContextMenuItem::action(
+                    "Reveal In Project Panel",
+                    RevealInProjectPanel {
+                        entry_id: entry.to_proto(),
+                    },
+                ));
+            }
+
+            menu.show(position, AnchorCorner::TopLeft, menu_items, cx);
         });
     }
 

crates/workspace2/src/pane.rs 🔗

@@ -85,7 +85,21 @@ pub struct CloseAllItems {
     pub save_intent: Option<SaveIntent>,
 }
 
-impl_actions!(pane, [CloseAllItems, CloseActiveItem, ActivateItem]);
+#[derive(Clone, PartialEq, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RevealInProjectPanel {
+    pub entry_id: u64,
+}
+
+impl_actions!(
+    pane,
+    [
+        CloseAllItems,
+        CloseActiveItem,
+        ActivateItem,
+        RevealInProjectPanel
+    ]
+);
 
 actions!(
     pane,
@@ -1499,9 +1513,19 @@ impl Pane {
                 )
                 .child(label);
 
-        right_click_menu(ix).trigger(tab).menu(|cx| {
-            ContextMenu::build(cx, |menu, cx| {
-                menu.action("Close", CloseActiveItem { save_intent: None }.boxed_clone())
+        let single_entry_to_resolve = {
+            let item_entries = self.items[ix].project_entry_ids(cx);
+            if item_entries.len() == 1 {
+                Some(item_entries[0])
+            } else {
+                None
+            }
+        };
+
+        right_click_menu(ix).trigger(tab).menu(move |cx| {
+            ContextMenu::build(cx, |menu, _| {
+                let menu = menu
+                    .action("Close", CloseActiveItem { save_intent: None }.boxed_clone())
                     .action("Close Others", CloseInactiveItems.boxed_clone())
                     .separator()
                     .action("Close Left", CloseItemsToTheLeft.boxed_clone())
@@ -1511,7 +1535,19 @@ impl Pane {
                     .action(
                         "Close All",
                         CloseAllItems { save_intent: None }.boxed_clone(),
+                    );
+
+                if let Some(entry) = single_entry_to_resolve {
+                    menu.separator().action(
+                        "Reveal In Project Panel",
+                        RevealInProjectPanel {
+                            entry_id: entry.to_proto(),
+                        }
+                        .boxed_clone(),
                     )
+                } else {
+                    menu
+                }
             })
         })
     }
@@ -2135,6 +2171,15 @@ impl Render for Pane {
                         .map(|task| task.detach_and_log_err(cx));
                 }),
             )
+            .on_action(
+                cx.listener(|pane: &mut Self, action: &RevealInProjectPanel, cx| {
+                    pane.project.update(cx, |_, cx| {
+                        cx.emit(project::Event::RevealInProjectPanel(
+                            ProjectEntryId::from_proto(action.entry_id),
+                        ))
+                    })
+                }),
+            )
             .child(self.render_tab_bar(cx))
             .child(self.toolbar.clone())
             .child(if let Some(item) = self.active_item() {

crates/zed2/src/zed2.rs 🔗

@@ -130,7 +130,6 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
         let vim_mode_indicator = cx.build_view(|cx| vim::ModeIndicator::new(cx));
         let feedback_button = cx
             .build_view(|_| feedback::deploy_feedback_button::DeployFeedbackButton::new(workspace));
-        //     let cursor_position = cx.add_view(|_| editor::items::CursorPosition::new());
         let cursor_position = cx.build_view(|_| editor::items::CursorPosition::new());
         workspace.status_bar().update(cx, |status_bar, cx| {
             status_bar.add_left_item(diagnostic_summary, cx);