diff --git a/Cargo.lock b/Cargo.lock index 73614e6257995ba65fcc6ce46b1ad995fd821485..fc7acbbb8fdb870b951b61090b7238d453b03e12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15901,6 +15901,7 @@ dependencies = [ "theme", "ui", "util", + "vim_mode_setting", "workspace", "zed_actions", ] diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 24299f6a4c0dba33f8c1417ea2e688b33c9b9152..2fc50fe8f8d01dc0a1dd2086d8b57ba8dbe0ff01 100644 --- a/assets/keymaps/default-linux.json +++ b/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", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index b58b35d2b50ce9fa12695ac8fa20968072eb2c01..4f400565825d8c1cb05bd4874a4bfe95bbc0c4d5 100644 --- a/assets/keymaps/default-macos.json +++ b/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", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 426076a497055dbc350231d2889ae53006076582..1254febb1cf2afbaa8773e6b9d5f852451d6d62b 100644 --- a/assets/keymaps/default-windows.json +++ b/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", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 11b18040bb3f1e25eb8d5c148d023d181a1dbf6b..c8f1d67cfb07b2cdca0b4d2c5872e98b012c0516 100644 --- a/assets/keymaps/vim.json +++ b/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", + }, + }, ] diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 5b897f4d55c702263fa9098f3d0a987c08b0baa6..e790d2b74d97eddfe781a9f048c47038a61db893 100644 --- a/crates/sidebar/Cargo.toml +++ b/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 diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 5624ae3169122dab98e76ee0b2ff3ce056d73a17..9f8f6d453ad485710c856ba874a3b5efb0558b89 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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, ) { @@ -1918,7 +1928,7 @@ impl Sidebar { fn collapse_selected_entry( &mut self, - _: &CollapseSelectedEntry, + _: &SelectParent, _window: &mut Window, cx: &mut Context, ) { @@ -1951,6 +1961,68 @@ impl Sidebar { } } + fn toggle_selected_fold( + &mut self, + _: &editor::actions::ToggleFold, + _window: &mut Window, + cx: &mut Context, + ) { + 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, + ) { + 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.collapsed_groups.clear(); + self.update_entries(false, cx); + } + fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { 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!( diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 2d5ed4227dcc263f56cfa0bcb337f5673df8ef3c..d8574bb1b76b707fe9d36545ea054480cf097d64 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -30,6 +30,7 @@ impl VimTestContext { theme::init(theme::LoadThemes::JustBase, cx); settings_ui::init(cx); markdown_preview::init(cx); + zed_actions::init(); }); } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index c1058f5738915359b107865bf99d9f2c73f2085d..9261abaf5d896944655018df5a70782759f7fcfa 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -635,7 +635,7 @@ impl Vim { fn activate(editor: &mut Editor, window: &mut Window, cx: &mut Context) { 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; } diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index ee26041c50389a7375d96f40003ec59e91adea12..2028a2e28c1b1a539562a195b0d3737a9f739fc5 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -241,9 +241,11 @@ impl MultiWorkspace { pub fn open_sidebar(&mut self, cx: &mut Context) { 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); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0dfd5b2a1c1d4e3027d2d9ed099be5cb2ffbd4d0..d6549a1a0578a439d848cc1956a2e437008a17ca 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1340,6 +1340,7 @@ pub struct Workspace { last_open_dock_positions: Vec, removing: bool, _panels_task: Option>>, + sidebar_focus_handle: Option, } impl EventEmitter 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) { + 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.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), Dock(Entity), + Sidebar(FocusHandle), } fn notify_if_database_failed(window: WindowHandle, cx: &mut AsyncApp) { diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index f01361ecea54561fd30e6dbe8aa01cc99b725a43..fe1575bd155031c82c0610e2d96f2dc7e1a6ec3d 100644 --- a/crates/zed_actions/src/lib.rs +++ b/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;