vim: Don't steal focus from non-pane panels for search commands (#54012)

When the Agent panel (or any dock panel without its own pane) is focused
and a file is open in the center editor, pressing `/` in vim mode would
steal focus to the buffer's search bar instead of staying on the panel.

## Root cause

`Vim::pane()` calls `workspace.focused_pane()`, which falls back to the
center pane when a dock panel without its own `pane()` method is
focused. Vim search commands then open the `BufferSearchBar` on the
center pane and focus it, stealing focus from the panel.

## Fix

Add a guard in `Vim::pane()` that returns `None` when the resolved pane
doesn't actually contain focus. This prevents all vim search/match
commands (`/`, `?`, `n`, `N`, `*`, `#`, etc.) from stealing focus from
non-pane panels.

All 497 vim tests and 253 agent_ui tests pass.

Release Notes:

- Fixed vim search (`/`) stealing focus from the Agent panel when a file
is open in the editor.
This commit is contained in:
Richard Feldman 2026-04-16 11:47:59 -04:00 committed by GitHub
parent 75fa566511
commit eb254be084
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 114 additions and 4 deletions

2
Cargo.lock generated
View file

@ -391,6 +391,7 @@ dependencies = [
"rope",
"rules_library",
"schemars",
"search",
"semver",
"serde",
"serde_json",
@ -415,6 +416,7 @@ dependencies = [
"url",
"util",
"uuid",
"vim",
"watch",
"workspace",
"zed_actions",

View file

@ -135,8 +135,10 @@ remote = { workspace = true, features = ["test-support"] }
remote_connection = { workspace = true, features = ["test-support"] }
remote_server = { workspace = true, features = ["test-support"] }
search = { workspace = true, features = ["test-support"] }
semver.workspace = true
reqwest_client.workspace = true
tempfile.workspace = true
vim.workspace = true
tree-sitter-md.workspace = true
unindent.workspace = true

View file

@ -8052,6 +8052,103 @@ mod tests {
);
});
}
#[gpui::test]
async fn test_vim_search_does_not_steal_focus_from_agent_panel(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
vim::init(cx);
search::init(cx);
// Enable vim mode
settings::SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |s| s.vim_mode = Some(true));
});
// Load vim keybindings
let mut vim_key_bindings =
settings::KeymapFile::load_asset_allow_partial_failure("keymaps/vim.json", cx)
.unwrap();
for key_binding in &mut vim_key_bindings {
key_binding.set_meta(settings::KeybindSource::Vim.meta());
}
cx.bind_keys(vim_key_bindings);
});
// Create a project with a file so we have a buffer in the center pane.
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({ "file.txt": "hello world" }))
.await;
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |mw, _cx| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
// Open a file in the center pane.
workspace
.update_in(&mut cx, |workspace, window, cx| {
workspace.open_paths(
vec![PathBuf::from("/project/file.txt")],
workspace::OpenOptions::default(),
None,
window,
cx,
)
})
.await;
cx.run_until_parked();
// Add a BufferSearchBar to the center pane's toolbar, as a real
// workspace would have.
workspace.update_in(&mut cx, |workspace, window, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.toolbar().update(cx, |toolbar, cx| {
let search_bar = cx.new(|cx| search::BufferSearchBar::new(None, window, cx));
toolbar.add_item(search_bar, window, cx);
});
});
});
// Create the agent panel and add it to the workspace.
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
// Open a thread so the panel has an active editor.
open_thread_with_connection(&panel, StubAgentConnection::new(), &mut cx);
// Focus the agent panel.
workspace.update_in(&mut cx, |workspace, window, cx| {
workspace.focus_panel::<AgentPanel>(window, cx);
});
cx.run_until_parked();
// Verify the agent panel has focus.
workspace.update_in(&mut cx, |_, window, cx| {
assert!(
panel.read(cx).focus_handle(cx).contains_focused(window, cx),
"Agent panel should be focused before pressing '/'"
);
});
// Press '/' — the vim search keybinding.
cx.simulate_keystrokes("/");
// Focus should remain on the agent panel.
workspace.update_in(&mut cx, |_, window, cx| {
assert!(
panel.read(cx).focus_handle(cx).contains_focused(window, cx),
"Focus should remain on the agent panel after pressing '/'"
);
});
}
/// Connection that tracks closed sessions and detects prompts against
/// sessions that no longer exist, used to reproduce session disassociation.

View file

@ -29,8 +29,8 @@ use editor::{
movement::{self, FindRange},
};
use gpui::{
Action, App, AppContext, Axis, Context, Entity, EventEmitter, KeyContext, KeystrokeEvent,
Render, Subscription, Task, WeakEntity, Window, actions,
Action, App, AppContext, Axis, Context, Entity, EventEmitter, Focusable, KeyContext,
KeystrokeEvent, Render, Subscription, Task, WeakEntity, Window, actions,
};
use insert::{NormalBefore, TemporaryNormal};
use language::{
@ -1043,8 +1043,17 @@ impl Vim {
}
pub fn pane(&self, window: &Window, cx: &Context<Self>) -> Option<Entity<Pane>> {
self.workspace(window, cx)
.map(|workspace| workspace.read(cx).focused_pane(window, cx))
let pane = self
.workspace(window, cx)
.map(|workspace| workspace.read(cx).focused_pane(window, cx))?;
// `focused_pane` falls back to the center pane when a dock panel
// without its own pane (e.g. the Agent panel) has focus. Guard
// against that so vim search/match commands don't steal focus.
if pane.read(cx).focus_handle(cx).contains_focused(window, cx) {
Some(pane)
} else {
None
}
}
pub fn enabled(cx: &mut App) -> bool {