sidebar: Keyboard nav improvements (and vim mode) (#51856)

This commit is contained in:
Cameron Mcloughlin authored and GitHub committed 2026-03-18 17:53:04 +00:00
1 parent fdb769c132
commit c842fb55dd
12 files changed
+203 -42

No files matched your search

Generated
+1
View File
@@ -15901,6 +15901,7 @@ dependencies = [
"theme",
"ui",
"util",
"vim_mode_setting",
"workspace",
"zed_actions",
]
+2 -2
View File
@@ -675,8 +675,8 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "agents_sidebar::NewThreadInGroup",
"left": "agents_sidebar::CollapseSelectedEntry",
"right": "agents_sidebar::ExpandSelectedEntry",
"left": "menu::SelectParent",
"right": "menu::SelectChild",
"enter": "menu::Confirm",
"space": "menu::Confirm",
"ctrl-f": "agents_sidebar::FocusSidebarFilter",
+2 -2
View File
@@ -743,8 +743,8 @@
"use_key_equivalents": true,
"bindings": {
"cmd-n": "agents_sidebar::NewThreadInGroup",
"left": "agents_sidebar::CollapseSelectedEntry",
"right": "agents_sidebar::ExpandSelectedEntry",
"left": "menu::SelectParent",
"right": "menu::SelectChild",
"enter": "menu::Confirm",
"space": "menu::Confirm",
"cmd-f": "agents_sidebar::FocusSidebarFilter",
+2 -2
View File
@@ -679,8 +679,8 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "agents_sidebar::NewThreadInGroup",
"left": "agents_sidebar::CollapseSelectedEntry",
"right": "agents_sidebar::ExpandSelectedEntry",
"left": "menu::SelectParent",
"right": "menu::SelectChild",
"enter": "menu::Confirm",
"space": "menu::Confirm",
"ctrl-f": "agents_sidebar::FocusSidebarFilter",
+27
View File
@@ -1113,4 +1113,31 @@
"k": "notebook::NotebookMoveUp",
},
},
{
"context": "ThreadsSidebar && !Editor",
"bindings": {
"j": "menu::SelectNext",
"k": "menu::SelectPrevious",
"h": "menu::SelectParent",
"l": "menu::SelectChild",
"g g": "menu::SelectFirst",
"shift-g": "menu::SelectLast",
"/": "agents_sidebar::FocusSidebarFilter",
"z a": "editor::ToggleFold",
"z c": "menu::SelectParent",
"z o": "menu::SelectChild",
"z shift-m": "editor::FoldAll",
"z shift-r": "editor::UnfoldAll",
},
},
{
"context": "ThreadsSidebar > Editor && VimControl && vim_mode == normal",
"bindings": {
"j": "editor::MoveDown",
"k": "editor::MoveUp",
"/": "vim::SwitchToInsertMode",
"escape": "menu::Cancel",
"enter": "editor::Newline",
},
},
]
+1
View File
@@ -33,6 +33,7 @@ settings.workspace = true
theme.workspace = true
ui.workspace = true
util.workspace = true
vim_mode_setting.workspace = true
workspace.workspace = true
zed_actions.workspace = true
+93 -18
View File
@@ -12,13 +12,16 @@ use editor::Editor;
use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
use gpui::{
Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels,
Render, SharedString, WeakEntity, Window, WindowHandle, actions, list, prelude::*, px,
Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px,
};
use menu::{
Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
};
use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use project::{AgentId, Event as ProjectEvent};
use recent_projects::RecentProjects;
use ui::utils::platform_title_bar_height;
use settings::Settings as _;
use std::collections::{HashMap, HashSet};
use std::mem;
use std::path::Path;
@@ -39,15 +42,11 @@ use workspace::{
use zed_actions::OpenRecent;
use zed_actions::editor::{MoveDown, MoveUp};
actions!(
use zed_actions::agents_sidebar::FocusSidebarFilter;
gpui::actions!(
agents_sidebar,
[
/// Collapses the selected entry in the workspace sidebar.
CollapseSelectedEntry,
/// Expands the selected entry in the workspace sidebar.
ExpandSelectedEntry,
/// Moves focus to the sidebar's search/filter editor.
FocusSidebarFilter,
/// Creates a new thread in the currently selected or active project group.
NewThreadInGroup,
]
@@ -264,6 +263,7 @@ impl Sidebar {
let filter_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_use_modal_editing(true);
editor.set_placeholder_text("Search…", window, cx);
editor
});
@@ -1511,6 +1511,16 @@ impl Sidebar {
) {
self.selection = None;
self.filter_editor.focus_handle(cx).focus(window, cx);
// When vim mode is active, the editor defaults to normal mode which
// blocks text input. Switch to insert mode so the user can type
// immediately.
if vim_mode_setting::VimModeSetting::get_global(cx).0 {
if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) {
window.dispatch_action(action, cx);
}
}
cx.notify();
}
@@ -1894,7 +1904,7 @@ impl Sidebar {
fn expand_selected_entry(
&mut self,
_: &ExpandSelectedEntry,
_: &SelectChild,
_window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1918,7 +1928,7 @@ impl Sidebar {
fn collapse_selected_entry(
&mut self,
_: &CollapseSelectedEntry,
_: &SelectParent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1951,6 +1961,68 @@ impl Sidebar {
}
}
fn toggle_selected_fold(
&mut self,
_: &editor::actions::ToggleFold,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(ix) = self.selection else { return };
// Find the group header for the current selection.
let header_ix = match self.contents.entries.get(ix) {
Some(ListEntry::ProjectHeader { .. }) => Some(ix),
Some(
ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
) => (0..ix).rev().find(|&i| {
matches!(
self.contents.entries.get(i),
Some(ListEntry::ProjectHeader { .. })
)
}),
None => None,
};
if let Some(header_ix) = header_ix {
if let Some(ListEntry::ProjectHeader { path_list, .. }) =
self.contents.entries.get(header_ix)
{
let path_list = path_list.clone();
if self.collapsed_groups.contains(&path_list) {
self.collapsed_groups.remove(&path_list);
} else {
self.selection = Some(header_ix);
self.collapsed_groups.insert(path_list);
}
self.update_entries(false, cx);
}
}
}
fn fold_all(
&mut self,
_: &editor::actions::FoldAll,
_window: &mut Window,
cx: &mut Context<Self>,
) {
for entry in &self.contents.entries {
if let ListEntry::ProjectHeader { path_list, .. } = entry {
self.collapsed_groups.insert(path_list.clone());
}
}
self.update_entries(false, cx);
}
fn unfold_all(
&mut self,
_: &editor::actions::UnfoldAll,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.collapsed_groups.clear();
self.update_entries(false, cx);
}
fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return;
@@ -2670,6 +2742,9 @@ impl Render for Sidebar {
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::expand_selected_entry))
.on_action(cx.listener(Self::collapse_selected_entry))
.on_action(cx.listener(Self::toggle_selected_fold))
.on_action(cx.listener(Self::fold_all))
.on_action(cx.listener(Self::unfold_all))
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::remove_selected_thread))
.on_action(cx.listener(Self::new_thread_in_group))
@@ -3654,7 +3729,7 @@ mod tests {
sidebar.selection = Some(0);
});
cx.dispatch_action(CollapseSelectedEntry);
cx.dispatch_action(SelectParent);
cx.run_until_parked();
assert_eq!(
@@ -3663,7 +3738,7 @@ mod tests {
);
// Press right to expand
cx.dispatch_action(ExpandSelectedEntry);
cx.dispatch_action(SelectChild);
cx.run_until_parked();
assert_eq!(
@@ -3676,7 +3751,7 @@ mod tests {
);
// Press right again on already-expanded header moves selection down
cx.dispatch_action(ExpandSelectedEntry);
cx.dispatch_action(SelectChild);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
}
@@ -3709,7 +3784,7 @@ mod tests {
);
// Pressing left on a child collapses the parent group and selects it
cx.dispatch_action(CollapseSelectedEntry);
cx.dispatch_action(SelectParent);
cx.run_until_parked();
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
@@ -3773,7 +3848,7 @@ mod tests {
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
// Collapse the group, which removes the thread from the list
cx.dispatch_action(CollapseSelectedEntry);
cx.dispatch_action(SelectParent);
cx.run_until_parked();
// Selection should be clamped to the last valid index (0 = header)
@@ -4383,12 +4458,12 @@ mod tests {
cx.run_until_parked();
// User focuses the sidebar and collapses the group using keyboard:
// manually select the header, then press CollapseSelectedEntry to collapse.
// manually select the header, then press SelectParent to collapse.
open_and_focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, _window, _cx| {
sidebar.selection = Some(0);
});
cx.dispatch_action(CollapseSelectedEntry);
cx.dispatch_action(SelectParent);
cx.run_until_parked();
assert_eq!(
+1
View File
@@ -30,6 +30,7 @@ impl VimTestContext {
theme::init(theme::LoadThemes::JustBase, cx);
settings_ui::init(cx);
markdown_preview::init(cx);
zed_actions::init();
});
}
+1 -1
View File
@@ -635,7 +635,7 @@ impl Vim {
fn activate(editor: &mut Editor, window: &mut Window, cx: &mut Context<Editor>) {
let vim = Vim::new(window, cx);
let state = vim.update(cx, |vim, cx| {
if !editor.mode().is_full() {
if !editor.use_modal_editing() {
vim.mode = Mode::Insert;
}
+5
View File
@@ -241,9 +241,11 @@ impl MultiWorkspace {
pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
self.sidebar_open = true;
let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
for workspace in &self.workspaces {
workspace.update(cx, |workspace, cx| {
workspace.set_workspace_sidebar_open(true, cx);
workspace.set_sidebar_focus_handle(sidebar_focus_handle.clone());
});
}
self.serialize(cx);
@@ -255,6 +257,7 @@ impl MultiWorkspace {
for workspace in &self.workspaces {
workspace.update(cx, |workspace, cx| {
workspace.set_workspace_sidebar_open(false, cx);
workspace.set_sidebar_focus_handle(None);
});
}
let pane = self.workspace().read(cx).active_pane().clone();
@@ -349,8 +352,10 @@ impl MultiWorkspace {
index
} else {
if self.sidebar_open {
let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
workspace.update(cx, |workspace, cx| {
workspace.set_workspace_sidebar_open(true, cx);
workspace.set_sidebar_focus_handle(sidebar_focus_handle);
});
}
Self::subscribe_to_workspace(&workspace, cx);
+56 -17
View File
@@ -1340,6 +1340,7 @@ pub struct Workspace {
last_open_dock_positions: Vec<DockPosition>,
removing: bool,
_panels_task: Option<Task<Result<()>>>,
sidebar_focus_handle: Option<FocusHandle>,
}
impl EventEmitter<Event> for Workspace {}
@@ -1745,6 +1746,7 @@ impl Workspace {
scheduled_tasks: Vec::new(),
last_open_dock_positions: Vec::new(),
removing: false,
sidebar_focus_handle: None,
}
}
@@ -2163,6 +2165,10 @@ impl Workspace {
});
}
pub fn set_sidebar_focus_handle(&mut self, handle: Option<FocusHandle>) {
self.sidebar_focus_handle = handle;
}
pub fn status_bar_visible(&self, cx: &App) -> bool {
StatusBarSettings::get_global(cx).show
}
@@ -4481,26 +4487,35 @@ impl Workspace {
) {
use ActivateInDirectionTarget as Target;
enum Origin {
Sidebar,
LeftDock,
RightDock,
BottomDock,
Center,
}
let origin: Origin = [
(&self.left_dock, Origin::LeftDock),
(&self.right_dock, Origin::RightDock),
(&self.bottom_dock, Origin::BottomDock),
]
.into_iter()
.find_map(|(dock, origin)| {
if dock.focus_handle(cx).contains_focused(window, cx) && dock.read(cx).is_open() {
Some(origin)
} else {
None
}
})
.unwrap_or(Origin::Center);
let origin: Origin = if self
.sidebar_focus_handle
.as_ref()
.is_some_and(|h| h.contains_focused(window, cx))
{
Origin::Sidebar
} else {
[
(&self.left_dock, Origin::LeftDock),
(&self.right_dock, Origin::RightDock),
(&self.bottom_dock, Origin::BottomDock),
]
.into_iter()
.find_map(|(dock, origin)| {
if dock.focus_handle(cx).contains_focused(window, cx) && dock.read(cx).is_open() {
Some(origin)
} else {
None
}
})
.unwrap_or(Origin::Center)
};
let get_last_active_pane = || {
let pane = self
@@ -4519,7 +4534,20 @@ impl Workspace {
let try_dock =
|dock: &Entity<Dock>| dock.read(cx).is_open().then(|| Target::Dock(dock.clone()));
let sidebar_target = self
.sidebar_focus_handle
.as_ref()
.map(|h| Target::Sidebar(h.clone()));
let target = match (origin, direction) {
// From the sidebar, only Right navigates into the workspace.
(Origin::Sidebar, SplitDirection::Right) => try_dock(&self.left_dock)
.or_else(|| get_last_active_pane().map(Target::Pane))
.or_else(|| try_dock(&self.bottom_dock))
.or_else(|| try_dock(&self.right_dock)),
(Origin::Sidebar, _) => None,
// We're in the center, so we first try to go to a different pane,
// otherwise try to go to a dock.
(Origin::Center, direction) => {
@@ -4529,7 +4557,7 @@ impl Workspace {
match direction {
SplitDirection::Up => None,
SplitDirection::Down => try_dock(&self.bottom_dock),
SplitDirection::Left => try_dock(&self.left_dock),
SplitDirection::Left => try_dock(&self.left_dock).or(sidebar_target),
SplitDirection::Right => try_dock(&self.right_dock),
}
}
@@ -4543,18 +4571,24 @@ impl Workspace {
}
}
(Origin::LeftDock, SplitDirection::Left) => sidebar_target,
(Origin::LeftDock, SplitDirection::Down)
| (Origin::RightDock, SplitDirection::Down) => try_dock(&self.bottom_dock),
(Origin::BottomDock, SplitDirection::Up) => get_last_active_pane().map(Target::Pane),
(Origin::BottomDock, SplitDirection::Left) => try_dock(&self.left_dock),
(Origin::BottomDock, SplitDirection::Left) => {
try_dock(&self.left_dock).or(sidebar_target)
}
(Origin::BottomDock, SplitDirection::Right) => try_dock(&self.right_dock),
(Origin::RightDock, SplitDirection::Left) => {
if let Some(last_active_pane) = get_last_active_pane() {
Some(Target::Pane(last_active_pane))
} else {
try_dock(&self.bottom_dock).or_else(|| try_dock(&self.left_dock))
try_dock(&self.bottom_dock)
.or_else(|| try_dock(&self.left_dock))
.or(sidebar_target)
}
}
@@ -4583,6 +4617,9 @@ impl Workspace {
}
})
}
Some(ActivateInDirectionTarget::Sidebar(focus_handle)) => {
focus_handle.focus(window, cx);
}
None => {}
}
}
@@ -7488,9 +7525,11 @@ fn open_items(
})
}
#[derive(Clone)]
enum ActivateInDirectionTarget {
Pane(Entity<Pane>),
Dock(Entity<Dock>),
Sidebar(FocusHandle),
}
fn notify_if_database_failed(window: WindowHandle<MultiWorkspace>, cx: &mut AsyncApp) {
+12
View File
@@ -776,6 +776,18 @@ pub mod preview {
}
}
pub mod agents_sidebar {
use gpui::actions;
actions!(
agents_sidebar,
[
/// Moves focus to the sidebar's search/filter editor.
FocusSidebarFilter,
]
);
}
pub mod notebook {
use gpui::actions;