From 1dc3bb90e96be26cab72e7392c4042e1e5d0d71a Mon Sep 17 00:00:00 2001 From: Pratik Karki Date: Tue, 7 Apr 2026 17:10:55 +0545 Subject: [PATCH] Fix pane::RevealInProjectPanel to focus/open project panel for non-project buffers (#51246) 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 Co-authored-by: dino --- .../project_panel/src/project_panel_tests.rs | 146 +++++++++++++++++- crates/workspace/src/pane.rs | 66 +++++++- 2 files changed, 203 insertions(+), 9 deletions(-) diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 55b53cde8b6252f8b9732cf4effc35ea53c073e0..603cfd892a218d866383f485d058296ad179da05 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/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(¬ification_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); diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 27cc96ae80a010db2dd5357a9a0bc037ca762875..a09ba73add7e94fbe6910eb400b1364bd21cd313 100644 --- a/crates/workspace/src/pane.rs +++ b/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::(); + 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| {