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

Cameron Mcloughlin created

Change summary

Cargo.lock                              |   1 
assets/keymaps/default-linux.json       |   4 
assets/keymaps/default-macos.json       |   4 
assets/keymaps/default-windows.json     |   4 
assets/keymaps/vim.json                 |  27 ++++++
crates/sidebar/Cargo.toml               |   1 
crates/sidebar/src/sidebar.rs           | 111 ++++++++++++++++++++++----
crates/vim/src/test/vim_test_context.rs |   1 
crates/vim/src/vim.rs                   |   2 
crates/workspace/src/multi_workspace.rs |   5 +
crates/workspace/src/workspace.rs       |  73 +++++++++++++----
crates/zed_actions/src/lib.rs           |  12 ++
12 files changed, 203 insertions(+), 42 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -15901,6 +15901,7 @@ dependencies = [
  "theme",
  "ui",
  "util",
+ "vim_mode_setting",
  "workspace",
  "zed_actions",
 ]

assets/keymaps/default-linux.json πŸ”—

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

assets/keymaps/default-macos.json πŸ”—

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

assets/keymaps/default-windows.json πŸ”—

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

assets/keymaps/vim.json πŸ”—

@@ -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",
+    },
+  },
 ]

crates/sidebar/Cargo.toml πŸ”—

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

crates/sidebar/src/sidebar.rs πŸ”—

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

crates/vim/src/vim.rs πŸ”—

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

crates/workspace/src/multi_workspace.rs πŸ”—

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

crates/workspace/src/workspace.rs πŸ”—

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

crates/zed_actions/src/lib.rs πŸ”—

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