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
This commit is contained in:
Kirill Bulatov 2026-05-25 14:53:12 +03:00 committed by GitHub
parent 13e7c11768
commit d3a9fd96a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 359 additions and 72 deletions

View file

@ -566,6 +566,10 @@ impl Item for AgentDiffPane {
.for_each_project_item(cx, f)
}
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
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);

View file

@ -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!(

View file

@ -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::<Editor>().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::<Editor>().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::<Editor>().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::<Editor>().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::<Editor>().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::<Editor>().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",

View file

@ -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));

View file

@ -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::<Editor>(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<ProjectPath> {
Some(self.project_path.clone())
}
fn has_conflict(&self, cx: &App) -> bool {
self.multibuffer.read(cx).has_conflict(cx)
}

View file

@ -807,6 +807,10 @@ impl Item for ProjectDiagnosticsEditor {
self.editor.for_each_project_item(cx, f)
}
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
self.editor.read(cx).active_project_path(cx)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,

View file

@ -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<ProjectPath> {
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)

View file

@ -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<Path> {
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)

View file

@ -816,6 +816,10 @@ impl Item for Editor {
}
}
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
self.active_buffer(cx)?.read(cx).project_path(cx)
}
fn can_save_as(&self, cx: &App) -> bool {
self.buffer.read(cx).is_singleton()
}

View file

@ -1756,6 +1756,10 @@ impl Item for SplittableEditor {
self.rhs_editor.read(cx).buffer_kind(cx)
}
fn active_project_path(&self, cx: &App) -> Option<project::ProjectPath> {
self.rhs_editor.read(cx).active_project_path(cx)
}
fn is_dirty(&self, cx: &App) -> bool {
self.rhs_editor.read(cx).is_dirty(cx)
}

View file

@ -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::<Editor>(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::<Editor>(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::<Editor>(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 {

View file

@ -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<ProjectPath> {
self.editor.read(cx).active_project_path(cx)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,

View file

@ -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<ProjectPath> {
self.editor.read(cx).active_project_path(cx)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,

View file

@ -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::<ProjectDiff>(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::<ProjectDiff>(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());
});

View file

@ -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<ProjectPath> {
self.editor.read(cx).active_project_path(cx)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,

View file

@ -544,21 +544,6 @@ impl ProjectDiff {
self.move_to_path(path_key, window, cx)
}
pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
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>) {
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<ProjectPath> {
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,

View file

@ -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<ProjectPath> {
self.diff_editor.read(cx).active_project_path(cx)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,

View file

@ -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<Editor>,
_subscription: Subscription,
}
impl TestMultibufferWrapper {
pub fn new(editor: Entity<Editor>, cx: &mut Context<Self>) -> Self {
let _subscription = cx.subscribe(&editor, |_, _, event: &EditorEvent, cx| {
cx.emit(event.clone());
});
Self {
editor,
_subscription,
}
}
}
impl EventEmitter<EditorEvent> 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<Self>) -> 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<project::ProjectPath> {
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::<SettingsStore, _>(|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);

View file

@ -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<ProjectPath> {
self.results_editor.read(cx).active_project_path(cx)
}
fn can_save(&self, _: &App) -> bool {
true
}

View file

@ -237,6 +237,24 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + 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<ProjectPath> {
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<Self>) {}
fn can_split(&self) -> bool {
@ -646,14 +664,7 @@ impl<T: Item> ItemHandle for Entity<T> {
}
fn project_path(&self, cx: &App) -> Option<ProjectPath> {
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
<T as Item>::active_project_path(self.read(cx), cx)
}
fn workspace_settings<'a>(&self, cx: &'a App) -> &'a WorkspaceSettings {
@ -910,6 +921,16 @@ impl<T: Item> ItemHandle for Entity<T> {
}
}
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<T: Item> ItemHandle for Entity<T> {
}
pane.update(cx, |pane, cx| pane.handle_item_edit(item.item_id(), cx));
}
_ => {}
});
},
));

View file

@ -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,

View file

@ -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::<Editor>().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,
)