Fix pane::RevealInProjectPanel to focus/open project panel for non-project buffers (#51246)

Pratik Karki and dino created

Update how `workspace::pane::Pane` handles the `RevealInProjectPanel`
action so as to display a notification when the user attempts to reveal
an unsaved buffer or a file that does not belong to any of the open
projects.

Closes #23967 

Release Notes:

- Update `pane: reveal in project panel` to display a notification when
the user attempts to use it with an unsaved buffer or a file that is not
part of the open projects

---------

Signed-off-by: Pratik Karki <pratik@prertik.com>
Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

crates/project_panel/src/project_panel_tests.rs | 146 ++++++++++++++++++
crates/workspace/src/pane.rs                    |  66 +++++++-
2 files changed, 203 insertions(+), 9 deletions(-)

Detailed changes

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
 use util::{path, paths::PathStyle, rel_path::rel_path};
 use workspace::{
     AppState, ItemHandle, MultiWorkspace, Pane, Workspace,
-    item::{Item, ProjectItem},
+    item::{Item, ProjectItem, test::TestItem},
     register_project_item,
 };
 
@@ -6015,6 +6015,150 @@ async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_reveal_in_project_panel_notifications(cx: &mut gpui::TestAppContext) {
+    init_test_with_editor(cx);
+    let fs = FakeFs::new(cx.background_executor.clone());
+    fs.insert_tree(
+        "/workspace",
+        json!({
+            "README.md": ""
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/workspace".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    // Ensure that, attempting to run `pane: reveal in project panel` without
+    // any active item does nothing, i.e., does not focus the project panel but
+    // it also does not show a notification.
+    cx.dispatch_action(workspace::RevealInProjectPanel::default());
+    cx.run_until_parked();
+
+    panel.update_in(cx, |panel, window, cx| {
+        assert!(
+            !panel.focus_handle(cx).is_focused(window),
+            "Project panel should not be focused after attempting to reveal an invisible worktree entry"
+        );
+
+        panel.workspace.update(cx, |workspace, cx| {
+            assert!(
+                workspace.active_item(cx).is_none(),
+                "Workspace should not have an active item"
+            );
+            assert_eq!(
+                workspace.notification_ids(),
+                vec![],
+                "No notification should be shown when there's no active item"
+            );
+        }).unwrap();
+    });
+
+    // Create a file in a different folder than the one in the project so we can
+    // later open it and ensure that, attempting to reveal it in the project
+    // panel shows a notification and does not focus the project panel.
+    fs.insert_tree(
+        "/external",
+        json!({
+            "file.txt": "External File",
+        }),
+    )
+    .await;
+
+    let (worktree, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/external/file.txt", false, cx)
+        })
+        .await
+        .unwrap();
+
+    workspace
+        .update_in(cx, |workspace, window, cx| {
+            let worktree_id = worktree.read(cx).id();
+            let path = rel_path("").into();
+            let project_path = ProjectPath { worktree_id, path };
+
+            workspace.open_path(project_path, None, true, window, cx)
+        })
+        .await
+        .unwrap();
+    cx.run_until_parked();
+
+    cx.dispatch_action(workspace::RevealInProjectPanel::default());
+    cx.run_until_parked();
+
+    panel.update_in(cx, |panel, window, cx| {
+        assert!(
+            !panel.focus_handle(cx).is_focused(window),
+            "Project panel should not be focused after attempting to reveal an invisible worktree entry"
+        );
+
+        panel.workspace.update(cx, |workspace, cx| {
+            assert!(
+                workspace.active_item(cx).is_some(),
+                "Workspace should have an active item"
+            );
+
+            let notification_ids = workspace.notification_ids();
+            assert_eq!(
+                notification_ids.len(),
+                1,
+                "A notification should be shown when trying to reveal an invisible worktree entry"
+            );
+
+            workspace.dismiss_notification(&notification_ids[0], cx);
+            assert_eq!(
+                workspace.notification_ids().len(),
+                0,
+                "No notifications should be left after dismissing"
+            );
+        }).unwrap();
+    });
+
+    // Create an empty buffer so we can ensure that, attempting to reveal it in
+    // the project panel shows a notification and does not focus the project
+    // panel.
+    let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
+    pane.update_in(cx, |pane, window, cx| {
+        let item = cx.new(|cx| TestItem::new(cx).with_label("Unsaved buffer"));
+        pane.add_item(Box::new(item), false, false, None, window, cx);
+    });
+
+    cx.dispatch_action(workspace::RevealInProjectPanel::default());
+    cx.run_until_parked();
+
+    panel.update_in(cx, |panel, window, cx| {
+        assert!(
+            !panel.focus_handle(cx).is_focused(window),
+            "Project panel should not be focused after attempting to reveal an unsaved buffer"
+        );
+
+        panel
+            .workspace
+            .update(cx, |workspace, cx| {
+                assert!(
+                    workspace.active_item(cx).is_some(),
+                    "Workspace should have an active item"
+                );
+
+                let notification_ids = workspace.notification_ids();
+                assert_eq!(
+                    notification_ids.len(),
+                    1,
+                    "A notification should be shown when trying to reveal an unsaved buffer"
+                );
+            })
+            .unwrap();
+    });
+}
+
 #[gpui::test]
 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
     init_test(cx);

crates/workspace/src/pane.rs 🔗

@@ -10,7 +10,10 @@ use crate::{
         TabContentParams, TabTooltipContent, WeakItemHandle,
     },
     move_item,
-    notifications::NotifyResultExt,
+    notifications::{
+        NotificationId, NotifyResultExt, show_app_notification,
+        simple_message_notification::MessageNotification,
+    },
     toolbar::Toolbar,
     workspace_settings::{AutosaveSetting, FocusFollowsMouse, TabBarSettings, WorkspaceSettings},
 };
@@ -4400,17 +4403,64 @@ impl Render for Pane {
             ))
             .on_action(
                 cx.listener(|pane: &mut Self, action: &RevealInProjectPanel, _, cx| {
+                    let Some(active_item) = pane.active_item() else {
+                        return;
+                    };
+
                     let entry_id = action
                         .entry_id
                         .map(ProjectEntryId::from_proto)
-                        .or_else(|| pane.active_item()?.project_entry_ids(cx).first().copied());
-                    if let Some(entry_id) = entry_id {
-                        pane.project
-                            .update(cx, |_, cx| {
-                                cx.emit(project::Event::RevealInProjectPanel(entry_id))
-                            })
-                            .ok();
+                        .or_else(|| active_item.project_entry_ids(cx).first().copied());
+
+                    let show_reveal_error_toast = |display_name: &str, cx: &mut App| {
+                        let notification_id = NotificationId::unique::<RevealInProjectPanel>();
+                        let message = SharedString::from(format!(
+                            "\"{display_name}\" is not part of any open projects."
+                        ));
+
+                        show_app_notification(notification_id, cx, move |cx| {
+                            let message = message.clone();
+                            cx.new(|cx| MessageNotification::new(message, cx))
+                        });
+                    };
+
+                    let Some(entry_id) = entry_id else {
+                        // When working with an unsaved buffer, display a toast
+                        // informing the user that the buffer is not present in
+                        // any of the open projects and stop execution, as we
+                        // don't want to open the project panel.
+                        let display_name = active_item
+                            .tab_tooltip_text(cx)
+                            .unwrap_or_else(|| active_item.tab_content_text(0, cx));
+
+                        return show_reveal_error_toast(&display_name, cx);
+                    };
+
+                    // We'll now check whether the entry belongs to a visible
+                    // worktree and, if that's not the case, it means the user
+                    // is interacting with a file that does not belong to any of
+                    // the open projects, so we'll show a toast informing them
+                    // of this and stop execution.
+                    let display_name = pane
+                        .project
+                        .read_with(cx, |project, cx| {
+                            project
+                                .worktree_for_entry(entry_id, cx)
+                                .filter(|worktree| !worktree.read(cx).is_visible())
+                                .map(|worktree| worktree.read(cx).root_name_str().to_string())
+                        })
+                        .ok()
+                        .flatten();
+
+                    if let Some(display_name) = display_name {
+                        return show_reveal_error_toast(&display_name, cx);
                     }
+
+                    pane.project
+                        .update(cx, |_, cx| {
+                            cx.emit(project::Event::RevealInProjectPanel(entry_id))
+                        })
+                        .log_err();
                 }),
             )
             .on_action(cx.listener(|_, _: &menu::Cancel, window, cx| {