From 4bc769a328618372060fa6ab7de87fba82150a4c Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 25 May 2026 14:53:12 +0300 Subject: [PATCH] Make project panel to auto reveal multi buffer excerpts with latest selection (#57236) Make non-singleton editors to return project paths by adding a `fn active_project_path`: this had been added as `fn project_path` and similar already, so the PR replaced those methods with the generic one now. Before: https://github.com/user-attachments/assets/d0773e18-3910-4c5b-bcb3-a742f9bf9691 After: https://github.com/user-attachments/assets/e7a3f13e-9649-4564-a7e6-dccf54f8c000 Release Notes: - Made project panel to auto reveal multi buffer excerpts with latest selection --- crates/agent_ui/src/agent_diff.rs | 6 +- .../tests/integration/following_tests.rs | 8 +- .../debugger_ui/src/tests/debugger_panel.rs | 12 +- .../debugger_ui/src/tests/stack_frame_list.rs | 5 +- crates/diagnostics/src/buffer_diagnostics.rs | 6 +- crates/diagnostics/src/diagnostics.rs | 4 + crates/editor/src/editor.rs | 10 - crates/editor/src/editor_tests.rs | 15 +- crates/editor/src/items.rs | 4 + crates/editor/src/split.rs | 4 + crates/file_finder/src/file_finder_tests.rs | 9 +- crates/git_ui/src/commit_view.rs | 6 +- crates/git_ui/src/file_diff_view.rs | 6 +- crates/git_ui/src/git_panel.rs | 8 +- crates/git_ui/src/multi_diff_view.rs | 6 +- crates/git_ui/src/project_diff.rs | 32 +-- crates/git_ui/src/text_diff_view.rs | 6 +- .../project_panel/src/project_panel_tests.rs | 235 ++++++++++++++++++ crates/search/src/project_search.rs | 4 + crates/workspace/src/item.rs | 39 ++- crates/workspace/src/workspace.rs | 2 +- crates/zed/src/zed.rs | 4 +- 22 files changed, 359 insertions(+), 72 deletions(-) diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 6f4e900be4..95a9191ef8 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -566,6 +566,10 @@ impl Item for AgentDiffPane { .for_each_project_item(cx, f) } + fn active_project_path(&self, cx: &App) -> Option { + self.editor.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, @@ -2253,7 +2257,7 @@ mod tests { }); let editor2_path = editor2 - .read_with(cx, |editor, cx| editor.project_path(cx)) + .read_with(cx, |editor, cx| editor.active_project_path(cx)) .unwrap(); assert_eq!(editor2_path, buffer_path2); diff --git a/crates/collab/tests/integration/following_tests.rs b/crates/collab/tests/integration/following_tests.rs index b4b29ade76..2a80a6f0b1 100644 --- a/crates/collab/tests/integration/following_tests.rs +++ b/crates/collab/tests/integration/following_tests.rs @@ -18,8 +18,8 @@ use settings::SettingsStore; use text::{Point, ToPoint}; use util::{path, rel_path::rel_path, test::sample_text}; use workspace::{ - CloseWindow, CollaboratorId, MultiWorkspace, ParticipantLocation, SplitDirection, Workspace, - item::ItemHandle as _, + CloseWindow, CollaboratorId, Item, MultiWorkspace, ParticipantLocation, SplitDirection, + Workspace, item::ItemHandle as _, }; use super::TestClient; @@ -154,7 +154,7 @@ async fn test_basic_following( .unwrap() }); assert_eq!( - cx_b.read(|cx| editor_b2.project_path(cx)), + cx_b.read(|cx| editor_b2.read(cx).active_project_path(cx)), Some((worktree_id, rel_path("2.txt")).into()) ); assert_eq!( @@ -1866,7 +1866,7 @@ async fn test_following_into_excluded_file( .unwrap() }); assert_eq!( - cx_b.read(|cx| editor_for_excluded_b.project_path(cx)), + cx_b.read(|cx| editor_for_excluded_b.read(cx).active_project_path(cx)), Some((worktree_id, rel_path(".git/COMMIT_EDITMSG")).into()) ); assert_eq!( diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index 4a6c8816a2..6978b7850a 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -1962,7 +1962,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view( .read_with(cx, |_multi, cx| { let active = pane_a.read(cx).active_item().unwrap(); let editor = active.to_any_view().downcast::().unwrap(); - let path = editor.read(cx).project_path(cx).unwrap(); + let path = editor.read(cx).active_project_path(cx).unwrap(); assert_eq!( path.path.file_name().unwrap(), "second.rs", @@ -1976,7 +1976,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view( .read_with(cx, |_multi, cx| { let active = pane_b.read(cx).active_item().unwrap(); let editor = active.to_any_view().downcast::().unwrap(); - let path = editor.read(cx).project_path(cx).unwrap(); + let path = editor.read(cx).active_project_path(cx).unwrap(); assert_eq!( path.path.file_name().unwrap(), "main.rs", @@ -2056,7 +2056,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view( .read_with(cx, |_multi, cx| { let pane_a_active = pane_a.read(cx).active_item().unwrap(); let pane_a_editor = pane_a_active.to_any_view().downcast::().unwrap(); - let pane_a_path = pane_a_editor.read(cx).project_path(cx).unwrap(); + let pane_a_path = pane_a_editor.read(cx).active_project_path(cx).unwrap(); assert_eq!( pane_a_path.path.file_name().unwrap(), "second.rs", @@ -2161,7 +2161,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view( .read_with(cx, |_multi, cx| { let pane_b_active = pane_b.read(cx).active_item().unwrap(); let pane_b_editor = pane_b_active.to_any_view().downcast::().unwrap(); - let pane_b_path = pane_b_editor.read(cx).project_path(cx).unwrap(); + let pane_b_path = pane_b_editor.read(cx).active_project_path(cx).unwrap(); assert_eq!( pane_b_path.path.file_name().unwrap(), "second.rs", @@ -2232,7 +2232,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view( .read_with(cx, |_multi, cx| { let pane_c_active = pane_c.read(cx).active_item().unwrap(); let pane_c_editor = pane_c_active.to_any_view().downcast::().unwrap(); - let pane_c_path = pane_c_editor.read(cx).project_path(cx).unwrap(); + let pane_c_path = pane_c_editor.read(cx).active_project_path(cx).unwrap(); assert_eq!( pane_c_path.path.file_name().unwrap(), "second.rs", @@ -2294,7 +2294,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view( .read_with(cx, |_multi, cx| { let pane_c_active = pane_c.read(cx).active_item().unwrap(); let pane_c_editor = pane_c_active.to_any_view().downcast::().unwrap(); - let pane_c_path = pane_c_editor.read(cx).project_path(cx).unwrap(); + let pane_c_path = pane_c_editor.read(cx).active_project_path(cx).unwrap(); assert_eq!( pane_c_path.path.file_name().unwrap(), "main.rs", diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index dd1ddcdf7a..c7977efece 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -18,6 +18,7 @@ use serde_json::json; use std::sync::Arc; use unindent::Unindent as _; use util::{path, rel_path::rel_path}; +use workspace::Item; #[gpui::test] async fn test_fetch_initial_stack_frames_and_go_to_stack_frame( @@ -334,7 +335,7 @@ async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppC assert_eq!(1, editors.len()); let project_path = editors[0] - .update(cx, |editor, cx| editor.project_path(cx)) + .update(cx, |editor, cx| editor.active_project_path(cx)) .unwrap(); assert_eq!(rel_path("src/test.js"), project_path.path.as_ref()); assert_eq!(test_file_content, editors[0].read(cx).text(cx)); @@ -397,7 +398,7 @@ async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppC assert_eq!(1, editors.len()); let project_path = editors[0] - .update(cx, |editor, cx| editor.project_path(cx)) + .update(cx, |editor, cx| editor.active_project_path(cx)) .unwrap(); assert_eq!(rel_path("src/module.js"), project_path.path.as_ref()); assert_eq!(module_file_content, editors[0].read(cx).text(cx)); diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index b05e6a0f43..4558ce0707 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -220,7 +220,7 @@ impl BufferDiagnosticsEditor { // If there's no active editor with a project path, avoiding deploying // the buffer diagnostics view. if let Some(editor) = workspace.active_item_as::(cx) - && let Some(project_path) = editor.project_path(cx) + && let Some(project_path) = editor.read(cx).active_project_path(cx) { // Check if there's already a `BufferDiagnosticsEditor` tab for this // same path, and if so, focus on that one instead of creating a new @@ -749,6 +749,10 @@ impl Item for BufferDiagnosticsEditor { self.editor.for_each_project_item(cx, f); } + fn active_project_path(&self, _cx: &App) -> Option { + Some(self.project_path.clone()) + } + fn has_conflict(&self, cx: &App) -> bool { self.multibuffer.read(cx).has_conflict(cx) } diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index de99274d86..6b1e91b680 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -807,6 +807,10 @@ impl Item for ProjectDiagnosticsEditor { self.editor.for_each_project_item(cx, f) } + fn active_project_path(&self, cx: &App) -> Option { + self.editor.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1a470ebeb1..95ab2d193a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2650,16 +2650,6 @@ impl Editor { }) } - /// Returns the project path for the editor's buffer, if any buffer is - /// opened in the editor. - pub fn project_path(&self, cx: &App) -> Option { - if let Some(buffer) = self.buffer.read(cx).as_singleton() { - buffer.read(cx).project_path(cx) - } else { - None - } - } - pub fn selection_menu_enabled(&self, cx: &App) -> bool { self.show_selection_menu .unwrap_or_else(|| EditorSettings::get_global(cx).toolbar.selections_menu) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 257122307e..6ef112d9fd 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -28013,7 +28013,7 @@ async fn test_breakpoint_toggling(cx: &mut TestAppContext) { ) }); - let project_path = editor.update(cx, |editor, cx| editor.project_path(cx).unwrap()); + let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) @@ -28163,7 +28163,8 @@ async fn test_breakpoint_after_save_as_existing_path(cx: &mut TestAppContext) { editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); }); - let project_path = first_editor.update(cx, |editor, cx| editor.project_path(cx).unwrap()); + let project_path = + first_editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) @@ -28231,7 +28232,7 @@ async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { ) }); - let project_path = editor.update(cx, |editor, cx| editor.project_path(cx).unwrap()); + let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) @@ -28402,7 +28403,7 @@ async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { ) }); - let project_path = editor.update(cx, |editor, cx| editor.project_path(cx).unwrap()); + let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap()); let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) @@ -28563,9 +28564,9 @@ impl BookmarkTestContext { } fn abs_path(&self) -> Arc { - let project_path = self - .editor - .read_with(&self.cx, |editor, cx| editor.project_path(cx).unwrap()); + let project_path = self.editor.read_with(&self.cx, |editor, cx| { + editor.active_project_path(cx).unwrap() + }); self.project.read_with(&self.cx, |project, cx| { project .absolute_path(&project_path, cx) diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 5b46a735dc..89f323ab14 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -816,6 +816,10 @@ impl Item for Editor { } } + fn active_project_path(&self, cx: &App) -> Option { + self.active_buffer(cx)?.read(cx).project_path(cx) + } + fn can_save_as(&self, cx: &App) -> bool { self.buffer.read(cx).is_singleton() } diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 960388ae73..82b92a8a21 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -1756,6 +1756,10 @@ impl Item for SplittableEditor { self.rhs_editor.read(cx).buffer_kind(cx) } + fn active_project_path(&self, cx: &App) -> Option { + self.rhs_editor.read(cx).active_project_path(cx) + } + fn is_dirty(&self, cx: &App) -> bool { self.rhs_editor.read(cx).is_dirty(cx) } diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index a9d67dd31a..bafd484fde 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -10,7 +10,8 @@ use serde_json::json; use settings::SettingsStore; use util::{path, rel_path::rel_path}; use workspace::{ - AppState, CloseActiveItem, MultiWorkspace, OpenOptions, ToggleFileFinder, Workspace, open_paths, + AppState, CloseActiveItem, Item, MultiWorkspace, OpenOptions, ToggleFileFinder, Workspace, + open_paths, }; #[ctor::ctor] @@ -1540,7 +1541,7 @@ async fn test_create_file_for_multiple_worktrees(cx: &mut TestAppContext) { cx.run_until_parked(); cx.read(|cx| { let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); - let project_path = active_editor.read(cx).project_path(cx); + let project_path = active_editor.read(cx).active_project_path(cx); assert_eq!( project_path, Some(ProjectPath { @@ -1618,7 +1619,7 @@ async fn test_create_file_focused_file_does_not_belong_to_available_worktrees( cx.read(|cx| { let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); - let project_path = active_editor.read(cx).project_path(cx); + let project_path = active_editor.read(cx).active_project_path(cx); assert!( project_path.is_some(), @@ -1690,7 +1691,7 @@ async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppCon cx.run_until_parked(); cx.read(|cx| { let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); - let project_path = active_editor.read(cx).project_path(cx); + let project_path = active_editor.read(cx).active_project_path(cx); assert_eq!( project_path, Some(ProjectPath { diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index c39c175e32..0109fb0ac5 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -19,7 +19,7 @@ use language::{ Point, ReplicaId, Rope, TextBuffer, }; use multi_buffer::PathKey; -use project::{Project, WorktreeId, git_store::Repository}; +use project::{Project, ProjectPath, WorktreeId, git_store::Repository}; use std::{ any::{Any, TypeId}, collections::HashSet, @@ -997,6 +997,10 @@ impl Item for CommitView { self.editor.for_each_project_item(cx, f) } + fn active_project_path(&self, cx: &App) -> Option { + self.editor.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index e313700dd8..ac9e4175b6 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -9,7 +9,7 @@ use gpui::{ Focusable, Font, IntoElement, Render, Task, WeakEntity, Window, }; use language::{Buffer, HighlightedText, LanguageRegistry}; -use project::Project; +use project::{Project, ProjectPath}; use settings::Settings; use std::{ any::{Any, TypeId}, @@ -303,6 +303,10 @@ impl Item for FileDiffView { self.editor.for_each_project_item(cx, f) } + fn active_project_path(&self, cx: &App) -> Option { + self.editor.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 3ef1b77edd..2618d84d93 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -83,7 +83,7 @@ use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath}; use workspace::SERIALIZATION_THROTTLE_TIME; use workspace::{ - Workspace, + Item, Workspace, dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt}, }; @@ -1362,7 +1362,7 @@ impl GitPanel { let git_repo = self.active_repository.as_ref()?; if let Some(project_diff) = workspace.read(cx).active_item_as::(cx) - && let Some(project_path) = project_diff.read(cx).active_path(cx) + && let Some(project_path) = project_diff.read(cx).active_project_path(cx) && Some(&entry.repo_path) == git_repo .read(cx) @@ -8380,8 +8380,8 @@ mod tests { .item_of_type::(cx) .expect("ProjectDiff should exist") .read(cx) - .active_path(cx) - .expect("active_path should exist"); + .active_project_path(cx) + .expect("active_project_path should exist"); assert_eq!(active_path.path, rel_path("untracked").into_arc()); }); diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index c5e456a1e4..a3b55fabe9 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -7,7 +7,7 @@ use gpui::{ }; use language::{Buffer, Capability, HighlightedText, OffsetRangeExt}; use multi_buffer::PathKey; -use project::Project; +use project::{Project, ProjectPath}; use std::{ any::{Any, TypeId}, path::{Path, PathBuf}, @@ -313,6 +313,10 @@ impl Item for MultiDiffView { Some(Box::new(self.editor.clone())) } + fn active_project_path(&self, cx: &App) -> Option { + self.editor.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index be7ffda92d..cd91c6704e 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -544,21 +544,6 @@ impl ProjectDiff { self.move_to_path(path_key, window, cx) } - pub fn active_path(&self, cx: &App) -> Option { - let editor = self.editor.read(cx).focused_editor().read(cx); - let multibuffer = editor.buffer().read(cx); - let position = editor.selections.newest_anchor().head(); - let snapshot = multibuffer.snapshot(cx); - let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; - let buffer = multibuffer.buffer(text_anchor.buffer_id)?; - - let file = buffer.read(cx).file()?; - Some(ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path().clone(), - }) - } - fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.rhs_editor().update(cx, |editor, cx| { @@ -675,7 +660,7 @@ impl ProjectDiff { ) { match event { EditorEvent::SelectionsChanged { local: true } => { - let Some(project_path) = self.active_path(cx) else { + let Some(project_path) = self.active_project_path(cx) else { return; }; self.workspace @@ -1048,6 +1033,21 @@ impl Item for ProjectDiff { .for_each_project_item(cx, f) } + fn active_project_path(&self, cx: &App) -> Option { + let editor = self.editor.read(cx).focused_editor().read(cx); + let multibuffer = editor.buffer().read(cx); + let position = editor.selections.newest_anchor().head(); + let snapshot = multibuffer.snapshot(cx); + let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; + let buffer = multibuffer.buffer(text_anchor.buffer_id)?; + + let file = buffer.read(cx).file()?; + Some(ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 739c314880..aae52ffe43 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -12,7 +12,7 @@ use gpui::{ Focusable, IntoElement, Render, Task, Window, }; use language::{self, Buffer, OffsetRangeExt, Point}; -use project::Project; +use project::{Project, ProjectPath}; use settings::Settings; use std::{ any::{Any, TypeId}, @@ -377,6 +377,10 @@ impl Item for TextDiffView { self.diff_editor.read(cx).for_each_project_item(cx, f) } + fn active_project_path(&self, cx: &App) -> Option { + self.diff_editor.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 6722a300dd..448f5183b2 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -5467,6 +5467,241 @@ async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) { ); } +/// Mirrors real multi-buffer views (`ProjectDiagnosticsEditor`, `ProjectDiff`, +/// etc.): the workspace `Item` is a thin wrapper that holds an inner `Editor` +/// and re-emits its events. +mod multibuffer_wrapper { + use editor::{Editor, EditorEvent}; + use gpui::{ + App, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, IntoElement, + ParentElement, Render, SharedString, Subscription, Window, div, + }; + use workspace::item::{Item, ItemEvent, TabContentParams}; + + pub struct TestMultibufferWrapper { + pub editor: Entity, + _subscription: Subscription, + } + + impl TestMultibufferWrapper { + pub fn new(editor: Entity, cx: &mut Context) -> Self { + let _subscription = cx.subscribe(&editor, |_, _, event: &EditorEvent, cx| { + cx.emit(event.clone()); + }); + Self { + editor, + _subscription, + } + } + } + + impl EventEmitter for TestMultibufferWrapper {} + + impl Focusable for TestMultibufferWrapper { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.read(cx).focus_handle(cx) + } + } + + impl Render for TestMultibufferWrapper { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().child(self.editor.clone()) + } + } + + impl Item for TestMultibufferWrapper { + type Event = EditorEvent; + + fn tab_content_text(&self, _: usize, _: &App) -> SharedString { + "wrapper".into() + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), + ) { + self.editor.read(cx).for_each_project_item(cx, f) + } + + fn active_project_path(&self, cx: &App) -> Option { + self.editor.read(cx).active_project_path(cx) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn tab_content(&self, params: TabContentParams, _: &Window, cx: &App) -> gpui::AnyElement { + ui::Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx)) + .into_any_element() + } + } +} + +#[gpui::test] +async fn test_autoreveal_follows_multibuffer_selection(cx: &mut gpui::TestAppContext) { + use editor::{ + Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, ToOffset, + }; + use language::Point; + use multibuffer_wrapper::TestMultibufferWrapper; + + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings + .project_panel + .get_or_insert_default() + .auto_reveal_entries = Some(true); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project_root"), + json!({ + "dir_1": { "file_1.py": "alpha 1\nalpha 2\nalpha 3\n" }, + "dir_2": { "file_2.py": "beta 1\nbeta 2\nbeta 3\n" }, + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project_root").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(); + + let buffer_1 = project + .update(cx, |project, cx| { + let project_path = project + .find_project_path("project_root/dir_1/file_1.py", cx) + .unwrap(); + project.open_buffer(project_path, cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + let project_path = project + .find_project_path("project_root/dir_2/file_2.py", cx) + .unwrap(); + project.open_buffer(project_path, cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.update(|_, cx| { + cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(language::Capability::ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(2, 0)], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(2, 0)], + 0, + cx, + ); + multi_buffer + }) + }); + + let inner_editor = cx.update(|window, cx| { + cx.new(|cx| { + Editor::new( + EditorMode::full(), + multi_buffer.clone(), + Some(project.clone()), + window, + cx, + ) + }) + }); + + // Wrap the multibuffer editor in an `Item`, mirroring real multibuffer + // views (`ProjectDiagnosticsEditor`, `ProjectDiff`, etc.). Auto-reveal + // should follow the inner editor's active buffer. + workspace.update_in(cx, |workspace, window, cx| { + let wrapper = cx.new(|cx| TestMultibufferWrapper::new(inner_editor.clone(), cx)); + workspace.add_item_to_active_pane(Box::new(wrapper), None, true, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " v dir_1", + " file_1.py <== selected <== marked", + " > dir_2", + ], + "When a multibuffer becomes active, its first excerpt's file should be revealed" + ); + + let buffer_2_offset = multi_buffer.read_with(cx, |multi_buffer, cx| { + let snapshot = multi_buffer.snapshot(cx); + let buffer_2_id = buffer_2.read(cx).remote_id(); + let excerpt = snapshot + .excerpts_for_buffer(buffer_2_id) + .next() + .expect("buffer_2 excerpt must exist"); + snapshot + .anchor_in_excerpt(excerpt.context.start) + .expect("excerpt anchor must resolve") + .to_offset(&snapshot) + }); + + inner_editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([buffer_2_offset..buffer_2_offset]); + }); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " v dir_1", + " file_1.py", + " v dir_2", + " file_2.py <== selected <== marked", + ], + "Moving the cursor into a different excerpt buffer should reveal that buffer's entry" + ); + + // Wrappers re-emit inner-editor events through `to_item_events`, so a + // benign `TitleChanged` (e.g. diagnostic summary updates) ultimately + // reaches `Workspace::active_item_path_changed`. The active path should be + // recomputed from the wrapper instead of falling back to a stale selection. + inner_editor.update(cx, |_, cx| cx.emit(EditorEvent::TitleChanged)); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " v dir_1", + " file_1.py", + " v dir_2", + " file_2.py <== selected <== marked", + ], + "Wrapper-level title updates must not clobber the inner editor's reveal" + ); +} + #[gpui::test] async fn test_reveal_in_project_panel_fallback(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 9aff505f90..93e4b3f9d1 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -679,6 +679,10 @@ impl Item for ProjectSearchView { self.results_editor.for_each_project_item(cx, f) } + fn active_project_path(&self, cx: &App) -> Option { + self.results_editor.read(cx).active_project_path(cx) + } + fn can_save(&self, _: &App) -> bool { true } diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 573a6d9ac0..7b247a073c 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -237,6 +237,24 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { fn buffer_kind(&self, _cx: &App) -> ItemBufferKind { ItemBufferKind::None } + + /// Returns the project path that should be treated as active for this item. + /// + /// Singleton items use their only project item by default. Items backed by + /// multiple buffers should override this to return the path for the buffer + /// under the primary cursor or otherwise selected sub-item. + fn active_project_path(&self, cx: &App) -> Option { + if self.buffer_kind(cx) != ItemBufferKind::Singleton { + return None; + } + + let mut result = None; + self.for_each_project_item(cx, &mut |_, item| { + result = item.project_path(cx); + }); + result + } + fn set_nav_history(&mut self, _: ItemNavHistory, _window: &mut Window, _: &mut Context) {} fn can_split(&self) -> bool { @@ -646,14 +664,7 @@ impl ItemHandle for Entity { } fn project_path(&self, cx: &App) -> Option { - let this = self.read(cx); - let mut result = None; - if this.buffer_kind(cx) == ItemBufferKind::Singleton { - this.for_each_project_item(cx, &mut |_, item| { - result = item.project_path(cx); - }); - } - result + ::active_project_path(self.read(cx), cx) } fn workspace_settings<'a>(&self, cx: &'a App) -> &'a WorkspaceSettings { @@ -910,6 +921,16 @@ impl ItemHandle for Entity { } } + ItemEvent::UpdateBreadcrumbs => { + if &pane == workspace.active_pane() + && pane.read(cx).active_item().is_some_and(|active_item| { + active_item.item_id() == item.item_id() + }) + { + workspace.active_item_path_changed(false, window, cx); + } + } + ItemEvent::Edit => { let autosave = item.workspace_settings(cx).autosave; @@ -932,8 +953,6 @@ impl ItemHandle for Entity { } pane.update(cx, |pane, cx| pane.handle_item_edit(item.item_id(), cx)); } - - _ => {} }); }, )); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index dbe132add8..3cd02c1599 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -5958,7 +5958,7 @@ impl Workspace { self.follower_states.contains_key(&id.into()) } - fn active_item_path_changed( + pub(crate) fn active_item_path_changed( &mut self, focus_changed: bool, window: &mut Window, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 9bfdd9b1fd..6d2611367d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4175,7 +4175,7 @@ mod tests { let (editor_1, buffer) = workspace.update_in(cx, |_, window, cx| { pane_1.update(cx, |pane_1, cx| { let editor = pane_1.active_item().unwrap().downcast::().unwrap(); - assert_eq!(editor.project_path(cx), Some(file1.clone())); + assert_eq!(editor.read(cx).active_project_path(cx), Some(file1.clone())); let buffer = editor.update(cx, |editor, cx| { editor.insert("dirt", window, cx); editor.buffer().downgrade() @@ -4731,7 +4731,7 @@ mod tests { let scroll_position = editor_ref.scroll_position(cx); ( - editor_ref.project_path(cx).unwrap(), + editor_ref.active_project_path(cx).unwrap(), selections[0].start, scroll_position.y, )