From aec3c2fbb71254113da801f2fec952bb426f7ce1 Mon Sep 17 00:00:00 2001 From: Ivan Trubach Date: Fri, 17 Oct 2025 02:09:04 +0300 Subject: [PATCH] workspace: Move panes to span the entire border in Vim mode (#39123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, ⌃w + HJKL keystrokes swap active pane with another pane in that direction. Also, if there is no pane to swap with, nothing happens. This does not match the expected Vim behavior: moving the split to span the entire border. See https://github.com/vim/vim/blob/ca6a260ef1a4b4ae94bc71c17cbabf8f12bf0f8c/runtime/doc/windows.txt#L527-L549 This change adds `MovePane{Up,Down,Left,Right}` actions that do exactly that and updates default Vim keymap.
Before After
Vim
Release Notes: - Changed `ctrl+w` + `shift-[hjkl]` in Vim mode to move the split to span the entire border, aligning with Vim‘s behavior. Signed-off-by: Ivan Trubach --- assets/keymaps/vim.json | 8 +-- crates/terminal_view/src/terminal_panel.rs | 28 +++++++- crates/workspace/src/pane_group.rs | 74 +++++++++++++++++++++- crates/workspace/src/workspace.rs | 30 +++++++++ 4 files changed, 130 insertions(+), 10 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index ac83f906627912e0938e892ca0a8afcac395b856..bd8e07d1a1e36cfce97580422ea7b1ebd275e35c 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -831,10 +831,10 @@ "ctrl-w shift-right": "workspace::SwapPaneRight", "ctrl-w shift-up": "workspace::SwapPaneUp", "ctrl-w shift-down": "workspace::SwapPaneDown", - "ctrl-w shift-h": "workspace::SwapPaneLeft", - "ctrl-w shift-l": "workspace::SwapPaneRight", - "ctrl-w shift-k": "workspace::SwapPaneUp", - "ctrl-w shift-j": "workspace::SwapPaneDown", + "ctrl-w shift-h": "workspace::MovePaneLeft", + "ctrl-w shift-l": "workspace::MovePaneRight", + "ctrl-w shift-k": "workspace::MovePaneUp", + "ctrl-w shift-j": "workspace::MovePaneDown", "ctrl-w >": "vim::ResizePaneRight", "ctrl-w <": "vim::ResizePaneLeft", "ctrl-w -": "vim::ResizePaneDown", diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index e146a9cdb9d602344aba5889fd4837bb56ccd9e0..bd718d1432cfeb178355663bc657fd53fc37d57b 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -29,9 +29,9 @@ use util::{ResultExt, TryFutureExt}; use workspace::{ ActivateNextPane, ActivatePane, ActivatePaneDown, ActivatePaneLeft, ActivatePaneRight, ActivatePaneUp, ActivatePreviousPane, DraggedSelection, DraggedTab, ItemId, MoveItemToPane, - MoveItemToPaneInDirection, NewTerminal, Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, - SplitRight, SplitUp, SwapPaneDown, SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, - Workspace, + MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, NewTerminal, + Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitRight, SplitUp, SwapPaneDown, + SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace, dock::{DockPosition, Panel, PanelEvent, PanelHandle}, item::SerializableItem, move_active_item, move_item, pane, @@ -1055,6 +1055,16 @@ impl TerminalPanel { cx.notify(); } } + + fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { + if self + .center + .move_to_border(&self.active_pane, direction) + .unwrap() + { + cx.notify(); + } + } } fn is_enabled_in_workspace(workspace: &Workspace, cx: &App) -> bool { @@ -1404,6 +1414,18 @@ impl Render for TerminalPanel { .on_action(cx.listener(|terminal_panel, _: &SwapPaneDown, _, cx| { terminal_panel.swap_pane_in_direction(SplitDirection::Down, cx); })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneLeft, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Left, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneRight, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Right, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneUp, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Up, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneDown, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Down, cx); + })) .on_action( cx.listener(|terminal_panel, action: &MoveItemToPane, window, cx| { let Some(&target_pane) = diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 127eae6de07670c265597a6f6df3a286487a9c64..88ec56de6fa2e890d11afbda58cc3a69ab1d6a60 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -79,6 +79,56 @@ impl PaneGroup { } } + /// Moves active pane to span the entire border in the given direction, + /// similar to Vim ctrl+w shift-[hjkl] motion. + /// + /// Returns: + /// - Ok(true) if it found and moved a pane + /// - Ok(false) if it found but did not move the pane + /// - Err(_) if it did not find the pane + pub fn move_to_border( + &mut self, + active_pane: &Entity, + direction: SplitDirection, + ) -> Result { + if let Some(pane) = self.find_pane_at_border(direction) + && pane == active_pane + { + return Ok(false); + } + + if !self.remove(active_pane)? { + return Ok(false); + } + + if let Member::Axis(root) = &mut self.root + && direction.axis() == root.axis + { + let idx = if direction.increasing() { + root.members.len() + } else { + 0 + }; + root.insert_pane(idx, active_pane); + return Ok(true); + } + + let members = if direction.increasing() { + vec![self.root.clone(), Member::Pane(active_pane.clone())] + } else { + vec![Member::Pane(active_pane.clone()), self.root.clone()] + }; + self.root = Member::Axis(PaneAxis::new(direction.axis(), members)); + Ok(true) + } + + fn find_pane_at_border(&self, direction: SplitDirection) -> Option<&Entity> { + match &self.root { + Member::Pane(pane) => Some(pane), + Member::Axis(axis) => axis.find_pane_at_border(direction), + } + } + /// Returns: /// - Ok(true) if it found and removed a pane /// - Ok(false) if it found but did not remove the pane @@ -526,9 +576,7 @@ impl PaneAxis { if direction.increasing() { idx += 1; } - - self.members.insert(idx, Member::Pane(new_pane.clone())); - *self.flexes.lock() = vec![1.; self.members.len()]; + self.insert_pane(idx, new_pane); } else { *member = Member::new_axis(old_pane.clone(), new_pane.clone(), direction); @@ -541,6 +589,26 @@ impl PaneAxis { anyhow::bail!("Pane not found"); } + fn insert_pane(&mut self, idx: usize, new_pane: &Entity) { + self.members.insert(idx, Member::Pane(new_pane.clone())); + *self.flexes.lock() = vec![1.; self.members.len()]; + } + + fn find_pane_at_border(&self, direction: SplitDirection) -> Option<&Entity> { + if self.axis != direction.axis() { + return None; + } + let member = if direction.increasing() { + self.members.last() + } else { + self.members.first() + }; + member.and_then(|e| match e { + Member::Pane(pane) => Some(pane), + Member::Axis(_) => None, + }) + } + fn remove(&mut self, pane_to_remove: &Entity) -> Result> { let mut found_pane = false; let mut remove_member = None; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 086554335f2733a7e6c8d7976d65acbaeb044050..e6e087a98e803df16f240486d7650eab6ec62c61 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -421,6 +421,14 @@ actions!( SwapPaneUp, /// Swaps the current pane with the one below. SwapPaneDown, + /// Move the current pane to be at the far left. + MovePaneLeft, + /// Move the current pane to be at the far right. + MovePaneRight, + /// Move the current pane to be at the very top. + MovePaneUp, + /// Move the current pane to be at the very bottom. + MovePaneDown, ] ); @@ -3866,6 +3874,16 @@ impl Workspace { } } + pub fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { + if self + .center + .move_to_border(&self.active_pane, direction) + .unwrap() + { + cx.notify(); + } + } + pub fn resize_pane( &mut self, axis: gpui::Axis, @@ -5674,6 +5692,18 @@ impl Workspace { .on_action(cx.listener(|workspace, _: &SwapPaneDown, _, cx| { workspace.swap_pane_in_direction(SplitDirection::Down, cx) })) + .on_action(cx.listener(|workspace, _: &MovePaneLeft, _, cx| { + workspace.move_pane_to_border(SplitDirection::Left, cx) + })) + .on_action(cx.listener(|workspace, _: &MovePaneRight, _, cx| { + workspace.move_pane_to_border(SplitDirection::Right, cx) + })) + .on_action(cx.listener(|workspace, _: &MovePaneUp, _, cx| { + workspace.move_pane_to_border(SplitDirection::Up, cx) + })) + .on_action(cx.listener(|workspace, _: &MovePaneDown, _, cx| { + workspace.move_pane_to_border(SplitDirection::Down, cx) + })) .on_action(cx.listener(|this, _: &ToggleLeftDock, window, cx| { this.toggle_dock(DockPosition::Left, window, cx); }))