aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-05 21:59:10 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-05 22:06:02 +0300
commit556b16b2df7ea2c3e9b8ad13db4ad7370c1ad838 (patch)
tree0a6f61701f2c8992c6cd1ee6d61171ef92a92533 /src
parentae3c88ab8c64931740d89623f688f7d3f408da5e (diff)
feat: add pane navigation controls
refs #421 refs #330 related discussions: https://github.com/ogulcancelik/herdr/discussions/422 https://github.com/ogulcancelik/herdr/discussions/438 https://github.com/ogulcancelik/herdr/discussions/442
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs3
-rw-r--r--src/api/schema.rs208
-rw-r--r--src/api/server.rs6
-rw-r--r--src/app/actions.rs120
-rw-r--r--src/app/api.rs8
-rw-r--r--src/app/api/panes.rs920
-rw-r--r--src/app/input/modal.rs107
-rw-r--r--src/app/input/mouse.rs138
-rw-r--r--src/app/input/navigate.rs41
-rw-r--r--src/app/input/terminal.rs10
-rw-r--r--src/app/mod.rs125
-rw-r--r--src/app/state.rs38
-rw-r--r--src/cli/pane.rs530
-rw-r--r--src/config/keybinds.rs36
-rw-r--r--src/config/model.rs12
-rw-r--r--src/layout.rs440
-rw-r--r--src/persist/snapshot.rs2
-rw-r--r--src/workspace.rs62
-rw-r--r--src/workspace/tab.rs33
19 files changed, 2695 insertions, 144 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 7fd16fda..f0cf42c7 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -37,6 +37,9 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::AgentFocus(_)
| Method::AgentStart(_)
| Method::PaneSplit(_)
+ | Method::PaneSwap(_)
+ | Method::PaneFocusDirection(_)
+ | Method::PaneResize(_)
| Method::PaneRename(_)
| Method::PaneReportAgent(_)
| Method::PaneReportAgentSession(_)
diff --git a/src/api/schema.rs b/src/api/schema.rs
index a0c3ca24..ba2fd1e5 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -2,14 +2,14 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Request {
pub id: String,
#[serde(flatten)]
pub method: Method,
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "method", content = "params")]
pub enum Method {
#[serde(rename = "ping")]
@@ -68,6 +68,18 @@ pub enum Method {
AgentStart(AgentStartParams),
#[serde(rename = "pane.split")]
PaneSplit(PaneSplitParams),
+ #[serde(rename = "pane.swap")]
+ PaneSwap(PaneSwapParams),
+ #[serde(rename = "pane.layout")]
+ PaneLayout(PaneLayoutParams),
+ #[serde(rename = "pane.neighbor")]
+ PaneNeighbor(PaneNeighborParams),
+ #[serde(rename = "pane.edges")]
+ PaneEdges(PaneEdgesParams),
+ #[serde(rename = "pane.focus_direction")]
+ PaneFocusDirection(PaneFocusDirectionParams),
+ #[serde(rename = "pane.resize")]
+ PaneResize(PaneResizeParams),
#[serde(rename = "pane.list")]
PaneList(PaneListParams),
#[serde(rename = "pane.get")]
@@ -262,13 +274,16 @@ pub struct AgentStartParams {
pub argv: Vec<String>,
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PaneSplitParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
- pub target_pane_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub target_pane_id: Option<String>,
pub direction: SplitDirection,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub ratio: Option<f32>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default)]
pub focus: bool,
@@ -281,6 +296,62 @@ pub enum SplitDirection {
Down,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneDirection {
+ Left,
+ Right,
+ Up,
+ Down,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PaneSwapParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub direction: Option<PaneDirection>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source_pane_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub target_pane_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PaneLayoutParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneNeighborParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ pub direction: PaneDirection,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct PaneEdgesParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneFocusDirectionParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ pub direction: PaneDirection,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneResizeParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ pub direction: PaneDirection,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub amount: Option<f32>,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PaneListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -628,7 +699,7 @@ pub enum EventKind {
PaneAgentStatusChanged,
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SuccessResponse {
pub id: String,
pub result: ResponseResult,
@@ -651,7 +722,7 @@ pub struct ServerCapabilities {
pub live_handoff: bool,
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseResult {
Pong {
@@ -719,6 +790,24 @@ pub enum ResponseResult {
PaneList {
panes: Vec<PaneInfo>,
},
+ PaneSwap {
+ swap: PaneSwapResult,
+ },
+ PaneLayout {
+ layout: PaneLayoutSnapshot,
+ },
+ PaneNeighbor {
+ neighbor: PaneNeighborResult,
+ },
+ PaneEdges {
+ edges: PaneEdgesResult,
+ },
+ PaneFocusDirection {
+ focus: PaneFocusDirectionResult,
+ },
+ PaneResize {
+ resize: PaneResizeResult,
+ },
PaneRead {
read: PaneReadResult,
},
@@ -863,6 +952,113 @@ pub struct PaneInfo {
pub revision: u64,
}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneSwapResult {
+ pub changed: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reason: Option<PaneSwapReason>,
+ pub source_pane_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub target_pane_id: Option<String>,
+ pub focused_pane_id: String,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneSwapReason {
+ NoNeighbor,
+ SamePane,
+ NotFound,
+ CrossTab,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneLayoutSnapshot {
+ pub workspace_id: String,
+ pub tab_id: String,
+ pub zoomed: bool,
+ pub area: PaneLayoutRect,
+ pub focused_pane_id: String,
+ pub panes: Vec<PaneLayoutPane>,
+ pub splits: Vec<PaneLayoutSplit>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneLayoutRect {
+ pub x: u16,
+ pub y: u16,
+ pub width: u16,
+ pub height: u16,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneLayoutPane {
+ pub pane_id: String,
+ pub focused: bool,
+ pub rect: PaneLayoutRect,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneLayoutSplit {
+ pub id: String,
+ pub direction: SplitDirection,
+ pub ratio: f32,
+ pub rect: PaneLayoutRect,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneNeighborResult {
+ pub pane_id: String,
+ pub direction: PaneDirection,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub neighbor_pane_id: Option<String>,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneEdgesResult {
+ pub pane_id: String,
+ pub left: bool,
+ pub right: bool,
+ pub up: bool,
+ pub down: bool,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneFocusDirectionResult {
+ pub changed: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reason: Option<PaneFocusDirectionReason>,
+ pub source_pane_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub focused_pane_id: Option<String>,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneFocusDirectionReason {
+ NoNeighbor,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneResizeResult {
+ pub changed: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reason: Option<PaneResizeReason>,
+ pub pane_id: String,
+ pub focused_pane_id: String,
+ pub layout: PaneLayoutSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneResizeReason {
+ Unchanged,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentSessionInfo {
pub source: String,
diff --git a/src/api/server.rs b/src/api/server.rs
index c51b46e8..44bb3097 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -295,6 +295,12 @@ fn api_method_name(method: &Method) -> &'static str {
Method::AgentFocus(_) => "agent.focus",
Method::AgentStart(_) => "agent.start",
Method::PaneSplit(_) => "pane.split",
+ Method::PaneSwap(_) => "pane.swap",
+ Method::PaneLayout(_) => "pane.layout",
+ Method::PaneNeighbor(_) => "pane.neighbor",
+ Method::PaneEdges(_) => "pane.edges",
+ Method::PaneFocusDirection(_) => "pane.focus_direction",
+ Method::PaneResize(_) => "pane.resize",
Method::PaneList(_) => "pane.list",
Method::PaneGet(_) => "pane.get",
Method::PaneRename(_) => "pane.rename",
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 9bce43ed..8124be93 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1371,6 +1371,41 @@ impl AppState {
}
}
+ pub fn swap_pane(&mut self, direction: NavDirection) -> bool {
+ let Some(ws_idx) = self.active else {
+ return false;
+ };
+ let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else {
+ return false;
+ };
+ let panes = if tab.zoomed {
+ tab.layout.panes(self.view.terminal_area)
+ } else {
+ self.view.pane_infos.clone()
+ };
+
+ let Some(focused) = panes.iter().find(|p| p.is_focused) else {
+ return false;
+ };
+ let Some(target) = find_in_direction(focused, direction, &panes) else {
+ return false;
+ };
+ let source = focused.id;
+ let Some(tab) = self
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.active_tab_mut())
+ else {
+ return false;
+ };
+ if tab.layout.swap_panes(source, target) {
+ self.mark_session_dirty();
+ true
+ } else {
+ false
+ }
+ }
+
pub fn resize_pane(&mut self, direction: NavDirection) {
if let Some(first) = self.view.pane_infos.first() {
let area = self
@@ -4093,6 +4128,91 @@ mod tests {
}
#[test]
+ fn swap_pane_direction_preserves_focus_and_swaps_layout_cells() {
+ let mut state = app_with_workspaces(&["test"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
+ let right = state.workspaces[0].test_split(Direction::Horizontal);
+ state.workspaces[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let before_root_rect = state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == root)
+ .unwrap()
+ .rect;
+ let before_right_rect = state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == right)
+ .unwrap()
+ .rect;
+
+ assert!(state.swap_pane(NavDirection::Right));
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
+ assert_eq!(
+ state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == root)
+ .unwrap()
+ .rect,
+ before_right_rect
+ );
+ assert_eq!(
+ state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == right)
+ .unwrap()
+ .rect,
+ before_root_rect
+ );
+ }
+
+ #[test]
+ fn swap_pane_direction_stays_zoomed_and_mutates_hidden_layout() {
+ let mut state = app_with_workspaces(&["test"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
+ let right = state.workspaces[0].test_split(Direction::Horizontal);
+ state.workspaces[0].layout.focus_pane(root);
+ state.workspaces[0].zoomed = true;
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+
+ assert!(state.swap_pane(NavDirection::Right));
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+
+ assert!(state.workspaces[0].zoomed);
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
+ assert_eq!(state.view.pane_infos.len(), 1);
+ assert_eq!(state.view.pane_infos[0].id, root);
+
+ state.workspaces[0].zoomed = false;
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_rect = state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == root)
+ .unwrap()
+ .rect;
+ let right_rect = state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == right)
+ .unwrap()
+ .rect;
+
+ assert!(root_rect.x > right_rect.x);
+ }
+
+ #[test]
fn close_pane_removes_from_workspace() {
let mut state = app_with_workspaces(&["test"]);
state.workspaces[0].test_split(Direction::Horizontal);
diff --git a/src/app/api.rs b/src/app/api.rs
index 733fb8b8..35994bff 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -555,6 +555,14 @@ impl App {
Method::AgentRead(params) => return self.handle_agent_read(request.id, params),
Method::AgentSend(params) => return self.handle_agent_send(request.id, params),
Method::PaneSplit(params) => return self.handle_pane_split(request.id, params),
+ Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params),
+ Method::PaneLayout(params) => return self.handle_pane_layout(request.id, params),
+ Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params),
+ Method::PaneEdges(params) => return self.handle_pane_edges(request.id, params),
+ Method::PaneFocusDirection(params) => {
+ return self.handle_pane_focus_direction(request.id, params);
+ }
+ Method::PaneResize(params) => return self.handle_pane_resize(request.id, params),
Method::PaneList(params) => return self.handle_pane_list(request.id, params),
Method::PaneGet(target) => return self.handle_pane_get(request.id, target),
Method::PaneRename(params) => return self.handle_pane_rename(request.id, params),
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index d027f732..23272ffd 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -1,13 +1,18 @@
use bytes::Bytes;
use crate::api::schema::{
- EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneListParams,
- PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams,
- PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams,
- PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget,
- ReadFormat, ReadSource, ResponseResult,
+ EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneDirection,
+ PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, PaneFocusDirectionReason,
+ PaneFocusDirectionResult, PaneLayoutPane, PaneLayoutParams, PaneLayoutRect, PaneLayoutSnapshot,
+ PaneLayoutSplit, PaneListParams, PaneNeighborParams, PaneNeighborResult, PaneReadParams,
+ PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams,
+ PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason,
+ PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
+ PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, ReadFormat, ReadSource,
+ ResponseResult,
};
use crate::app::{App, Mode};
+use crate::layout::{find_in_direction, NavDirection, PaneId};
use super::super::api_helpers::{
detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status,
@@ -17,8 +22,21 @@ use super::responses::{encode_error, encode_success};
impl App {
pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String {
- let Some((ws_idx, target_pane_id)) = self.parse_pane_id(&params.target_pane_id) else {
- return pane_not_found(id, &params.target_pane_id);
+ let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() {
+ self.parse_pane_id(target_pane_id)
+ } else if let Some(workspace_id) = params.workspace_id.as_deref() {
+ self.parse_workspace_id(workspace_id).and_then(|ws_idx| {
+ let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?;
+ Some((ws_idx, pane_id))
+ })
+ } else {
+ self.state.active.and_then(|ws_idx| {
+ let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?;
+ Some((ws_idx, pane_id))
+ })
+ };
+ let Some((ws_idx, target_pane_id)) = target else {
+ return encode_error(id, "pane_not_found", "pane not found");
};
let (rows, cols) = self.state.estimate_pane_size();
let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| {
@@ -37,26 +55,42 @@ impl App {
let host_terminal_theme = self.state.host_terminal_theme;
let previous_focus = self.state.current_pane_focus_target();
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
- return pane_not_found(id, &params.target_pane_id);
+ return encode_error(id, "pane_not_found", "pane not found");
};
let direction = match params.direction {
crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal,
crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical,
};
- let (target_tab_idx, new_pane) = match ws.split_pane(
- target_pane_id,
- direction,
- rows,
- cols,
- split_cwd,
- scrollback_limit_bytes,
- host_terminal_theme,
- crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode),
- params.focus,
- ) {
+ let shell_config = crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode);
+ let split_result = match params.ratio {
+ Some(ratio) => ws.split_pane_with_ratio(
+ target_pane_id,
+ direction,
+ ratio,
+ rows,
+ cols,
+ split_cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ shell_config,
+ params.focus,
+ ),
+ None => ws.split_pane(
+ target_pane_id,
+ direction,
+ rows,
+ cols,
+ split_cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ shell_config,
+ params.focus,
+ ),
+ };
+ let (target_tab_idx, new_pane) = match split_result {
Some(Ok(result)) => result,
Some(Err(err)) => return encode_error(id, "pane_split_failed", err.to_string()),
- None => return pane_not_found(id, &params.target_pane_id),
+ None => return encode_error(id, "pane_not_found", "pane not found"),
};
if params.focus {
self.state.switch_workspace_tab(ws_idx, target_tab_idx);
@@ -99,6 +133,390 @@ impl App {
encode_success(id, ResponseResult::PaneInfo { pane })
}
+ pub(super) fn handle_pane_layout(&mut self, id: String, params: PaneLayoutParams) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+
+ encode_success(id, ResponseResult::PaneLayout { layout })
+ }
+
+ pub(super) fn handle_pane_neighbor(
+ &mut self,
+ id: String,
+ params: PaneNeighborParams,
+ ) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(source_public_id) = self.public_pane_id(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let neighbor_pane_id = self
+ .directional_pane_target(ws_idx, tab_idx, pane_id, params.direction)
+ .and_then(|pane_id| self.public_pane_id(ws_idx, pane_id));
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+
+ encode_success(
+ id,
+ ResponseResult::PaneNeighbor {
+ neighbor: PaneNeighborResult {
+ pane_id: source_public_id,
+ direction: params.direction,
+ neighbor_pane_id,
+ layout,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_pane_edges(&mut self, id: String, params: PaneEdgesParams) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(tab) = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.get(tab_idx))
+ else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let area = self.state.view.terminal_area;
+ let Some(info) = tab
+ .layout
+ .panes(area)
+ .into_iter()
+ .find(|info| info.id == pane_id)
+ else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(pane_public_id) = self.public_pane_id(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+
+ encode_success(
+ id,
+ ResponseResult::PaneEdges {
+ edges: PaneEdgesResult {
+ pane_id: pane_public_id,
+ left: info.rect.x <= area.x,
+ right: info.rect.x + info.rect.width >= area.x + area.width,
+ up: info.rect.y <= area.y,
+ down: info.rect.y + info.rect.height >= area.y + area.height,
+ layout,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_pane_focus_direction(
+ &mut self,
+ id: String,
+ params: PaneFocusDirectionParams,
+ ) -> String {
+ let Some((ws_idx, source_pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref())
+ else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(source_pane_id)
+ else {
+ return pane_not_found(
+ id,
+ &self
+ .public_pane_id(ws_idx, source_pane_id)
+ .unwrap_or_default(),
+ );
+ };
+ let Some(source_public_id) = self.public_pane_id(ws_idx, source_pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let target =
+ self.directional_pane_target(ws_idx, tab_idx, source_pane_id, params.direction);
+ let reason = target
+ .is_none()
+ .then_some(PaneFocusDirectionReason::NoNeighbor);
+
+ if let Some(target_pane_id) = target {
+ self.state.focus_pane_in_workspace(ws_idx, target_pane_id);
+ self.state.switch_workspace_tab(ws_idx, tab_idx);
+ self.state.mode = Mode::Terminal;
+ }
+ let focused_pane_id = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.get(tab_idx))
+ .map(|tab| tab.layout.focused())
+ .and_then(|pane_id| self.public_pane_id(ws_idx, pane_id));
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+
+ encode_success(
+ id,
+ ResponseResult::PaneFocusDirection {
+ focus: PaneFocusDirectionResult {
+ changed: target.is_some(),
+ reason,
+ source_pane_id: source_public_id,
+ focused_pane_id,
+ layout,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_pane_resize(&mut self, id: String, params: PaneResizeParams) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(pane_public_id) = self.public_pane_id(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+
+ let amount = params
+ .amount
+ .filter(|amount| amount.is_finite())
+ .unwrap_or(0.05)
+ .abs()
+ .min(0.5);
+ let direction: NavDirection = params.direction.into();
+ let area = self.state.view.terminal_area;
+ let changed = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ .is_some_and(|tab| tab.layout.resize_pane(pane_id, direction, amount, area));
+ if changed {
+ self.schedule_session_save();
+ }
+
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let focused_pane_id = layout.focused_pane_id.clone();
+
+ encode_success(
+ id,
+ ResponseResult::PaneResize {
+ resize: PaneResizeResult {
+ changed,
+ reason: (!changed).then_some(PaneResizeReason::Unchanged),
+ pane_id: pane_public_id,
+ focused_pane_id,
+ layout,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_pane_swap(&mut self, id: String, params: PaneSwapParams) -> String {
+ let directional = params.direction.is_some();
+ let explicit = params.source_pane_id.is_some() || params.target_pane_id.is_some();
+ if directional == explicit {
+ return encode_error(
+ id,
+ "invalid_pane_swap",
+ "provide either direction with optional pane_id, or source_pane_id and target_pane_id",
+ );
+ }
+
+ let (ws_idx, tab_idx, source_pane_id, target_pane_id, reason) = if let Some(direction) =
+ params.direction
+ {
+ let Some((ws_idx, source_pane_id)) =
+ self.resolve_swap_source(params.pane_id.as_deref())
+ else {
+ return encode_error(id, "pane_not_found", "source pane not found");
+ };
+ let Some(tab_idx) =
+ self.state.workspaces[ws_idx].find_tab_index_for_pane(source_pane_id)
+ else {
+ return pane_not_found(
+ id,
+ &self
+ .public_pane_id(ws_idx, source_pane_id)
+ .unwrap_or_default(),
+ );
+ };
+ let target = self.directional_pane_target(ws_idx, tab_idx, source_pane_id, direction);
+ match target {
+ Some(target_pane_id) => {
+ (ws_idx, tab_idx, source_pane_id, Some(target_pane_id), None)
+ }
+ None => (
+ ws_idx,
+ tab_idx,
+ source_pane_id,
+ None,
+ Some(PaneSwapReason::NoNeighbor),
+ ),
+ }
+ } else {
+ let Some(source_raw) = params.source_pane_id.as_deref() else {
+ return encode_error(id, "invalid_pane_swap", "missing source_pane_id");
+ };
+ let Some(target_raw) = params.target_pane_id.as_deref() else {
+ return encode_error(id, "invalid_pane_swap", "missing target_pane_id");
+ };
+ let source = self
+ .parse_pane_id(source_raw)
+ .and_then(|(ws_idx, pane_id)| {
+ let tab_idx = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id)?;
+ Some((ws_idx, tab_idx, pane_id))
+ });
+ let target = self
+ .parse_pane_id(target_raw)
+ .and_then(|(ws_idx, pane_id)| {
+ let tab_idx = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id)?;
+ Some((ws_idx, tab_idx, pane_id))
+ });
+ let response_context = source
+ .map(|(ws_idx, tab_idx, _)| (ws_idx, tab_idx))
+ .or_else(|| target.map(|(ws_idx, tab_idx, _)| (ws_idx, tab_idx)))
+ .or_else(|| {
+ let ws_idx = self.state.active?;
+ let tab_idx = self.state.workspaces.get(ws_idx)?.active_tab_index();
+ Some((ws_idx, tab_idx))
+ });
+ let Some((ws_idx, tab_idx)) = response_context else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let source_pane_id = source
+ .map(|(_, _, pane_id)| pane_id)
+ .or_else(|| {
+ self.state
+ .workspaces
+ .get(ws_idx)?
+ .tabs
+ .get(tab_idx)
+ .map(|tab| tab.layout.focused())
+ })
+ .unwrap_or(PaneId::from_raw(0));
+ let target_pane_id = target.map(|(_, _, pane_id)| pane_id);
+ let reason = match (source, target) {
+ (None, _) | (_, None) => Some(PaneSwapReason::NotFound),
+ (Some((_, _, source)), Some((_, _, target))) if source == target => {
+ Some(PaneSwapReason::SamePane)
+ }
+ (Some((source_ws, source_tab, _)), Some((target_ws, target_tab, _)))
+ if source_ws != target_ws || source_tab != target_tab =>
+ {
+ Some(PaneSwapReason::CrossTab)
+ }
+ _ => None,
+ };
+ (ws_idx, tab_idx, source_pane_id, target_pane_id, reason)
+ };
+
+ let mut changed = false;
+ if reason.is_none() {
+ if let Some(target_pane_id) = target_pane_id {
+ let previous_focus = self.state.current_pane_focus_target();
+ if let Some(tab) = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ {
+ changed = tab.layout.swap_panes(source_pane_id, target_pane_id);
+ tab.layout.focus_pane(source_pane_id);
+ if changed {
+ self.state.switch_workspace_tab(ws_idx, tab_idx);
+ self.state
+ .record_pane_focus_change(previous_focus, ws_idx, source_pane_id);
+ self.state.mark_session_dirty();
+ self.schedule_session_save();
+ }
+ }
+ }
+ }
+
+ let source_public_id = match params.source_pane_id {
+ Some(raw) => self
+ .parse_pane_id(&raw)
+ .and_then(|(idx, pane_id)| {
+ self.state
+ .workspaces
+ .get(idx)?
+ .find_tab_index_for_pane(pane_id)?;
+ self.public_pane_id(idx, pane_id)
+ })
+ .unwrap_or(raw),
+ None => self
+ .public_pane_id(ws_idx, source_pane_id)
+ .unwrap_or_default(),
+ };
+ let target_public_id = match params.target_pane_id {
+ Some(raw) => self
+ .parse_pane_id(&raw)
+ .and_then(|(idx, pane_id)| {
+ self.state
+ .workspaces
+ .get(idx)?
+ .find_tab_index_for_pane(pane_id)?;
+ self.public_pane_id(idx, pane_id)
+ })
+ .or(Some(raw)),
+ None => target_pane_id.and_then(|pane_id| self.public_pane_id(ws_idx, pane_id)),
+ };
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let focused_pane_id = layout.focused_pane_id.clone();
+
+ encode_success(
+ id,
+ ResponseResult::PaneSwap {
+ swap: PaneSwapResult {
+ changed,
+ reason,
+ source_pane_id: source_public_id,
+ target_pane_id: target_public_id,
+ focused_pane_id,
+ layout,
+ },
+ },
+ )
+ }
+
pub(super) fn handle_pane_rename(&mut self, id: String, params: PaneRenameParams) -> String {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
@@ -526,6 +944,118 @@ fn pane_not_found(id: String, pane_id: &str) -> String {
encode_error(id, "pane_not_found", format!("pane {pane_id} not found"))
}
+impl App {
+ fn resolve_optional_pane(&self, pane_id: Option<&str>) -> Option<(usize, PaneId)> {
+ match pane_id {
+ Some(pane_id) => self.parse_pane_id(pane_id),
+ None => {
+ let ws_idx = self.state.active?;
+ let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?;
+ Some((ws_idx, pane_id))
+ }
+ }
+ }
+
+ fn resolve_swap_source(&self, pane_id: Option<&str>) -> Option<(usize, PaneId)> {
+ self.resolve_optional_pane(pane_id)
+ }
+
+ fn directional_pane_target(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ source_pane_id: PaneId,
+ direction: PaneDirection,
+ ) -> Option<PaneId> {
+ let tab = self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?;
+ let panes = tab.layout.panes(self.state.view.terminal_area);
+ let source = panes.iter().find(|pane| pane.id == source_pane_id)?;
+ find_in_direction(source, direction.into(), &panes)
+ }
+
+ fn pane_layout_snapshot(&self, ws_idx: usize, tab_idx: usize) -> Option<PaneLayoutSnapshot> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ let area = self.state.view.terminal_area;
+ let focused_pane_id = self.public_pane_id(ws_idx, tab.layout.focused())?;
+ let panes = tab
+ .layout
+ .panes(area)
+ .into_iter()
+ .filter_map(|pane| {
+ Some(PaneLayoutPane {
+ pane_id: self.public_pane_id(ws_idx, pane.id)?,
+ focused: pane.is_focused,
+ rect: pane.rect.into(),
+ })
+ })
+ .collect();
+ let splits = tab
+ .layout
+ .splits(area)
+ .into_iter()
+ .enumerate()
+ .map(|(idx, split)| PaneLayoutSplit {
+ id: split_path_id(idx, &split.path),
+ direction: match split.direction {
+ ratatui::layout::Direction::Horizontal => {
+ crate::api::schema::SplitDirection::Right
+ }
+ ratatui::layout::Direction::Vertical => {
+ crate::api::schema::SplitDirection::Down
+ }
+ },
+ ratio: split.ratio,
+ rect: split.area.into(),
+ })
+ .collect();
+
+ Some(PaneLayoutSnapshot {
+ workspace_id: self.public_workspace_id(ws_idx),
+ tab_id: self.public_tab_id(ws_idx, tab_idx)?,
+ zoomed: tab.zoomed,
+ area: area.into(),
+ focused_pane_id,
+ panes,
+ splits,
+ })
+ }
+}
+
+impl From<PaneDirection> for NavDirection {
+ fn from(direction: PaneDirection) -> Self {
+ match direction {
+ PaneDirection::Left => NavDirection::Left,
+ PaneDirection::Right => NavDirection::Right,
+ PaneDirection::Up => NavDirection::Up,
+ PaneDirection::Down => NavDirection::Down,
+ }
+ }
+}
+
+impl From<ratatui::layout::Rect> for PaneLayoutRect {
+ fn from(rect: ratatui::layout::Rect) -> Self {
+ Self {
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height,
+ }
+ }
+}
+
+fn split_path_id(idx: usize, path: &[bool]) -> String {
+ if path.is_empty() {
+ return format!("split_{idx}_root");
+ }
+ let path = path
+ .iter()
+ .map(|right| if *right { "1" } else { "0" })
+ .collect::<Vec<_>>()
+ .join("");
+ format!("split_{idx}_{path}")
+}
+
fn invalid_agent(id: String) -> String {
encode_error(id, "invalid_agent", "agent label must not be empty")
}
@@ -573,4 +1103,354 @@ mod tests {
assert_eq!(app.state.request_remove_linked_worktree, None);
assert!(app.state.workspaces.is_empty());
}
+
+ #[test]
+ fn api_pane_swap_explicit_source_and_target_preserves_focus_and_returns_layout() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(source);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let target_public = app.public_pane_id(0, target).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ source_pane_id: Some(source_public.clone()),
+ target_pane_id: Some(target_public.clone()),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(swap.changed);
+ assert_eq!(swap.reason, None);
+ assert_eq!(swap.source_pane_id, source_public);
+ assert_eq!(swap.target_pane_id, Some(target_public));
+ assert_eq!(swap.focused_pane_id, swap.source_pane_id);
+ assert_eq!(swap.layout.focused_pane_id, swap.source_pane_id);
+ assert_eq!(swap.layout.panes.len(), 2);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source));
+ }
+
+ #[test]
+ fn api_pane_swap_unfocused_source_updates_last_pane_history() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let focused = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Vertical);
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.workspaces[0].tabs[0].layout.focus_pane(focused);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let target_public = app.public_pane_id(0, target).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ source_pane_id: Some(source_public),
+ target_pane_id: Some(target_public),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(swap.changed);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source));
+
+ app.state.last_pane();
+
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(focused));
+ }
+
+ #[test]
+ fn api_pane_swap_direction_no_neighbor_returns_unchanged_layout() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ app.state.workspaces[0].tabs[0].layout.focus_pane(source);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let source_public = app.public_pane_id(0, source).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ pane_id: Some(source_public.clone()),
+ direction: Some(PaneDirection::Left),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(!swap.changed);
+ assert_eq!(swap.reason, Some(PaneSwapReason::NoNeighbor));
+ assert_eq!(swap.source_pane_id, source_public);
+ assert_eq!(swap.target_pane_id, None);
+ assert_eq!(swap.layout.panes.len(), 1);
+ }
+
+ #[test]
+ fn api_pane_swap_explicit_missing_target_returns_not_found_noop() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let source_public = app.public_pane_id(0, source).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ source_pane_id: Some(source_public.clone()),
+ target_pane_id: Some("missing-pane".into()),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(!swap.changed);
+ assert_eq!(swap.reason, Some(PaneSwapReason::NotFound));
+ assert_eq!(swap.source_pane_id, source_public);
+ assert_eq!(swap.target_pane_id, Some("missing-pane".into()));
+ assert_eq!(swap.layout.panes.len(), 1);
+ }
+
+ #[test]
+ fn api_pane_swap_explicit_missing_source_returns_not_found_noop() {
+ let mut app = app_with_linked_worktree();
+ let target = app.state.workspaces[0].tabs[0].root_pane;
+ let target_public = app.public_pane_id(0, target).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ source_pane_id: Some("missing-pane".into()),
+ target_pane_id: Some(target_public.clone()),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(!swap.changed);
+ assert_eq!(swap.reason, Some(PaneSwapReason::NotFound));
+ assert_eq!(swap.source_pane_id, "missing-pane");
+ assert_eq!(swap.target_pane_id, Some(target_public));
+ assert_eq!(swap.layout.panes.len(), 1);
+ }
+
+ #[test]
+ fn api_pane_swap_explicit_cross_workspace_preserves_target_id() {
+ let mut app = app_with_linked_worktree();
+ app.state.workspaces.push(Workspace::test_new("other"));
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let target = app.state.workspaces[1].tabs[0].root_pane;
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let target_public = app.public_pane_id(1, target).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ source_pane_id: Some(source_public.clone()),
+ target_pane_id: Some(target_public.clone()),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(!swap.changed);
+ assert_eq!(swap.reason, Some(PaneSwapReason::CrossTab));
+ assert_eq!(swap.source_pane_id, source_public);
+ assert_eq!(swap.target_pane_id, Some(target_public));
+ assert_eq!(swap.layout.workspace_id, app.public_workspace_id(0));
+ }
+
+ #[test]
+ fn api_pane_layout_returns_public_ids_rects_and_splits() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_layout(
+ "req".into(),
+ crate::api::schema::PaneLayoutParams {
+ pane_id: Some(root_public.clone()),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneLayout { layout } = success.result else {
+ panic!("expected pane layout response");
+ };
+ assert_eq!(layout.focused_pane_id, root_public);
+ assert!(layout.panes.iter().any(|pane| pane.pane_id == root_public));
+ assert!(layout.panes.iter().any(|pane| pane.pane_id == right_public));
+ assert_eq!(layout.splits.len(), 1);
+ assert_eq!(
+ layout.splits[0].direction,
+ crate::api::schema::SplitDirection::Right
+ );
+ }
+
+ #[test]
+ fn api_pane_neighbor_returns_directional_neighbor_public_id() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_neighbor(
+ "req".into(),
+ crate::api::schema::PaneNeighborParams {
+ pane_id: Some(root_public.clone()),
+ direction: PaneDirection::Right,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneNeighbor { neighbor } = success.result else {
+ panic!("expected pane neighbor response");
+ };
+ assert_eq!(neighbor.pane_id, root_public);
+ assert_eq!(neighbor.direction, PaneDirection::Right);
+ assert_eq!(neighbor.neighbor_pane_id, Some(right_public));
+ }
+
+ #[test]
+ fn api_pane_edges_reports_physical_layout_edges() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_edges(
+ "req".into(),
+ crate::api::schema::PaneEdgesParams {
+ pane_id: Some(right_public.clone()),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneEdges { edges } = success.result else {
+ panic!("expected pane edges response");
+ };
+ assert_eq!(edges.pane_id, right_public);
+ assert!(!edges.left);
+ assert!(edges.right);
+ assert!(edges.up);
+ assert!(edges.down);
+ }
+
+ #[test]
+ fn api_pane_resize_changes_target_ratio_without_changing_focus() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(right);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_resize(
+ "req".into(),
+ crate::api::schema::PaneResizeParams {
+ pane_id: Some(root_public.clone()),
+ direction: PaneDirection::Right,
+ amount: Some(0.1),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneResize { resize } = success.result else {
+ panic!("expected pane resize response");
+ };
+ assert!(resize.changed);
+ assert_eq!(resize.reason, None);
+ assert_eq!(resize.pane_id, root_public);
+ assert_eq!(resize.focused_pane_id, right_public);
+ assert_eq!(resize.layout.focused_pane_id, right_public);
+ assert!((resize.layout.splits[0].ratio - 0.6).abs() < f32::EPSILON);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right));
+ }
+
+ #[test]
+ fn api_pane_focus_direction_focuses_neighbor() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_focus_direction(
+ "req".into(),
+ crate::api::schema::PaneFocusDirectionParams {
+ pane_id: Some(root_public.clone()),
+ direction: PaneDirection::Right,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneFocusDirection { focus } = success.result else {
+ panic!("expected pane focus direction response");
+ };
+ assert!(focus.changed);
+ assert_eq!(focus.reason, None);
+ assert_eq!(focus.source_pane_id, root_public);
+ assert_eq!(focus.focused_pane_id, Some(right_public.clone()));
+ assert_eq!(focus.layout.focused_pane_id, right_public);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right));
+ }
+
+ #[test]
+ fn api_pane_focus_direction_no_neighbor_is_noop() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_pane_focus_direction(
+ "req".into(),
+ crate::api::schema::PaneFocusDirectionParams {
+ pane_id: Some(root_public.clone()),
+ direction: PaneDirection::Left,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneFocusDirection { focus } = success.result else {
+ panic!("expected pane focus direction response");
+ };
+ assert!(!focus.changed);
+ assert_eq!(focus.reason, Some(PaneFocusDirectionReason::NoNeighbor));
+ assert_eq!(focus.source_pane_id, root_public.clone());
+ assert_eq!(focus.focused_pane_id, Some(root_public));
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
+ }
}
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index 334b008e..5b179e9b 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -722,33 +722,111 @@ pub(super) fn apply_context_menu_action(
(ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => {
open_rename_pane(state, pane_id);
}
- (ContextMenuKind::Pane { pane_id, .. }, Some("Clear pane name")) => {
- if let Some(ws_idx) = state.active {
- if let Some(ws) = state.workspaces.get(ws_idx) {
- if let Some(pane) = ws.pane_state(pane_id) {
- let terminal_id = pane.attached_terminal_id.clone();
- if let Some(terminal) = state.terminals.get_mut(&terminal_id) {
- terminal.clear_manual_label();
- state.mark_session_dirty();
- }
+ (
+ ContextMenuKind::Pane {
+ ws_idx, pane_id, ..
+ },
+ Some("Clear pane name"),
+ ) => {
+ if let Some(ws) = state.workspaces.get(ws_idx) {
+ if let Some(pane) = ws.pane_state(pane_id) {
+ let terminal_id = pane.attached_terminal_id.clone();
+ if let Some(terminal) = state.terminals.get_mut(&terminal_id) {
+ terminal.clear_manual_label();
+ state.mark_session_dirty();
}
}
}
state.mode = Mode::Terminal;
}
- (ContextMenuKind::Pane { .. }, Some("Split vertical")) => {
+ (
+ ContextMenuKind::Pane {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ source_pane_id,
+ ..
+ },
+ Some("Swap with focused pane"),
+ ) => {
+ if let Some(source_pane_id) = source_pane_id {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ if let Some(tab) = state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ {
+ if tab.layout.swap_panes(source_pane_id, pane_id) {
+ tab.layout.focus_pane(source_pane_id);
+ state.mark_session_dirty();
+ }
+ }
+ }
+ state.mode = Mode::Terminal;
+ }
+ (
+ ContextMenuKind::Pane {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ ..
+ },
+ Some("Split right"),
+ ) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ state.focus_pane_in_workspace(ws_idx, pane_id);
state.split_pane(terminal_runtimes, Direction::Horizontal);
state.mode = Mode::Terminal;
}
- (ContextMenuKind::Pane { .. }, Some("Split horizontal")) => {
+ (
+ ContextMenuKind::Pane {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ ..
+ },
+ Some("Split down"),
+ ) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ state.focus_pane_in_workspace(ws_idx, pane_id);
state.split_pane(terminal_runtimes, Direction::Vertical);
state.mode = Mode::Terminal;
}
- (ContextMenuKind::Pane { .. }, Some("Zoom")) => {
+ (
+ ContextMenuKind::Pane {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ ..
+ },
+ Some("Zoom"),
+ ) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ state.focus_pane_in_workspace(ws_idx, pane_id);
state.toggle_zoom();
state.mode = Mode::Terminal;
}
- (ContextMenuKind::Pane { .. }, Some("Close pane")) => {
+ (
+ ContextMenuKind::Pane {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ ..
+ },
+ Some("Close pane"),
+ ) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ state.focus_pane_in_workspace(ws_idx, pane_id);
if !state.close_pane() {
state.mode = if state.active.is_some() {
Mode::Terminal
@@ -1301,7 +1379,10 @@ mod tests {
let pane_id = state.workspaces[0].tabs[0].root_pane;
let menu = ContextMenuState {
kind: ContextMenuKind::Pane {
+ ws_idx: 0,
+ tab_idx: 0,
pane_id,
+ source_pane_id: None,
has_manual_label: false,
},
x: 0,
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 51a9763e..9272c279 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -950,7 +950,6 @@ impl AppState {
if let (Some(ws_idx), Some(tab_idx)) =
(self.active, self.tab_at(mouse.column, mouse.row))
{
- self.switch_tab(tab_idx);
self.context_menu = Some(ContextMenuState {
kind: ContextMenuKind::Tab { ws_idx, tab_idx },
x: mouse.column,
@@ -963,17 +962,30 @@ impl AppState {
MouseEventKind::Down(MouseButton::Right) if !in_sidebar => {
if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
- self.focus_pane(info.id);
+ let ws_idx = self.active?;
+ let tab_idx = self
+ .workspaces
+ .get(ws_idx)
+ .map(|ws| ws.active_tab_index())?;
+ let previous_focused_pane_id = self
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.focused_pane_id());
+ let source_pane_id =
+ previous_focused_pane_id.filter(|pane_id| *pane_id != info.id);
let has_manual_label = self
- .active
- .and_then(|ws_idx| self.workspaces.get(ws_idx))
+ .workspaces
+ .get(ws_idx)
.and_then(|ws| ws.pane_state(info.id))
.and_then(|pane| self.terminals.get(&pane.attached_terminal_id))
.and_then(|terminal| terminal.manual_label.as_ref())
.is_some();
self.context_menu = Some(ContextMenuState {
kind: ContextMenuKind::Pane {
+ ws_idx,
+ tab_idx,
pane_id: info.id,
+ source_pane_id,
has_manual_label,
},
x: mouse.column,
@@ -1997,6 +2009,90 @@ mod tests {
}
#[tokio::test]
+ async fn pane_right_click_keeps_focus_and_swap_menu_swaps_with_focused_pane() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let source = ws.tabs[0].root_pane;
+ let target = ws.test_split(Direction::Horizontal);
+ ws.tabs[0].layout.focus_pane(source);
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20));
+ let target_info = app
+ .state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == target)
+ .expect("target pane info")
+ .clone();
+ let source_rect_before = app
+ .state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == source)
+ .expect("source pane info")
+ .rect;
+ let target_rect_before = target_info.rect;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Right),
+ target_info.inner_rect.x,
+ target_info.inner_rect.y,
+ ));
+
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source));
+ let menu = app.state.context_menu.as_mut().expect("pane context menu");
+ assert!(matches!(
+ menu.kind,
+ ContextMenuKind::Pane {
+ pane_id,
+ source_pane_id: Some(source_pane_id),
+ ..
+ } if pane_id == target && source_pane_id == source
+ ));
+ let swap_idx = menu
+ .items()
+ .iter()
+ .position(|item| *item == "Swap with focused pane")
+ .expect("swap item");
+ menu.list.highlighted = swap_idx;
+
+ handle_context_menu_key(
+ &mut app.state,
+ &mut app.terminal_runtimes,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20));
+
+ assert_eq!(app.state.mode, Mode::Terminal);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source));
+ assert_eq!(
+ app.state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == source)
+ .unwrap()
+ .rect,
+ target_rect_before
+ );
+ assert_eq!(
+ app.state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == target)
+ .unwrap()
+ .rect,
+ source_rect_before
+ );
+ }
+
+ #[tokio::test]
async fn right_click_passthrough_requires_exact_modifier_match() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
@@ -2446,7 +2542,10 @@ mod tests {
let runtime_count = app.terminal_runtimes.len();
app.state.context_menu = Some(ContextMenuState {
kind: ContextMenuKind::Pane {
+ ws_idx: 0,
+ tab_idx: 0,
pane_id,
+ source_pane_id: None,
has_manual_label: false,
},
x: 2,
@@ -2796,6 +2895,37 @@ mod tests {
}
#[test]
+ fn right_click_inactive_tab_opens_menu_without_switching_tabs() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("one");
+ ws.test_add_tab(Some("two"));
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let second_tab = app.state.view.tab_hit_areas[1];
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Right),
+ second_tab.x + 1,
+ second_tab.y,
+ ));
+
+ assert_eq!(app.state.workspaces[0].active_tab, 0);
+ let menu = app.state.context_menu.as_ref().expect("tab context menu");
+ assert_eq!(
+ menu.kind,
+ ContextMenuKind::Tab {
+ ws_idx: 0,
+ tab_idx: 1
+ }
+ );
+ assert_eq!(app.state.mode, Mode::ContextMenu);
+ }
+
+ #[test]
fn wheel_over_overflowing_tab_bar_switches_tabs() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("one");
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 2ac86aeb..7f803825 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -499,6 +499,10 @@ pub(crate) enum NavigateAction {
FocusPaneDown,
FocusPaneUp,
FocusPaneRight,
+ SwapPaneLeft,
+ SwapPaneDown,
+ SwapPaneUp,
+ SwapPaneRight,
SplitVertical,
SplitHorizontal,
ClosePane,
@@ -601,6 +605,10 @@ fn action_for_key(
(&kb.focus_pane_down, NavigateAction::FocusPaneDown),
(&kb.focus_pane_up, NavigateAction::FocusPaneUp),
(&kb.focus_pane_right, NavigateAction::FocusPaneRight),
+ (&kb.swap_pane_left, NavigateAction::SwapPaneLeft),
+ (&kb.swap_pane_down, NavigateAction::SwapPaneDown),
+ (&kb.swap_pane_up, NavigateAction::SwapPaneUp),
+ (&kb.swap_pane_right, NavigateAction::SwapPaneRight),
(&kb.last_pane, NavigateAction::LastPane),
(&kb.cycle_pane_next, NavigateAction::CyclePaneNext),
(&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious),
@@ -778,6 +786,22 @@ pub(super) fn execute_navigate_action_in_context(
NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down),
NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up),
NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right),
+ NavigateAction::SwapPaneLeft => {
+ state.swap_pane(NavDirection::Left);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::SwapPaneDown => {
+ state.swap_pane(NavDirection::Down);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::SwapPaneUp => {
+ state.swap_pane(NavDirection::Up);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::SwapPaneRight => {
+ state.swap_pane(NavDirection::Right);
+ leave_navigate_mode(state);
+ }
NavigateAction::SplitVertical => {
state.split_pane(terminal_runtimes, Direction::Horizontal);
leave_navigate_mode(state);
@@ -1501,6 +1525,19 @@ navigate_pane_right = "ctrl+l"
}
#[test]
+ fn terminal_direct_swap_pane_shortcut_maps_to_navigation_action() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.swap_pane_right = crate::config::ActionKeybinds::direct("alt+shift+l");
+
+ let action = terminal_direct_navigation_action(
+ &state,
+ TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT | KeyModifiers::SHIFT),
+ );
+
+ assert_eq!(action, Some(NavigateAction::SwapPaneRight));
+ }
+
+ #[test]
fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
state.keybinds.last_pane = crate::config::ActionKeybinds::direct("alt+l");
@@ -1788,11 +1825,11 @@ last_pane = "prefix+tab"
#[test]
fn modified_navigate_local_key_can_be_bound_as_prefix_rhs() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+h");
+ state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+u");
handle_navigate_key(
&mut state,
- KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT),
+ KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT),
);
assert!(state.sidebar_collapsed);
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 99ae3bf1..c60a9f48 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -652,7 +652,7 @@ mod tests {
}
#[tokio::test]
- async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_right_button() {
+ async fn right_clicking_unfocused_mouse_reporting_pane_keeps_focus_for_context_menu() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let first_pane = ws.tabs[0].root_pane;
@@ -702,12 +702,10 @@ mod tests {
second_info.inner_rect.y + 2,
));
- assert_eq!(
- app.state.workspaces[0].tabs[0].layout.focused(),
- second_pane
- );
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), first_pane);
assert_eq!(app.state.mode, Mode::ContextMenu);
- assert!(app.state.context_menu.is_some());
+ let menu = app.state.context_menu.as_ref().expect("pane context menu");
+ assert!(menu.items().contains(&"Swap with focused pane"));
}
#[tokio::test]
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 1914c895..a9408736 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -2590,12 +2590,40 @@ mod tests {
crate::api::schema::WorktreeCreateParams::default(),
),
};
+ let pane_swap = crate::api::schema::Request {
+ id: "req_6".into(),
+ method: crate::api::schema::Method::PaneSwap(crate::api::schema::PaneSwapParams {
+ pane_id: Some("w_1-1".into()),
+ direction: Some(crate::api::schema::PaneDirection::Right),
+ ..crate::api::schema::PaneSwapParams::default()
+ }),
+ };
+ let pane_focus_direction = crate::api::schema::Request {
+ id: "req_7".into(),
+ method: crate::api::schema::Method::PaneFocusDirection(
+ crate::api::schema::PaneFocusDirectionParams {
+ pane_id: Some("w_1-1".into()),
+ direction: crate::api::schema::PaneDirection::Right,
+ },
+ ),
+ };
+ let pane_resize = crate::api::schema::Request {
+ id: "req_8".into(),
+ method: crate::api::schema::Method::PaneResize(crate::api::schema::PaneResizeParams {
+ pane_id: Some("w_1-1".into()),
+ direction: crate::api::schema::PaneDirection::Right,
+ amount: Some(0.05),
+ }),
+ };
assert!(!crate::api::request_changes_ui(&read_only));
assert!(!crate::api::request_changes_ui(&worktree_list));
assert!(crate::api::request_changes_ui(&mutating));
assert!(crate::api::request_changes_ui(&pane_rename));
assert!(crate::api::request_changes_ui(&worktree_create));
+ assert!(crate::api::request_changes_ui(&pane_swap));
+ assert!(crate::api::request_changes_ui(&pane_focus_direction));
+ assert!(crate::api::request_changes_ui(&pane_resize));
}
#[test]
@@ -2936,8 +2964,9 @@ mod tests {
id: "req_pane_split_background_tab".into(),
method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams {
workspace_id: None,
- target_pane_id,
+ target_pane_id: Some(target_pane_id),
direction: crate::api::schema::SplitDirection::Right,
+ ratio: None,
cwd: None,
focus: false,
}),
@@ -3014,8 +3043,9 @@ mod tests {
id: "req_pane_split_focus_background_tab".into(),
method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams {
workspace_id: None,
- target_pane_id,
+ target_pane_id: Some(target_pane_id),
direction: crate::api::schema::SplitDirection::Right,
+ ratio: None,
cwd: None,
focus: true,
}),
@@ -3039,6 +3069,97 @@ mod tests {
}
#[tokio::test]
+ async fn pane_split_request_applies_ratio() {
+ let _guard = config_env_lock().lock().unwrap();
+ let original_shell = std::env::var_os("SHELL");
+ std::env::set_var("SHELL", "/usr/bin/true");
+
+ let mut app = test_app();
+ let workspace = Workspace::test_new("api-pane-split-ratio");
+ let target_pane = workspace.tabs[0].root_pane;
+ app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+
+ let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id;
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "req_pane_split_ratio".into(),
+ method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams {
+ workspace_id: None,
+ target_pane_id: Some(target_pane_id),
+ direction: crate::api::schema::SplitDirection::Right,
+ ratio: Some(0.333),
+ cwd: None,
+ focus: false,
+ }),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+
+ assert_eq!(response["result"]["type"], "pane_info");
+ let splits = app.state.workspaces[0].tabs[0]
+ .layout
+ .splits(ratatui::layout::Rect::new(0, 0, 100, 20));
+ assert_eq!(splits.len(), 1);
+ assert!((splits[0].ratio - 0.333).abs() < f32::EPSILON);
+
+ let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
+ for (_terminal_id, runtime) in runtimes {
+ runtime.shutdown();
+ }
+ match original_shell {
+ Some(value) => std::env::set_var("SHELL", value),
+ None => std::env::remove_var("SHELL"),
+ }
+ }
+
+ #[tokio::test]
+ async fn pane_split_request_uses_active_focused_pane_when_target_is_omitted() {
+ let _guard = config_env_lock().lock().unwrap();
+ let original_shell = std::env::var_os("SHELL");
+ std::env::set_var("SHELL", "/usr/bin/true");
+
+ let mut app = test_app();
+ let workspace = Workspace::test_new("api-pane-split-current");
+ let target_pane = workspace.tabs[0].root_pane;
+ app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.focus_pane_in_workspace(0, target_pane);
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "req_pane_split_current".into(),
+ method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams {
+ workspace_id: None,
+ target_pane_id: None,
+ direction: crate::api::schema::SplitDirection::Right,
+ ratio: None,
+ cwd: None,
+ focus: false,
+ }),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+
+ assert_eq!(response["result"]["type"], "pane_info");
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].layout.focused(),
+ target_pane
+ );
+
+ let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
+ for (_terminal_id, runtime) in runtimes {
+ runtime.shutdown();
+ }
+ match original_shell {
+ Some(value) => std::env::set_var("SHELL", value),
+ None => std::env::remove_var("SHELL"),
+ }
+ }
+
+ #[tokio::test]
async fn focused_agent_start_records_previous_pane() {
let mut app = test_app();
let workspace = Workspace::test_new("agent-start-focus");
diff --git a/src/app/state.rs b/src/app/state.rs
index a25c7c5e..e06fb3f5 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1052,7 +1052,10 @@ pub enum ContextMenuKind {
tab_idx: usize,
},
Pane {
+ ws_idx: usize,
+ tab_idx: usize,
pane_id: PaneId,
+ source_pane_id: Option<PaneId>,
has_manual_label: bool,
},
}
@@ -1105,22 +1108,49 @@ impl ContextMenuState {
ContextMenuKind::Tab { .. } => &["New tab", "Rename", "Close"],
ContextMenuKind::Pane {
has_manual_label: true,
+ source_pane_id: Some(_),
+ ..
+ } => &[
+ "Rename pane",
+ "Clear pane name",
+ "Swap with focused pane",
+ "Split right",
+ "Split down",
+ "Zoom",
+ "Close pane",
+ ],
+ ContextMenuKind::Pane {
+ has_manual_label: false,
+ source_pane_id: Some(_),
+ ..
+ } => &[
+ "Rename pane",
+ "Swap with focused pane",
+ "Split right",
+ "Split down",
+ "Zoom",
+ "Close pane",
+ ],
+ ContextMenuKind::Pane {
+ has_manual_label: true,
+ source_pane_id: None,
..
} => &[
"Rename pane",
"Clear pane name",
- "Split vertical",
- "Split horizontal",
+ "Split right",
+ "Split down",
"Zoom",
"Close pane",
],
ContextMenuKind::Pane {
has_manual_label: false,
+ source_pane_id: None,
..
} => &[
"Rename pane",
- "Split vertical",
- "Split horizontal",
+ "Split right",
+ "Split down",
"Zoom",
"Close pane",
],
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index d759fa0d..1a9b20e1 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -1,7 +1,9 @@
use crate::api::schema::{
- Method, PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams,
- PaneReportMetadataParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams,
- PaneSplitParams, PaneTarget, ReadFormat, ReadSource, Request,
+ Method, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, PaneLayoutParams,
+ PaneListParams, PaneNeighborParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams,
+ PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams,
+ PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, ReadFormat, ReadSource,
+ Request,
};
pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
@@ -13,9 +15,15 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
match subcommand {
"list" => pane_list(&args[1..]),
"get" => pane_get(&args[1..]),
+ "layout" => pane_layout(&args[1..]),
+ "neighbor" => pane_neighbor(&args[1..]),
+ "edges" => pane_edges(&args[1..]),
+ "focus" => pane_focus(&args[1..]),
+ "resize" => pane_resize(&args[1..]),
"read" => pane_read(&args[1..]),
"rename" => pane_rename(&args[1..]),
"split" => pane_split(&args[1..]),
+ "swap" => pane_swap(&args[1..]),
"close" => pane_close(&args[1..]),
"send-text" => pane_send_text(&args[1..]),
"send-keys" => pane_send_keys(&args[1..]),
@@ -78,6 +86,209 @@ fn pane_get(args: &[String]) -> std::io::Result<i32> {
})?)
}
+fn pane_layout(args: &[String]) -> std::io::Result<i32> {
+ let pane_id = match parse_optional_current_pane_args(args) {
+ Ok(pane_id) => pane_id,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:layout".into(),
+ method: Method::PaneLayout(PaneLayoutParams { pane_id }),
+ })?)
+}
+
+fn pane_edges(args: &[String]) -> std::io::Result<i32> {
+ let pane_id = match parse_optional_current_pane_args(args) {
+ Ok(pane_id) => pane_id,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:edges".into(),
+ method: Method::PaneEdges(PaneEdgesParams { pane_id }),
+ })?)
+}
+
+fn pane_neighbor(args: &[String]) -> std::io::Result<i32> {
+ let params = match parse_pane_neighbor_args(args) {
+ Ok(params) => params,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:neighbor".into(),
+ method: Method::PaneNeighbor(params),
+ })?)
+}
+
+fn pane_focus(args: &[String]) -> std::io::Result<i32> {
+ let params = match parse_pane_focus_args(args) {
+ Ok(params) => params,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:focus".into(),
+ method: Method::PaneFocusDirection(params),
+ })?)
+}
+
+fn pane_resize(args: &[String]) -> std::io::Result<i32> {
+ let params = match parse_pane_resize_args(args) {
+ Ok(params) => params,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:resize".into(),
+ method: Method::PaneResize(params),
+ })?)
+}
+
+fn parse_optional_current_pane_args(args: &[String]) -> Result<Option<String>, String> {
+ let mut pane_id = None;
+ let mut index = 0;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--pane" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --pane".into());
+ };
+ pane_id = Some(super::normalize_pane_id(value));
+ index += 2;
+ }
+ "--current" => {
+ pane_id = None;
+ index += 1;
+ }
+ other => return Err(format!("unknown option: {other}")),
+ }
+ }
+ Ok(pane_id)
+}
+
+fn parse_pane_neighbor_args(args: &[String]) -> Result<PaneNeighborParams, String> {
+ let mut pane_id = None;
+ let mut direction = None;
+
+ let mut index = 0;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--pane" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --pane".into());
+ };
+ pane_id = Some(super::normalize_pane_id(value));
+ index += 2;
+ }
+ "--current" => {
+ pane_id = None;
+ index += 1;
+ }
+ "--direction" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --direction".into());
+ };
+ direction = Some(parse_pane_direction(value)?);
+ index += 2;
+ }
+ other => return Err(format!("unknown option: {other}")),
+ }
+ }
+
+ let Some(direction) = direction else {
+ return Err(
+ "usage: herdr pane neighbor --direction left|right|up|down [--pane ID|--current]"
+ .into(),
+ );
+ };
+
+ Ok(PaneNeighborParams { pane_id, direction })
+}
+
+fn parse_pane_focus_args(args: &[String]) -> Result<PaneFocusDirectionParams, String> {
+ let params = parse_pane_neighbor_args(args).map_err(|_| {
+ "usage: herdr pane focus --direction left|right|up|down [--pane ID|--current]".to_string()
+ })?;
+ Ok(PaneFocusDirectionParams {
+ pane_id: params.pane_id,
+ direction: params.direction,
+ })
+}
+
+fn parse_pane_resize_args(args: &[String]) -> Result<PaneResizeParams, String> {
+ let mut pane_id = None;
+ let mut direction = None;
+ let mut amount = None;
+
+ let mut index = 0;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--pane" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --pane".into());
+ };
+ pane_id = Some(super::normalize_pane_id(value));
+ index += 2;
+ }
+ "--current" => {
+ pane_id = None;
+ index += 1;
+ }
+ "--direction" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --direction".into());
+ };
+ direction = Some(parse_pane_direction(value)?);
+ index += 2;
+ }
+ "--amount" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --amount".into());
+ };
+ let parsed = value
+ .parse::<f32>()
+ .map_err(|_| format!("invalid amount: {value}"))?;
+ if !parsed.is_finite() {
+ return Err(format!("invalid amount: {value}"));
+ }
+ amount = Some(parsed);
+ index += 2;
+ }
+ other => return Err(format!("unknown option: {other}")),
+ }
+ }
+
+ let Some(direction) = direction else {
+ return Err(
+ "usage: herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current]"
+ .into(),
+ );
+ };
+
+ Ok(PaneResizeParams {
+ pane_id,
+ direction,
+ amount,
+ })
+}
+
fn pane_rename(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
eprintln!("usage: herdr pane rename <pane_id> <label>|--clear");
@@ -180,33 +391,72 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> {
}
fn pane_split(args: &[String]) -> std::io::Result<i32> {
- let Some(raw_pane_id) = args.first() else {
- eprintln!(
- "usage: herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]"
- );
- return Ok(2);
+ let params = match parse_pane_split_args(args) {
+ Ok(params) => params,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
};
- let pane_id = super::normalize_pane_id(raw_pane_id);
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:split".into(),
+ method: Method::PaneSplit(params),
+ })?)
+}
+
+fn parse_pane_split_args(args: &[String]) -> Result<PaneSplitParams, String> {
+ let mut pane_id = None;
let mut direction = None;
+ let mut ratio = None;
let mut cwd = None;
let mut focus = false;
- let mut index = 1;
+ let mut index = 0;
+ if args
+ .first()
+ .is_some_and(|arg| !arg.as_str().starts_with("--"))
+ {
+ pane_id = args.first().map(|arg| super::normalize_pane_id(arg));
+ index = 1;
+ }
while index < args.len() {
match args[index].as_str() {
+ "--pane" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --pane".into());
+ };
+ pane_id = Some(super::normalize_pane_id(value));
+ index += 2;
+ }
+ "--current" => {
+ pane_id = None;
+ index += 1;
+ }
"--direction" => {
let Some(value) = args.get(index + 1) else {
- eprintln!("missing value for --direction");
- return Ok(2);
+ return Err("missing value for --direction".into());
+ };
+ direction =
+ Some(super::parse_split_direction(value).map_err(|err| err.to_string())?);
+ index += 2;
+ }
+ "--ratio" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --ratio".into());
};
- direction = Some(super::parse_split_direction(value)?);
+ let parsed = value
+ .parse::<f32>()
+ .map_err(|_| format!("invalid ratio: {value}"))?;
+ if !parsed.is_finite() {
+ return Err(format!("invalid ratio: {value}"));
+ }
+ ratio = Some(parsed);
index += 2;
}
"--cwd" => {
let Some(value) = args.get(index + 1) else {
- eprintln!("missing value for --cwd");
- return Ok(2);
+ return Err("missing value for --cwd".into());
};
cwd = Some(value.clone());
index += 2;
@@ -219,28 +469,119 @@ fn pane_split(args: &[String]) -> std::io::Result<i32> {
focus = false;
index += 1;
}
- other => {
- eprintln!("unknown option: {other}");
- return Ok(2);
- }
+ other => return Err(format!("unknown option: {other}")),
}
}
let Some(direction) = direction else {
- eprintln!("missing required --direction");
- return Ok(2);
+ return Err(
+ "usage: herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus]"
+ .into(),
+ );
+ };
+
+ Ok(PaneSplitParams {
+ workspace_id: None,
+ target_pane_id: pane_id,
+ direction,
+ ratio,
+ cwd,
+ focus,
+ })
+}
+
+fn pane_swap(args: &[String]) -> std::io::Result<i32> {
+ let params = match parse_pane_swap_args(args) {
+ Ok(params) => params,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
};
super::print_response(&super::send_request(&Request {
- id: "cli:pane:split".into(),
- method: Method::PaneSplit(PaneSplitParams {
- workspace_id: None,
- target_pane_id: pane_id,
+ id: "cli:pane:swap".into(),
+ method: Method::PaneSwap(params),
+ })?)
+}
+
+fn parse_pane_swap_args(args: &[String]) -> Result<PaneSwapParams, String> {
+ let mut pane_id = None;
+ let mut direction = None;
+ let mut source_pane_id = None;
+ let mut target_pane_id = None;
+
+ let mut index = 0;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--pane" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --pane".into());
+ };
+ pane_id = Some(super::normalize_pane_id(value));
+ index += 2;
+ }
+ "--current" => {
+ pane_id = None;
+ index += 1;
+ }
+ "--direction" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --direction".into());
+ };
+ direction = Some(parse_pane_direction(value)?);
+ index += 2;
+ }
+ "--source-pane" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --source-pane".into());
+ };
+ source_pane_id = Some(super::normalize_pane_id(value));
+ index += 2;
+ }
+ "--target-pane" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --target-pane".into());
+ };
+ target_pane_id = Some(super::normalize_pane_id(value));
+ index += 2;
+ }
+ other => return Err(format!("unknown option: {other}")),
+ }
+ }
+
+ let directional = direction.is_some();
+ let explicit = source_pane_id.is_some() || target_pane_id.is_some();
+ match (directional, explicit) {
+ (true, false) => Ok(PaneSwapParams {
+ pane_id,
direction,
- cwd,
- focus,
+ ..PaneSwapParams::default()
}),
- })?)
+ (false, true) if pane_id.is_none() && source_pane_id.is_some() && target_pane_id.is_some() => {
+ Ok(PaneSwapParams {
+ source_pane_id,
+ target_pane_id,
+ ..PaneSwapParams::default()
+ })
+ }
+ _ => Err(
+ "usage: herdr pane swap --direction left|right|up|down [--pane ID|--current]\n herdr pane swap --source-pane ID --target-pane ID"
+ .into(),
+ ),
+ }
+}
+
+fn parse_pane_direction(value: &str) -> Result<PaneDirection, String> {
+ match value {
+ "left" => Ok(PaneDirection::Left),
+ "right" => Ok(PaneDirection::Right),
+ "up" => Ok(PaneDirection::Up),
+ "down" => Ok(PaneDirection::Down),
+ _ => Err(format!(
+ "invalid pane direction: {value} (expected left, right, up, or down)"
+ )),
+ }
}
fn pane_close(args: &[String]) -> std::io::Result<i32> {
@@ -605,11 +946,20 @@ fn print_pane_help() {
eprintln!("herdr pane commands:");
eprintln!(" herdr pane list [--workspace <workspace_id>]");
eprintln!(" herdr pane get <pane_id>");
+ eprintln!(" herdr pane layout [--pane ID|--current]");
+ eprintln!(" herdr pane neighbor --direction left|right|up|down [--pane ID|--current]");
+ eprintln!(" herdr pane edges [--pane ID|--current]");
+ eprintln!(" herdr pane focus --direction left|right|up|down [--pane ID|--current]");
+ eprintln!(
+ " herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current]"
+ );
eprintln!(" herdr pane rename <pane_id> <label>|--clear");
eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
eprintln!(
- " herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]"
+ " herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus]"
);
+ eprintln!(" herdr pane swap --direction left|right|up|down [--pane ID|--current]");
+ eprintln!(" herdr pane swap --source-pane ID --target-pane ID");
eprintln!(" herdr pane close <pane_id>");
eprintln!(" herdr pane send-text <pane_id> <text>");
eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]");
@@ -617,3 +967,125 @@ fn print_pane_help() {
eprintln!(" herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]");
eprintln!(" herdr pane run <pane_id> <command>");
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn args(values: &[&str]) -> Vec<String> {
+ values.iter().map(|value| (*value).to_string()).collect()
+ }
+
+ #[test]
+ fn parse_pane_split_args_accepts_ratio() {
+ let params = parse_pane_split_args(&args(&[
+ "issue-1",
+ "--direction",
+ "right",
+ "--ratio",
+ "0.333",
+ ]))
+ .unwrap();
+
+ assert_eq!(params.target_pane_id, Some("issue-1".into()));
+ assert_eq!(params.direction, crate::api::schema::SplitDirection::Right);
+ assert_eq!(params.ratio, Some(0.333));
+ }
+
+ #[test]
+ fn parse_pane_split_args_accepts_current_target() {
+ let params = parse_pane_split_args(&args(&["--direction", "down", "--current"])).unwrap();
+
+ assert_eq!(params.target_pane_id, None);
+ assert_eq!(params.direction, crate::api::schema::SplitDirection::Down);
+ }
+
+ #[test]
+ fn parse_pane_split_args_accepts_pane_option() {
+ let params =
+ parse_pane_split_args(&args(&["--pane", "issue-2", "--direction", "right"])).unwrap();
+
+ assert_eq!(params.target_pane_id, Some("issue-2".into()));
+ assert_eq!(params.direction, crate::api::schema::SplitDirection::Right);
+ }
+
+ #[test]
+ fn parse_pane_swap_args_accepts_directional_current() {
+ let params = parse_pane_swap_args(&args(&["--direction", "right"])).unwrap();
+
+ assert_eq!(params.pane_id, None);
+ assert_eq!(params.direction, Some(PaneDirection::Right));
+ assert_eq!(params.source_pane_id, None);
+ assert_eq!(params.target_pane_id, None);
+ }
+
+ #[test]
+ fn parse_pane_swap_args_accepts_explicit_source_and_target() {
+ let params = parse_pane_swap_args(&args(&[
+ "--source-pane",
+ "issue-1",
+ "--target-pane",
+ "issue-2",
+ ]))
+ .unwrap();
+
+ assert_eq!(params.direction, None);
+ assert_eq!(params.source_pane_id, Some("issue-1".into()));
+ assert_eq!(params.target_pane_id, Some("issue-2".into()));
+ }
+
+ #[test]
+ fn parse_pane_swap_args_rejects_mixed_forms() {
+ let err = parse_pane_swap_args(&args(&[
+ "--direction",
+ "left",
+ "--source-pane",
+ "issue-1",
+ "--target-pane",
+ "issue-2",
+ ]))
+ .unwrap_err();
+
+ assert!(err.contains("usage: herdr pane swap"));
+ }
+
+ #[test]
+ fn parse_pane_neighbor_args_accepts_directional_current() {
+ let params = parse_pane_neighbor_args(&args(&["--direction", "down"])).unwrap();
+
+ assert_eq!(params.pane_id, None);
+ assert_eq!(params.direction, PaneDirection::Down);
+ }
+
+ #[test]
+ fn parse_optional_current_pane_args_accepts_explicit_pane() {
+ let pane_id = parse_optional_current_pane_args(&args(&["--pane", "issue-2"])).unwrap();
+
+ assert_eq!(pane_id, Some("issue-2".into()));
+ }
+
+ #[test]
+ fn parse_pane_focus_args_accepts_directional_current() {
+ let params = parse_pane_focus_args(&args(&["--direction", "up"])).unwrap();
+
+ assert_eq!(params.pane_id, None);
+ assert_eq!(params.direction, PaneDirection::Up);
+ }
+
+ #[test]
+ fn parse_pane_resize_args_accepts_amount_and_pane() {
+ let params = parse_pane_resize_args(&args(&[
+ "--pane",
+ "issue-2",
+ "--direction",
+ "left",
+ "--amount",
+ "0.125",
+ ]))
+ .unwrap();
+
+ assert_eq!(params.pane_id, Some("issue-2".into()));
+ assert_eq!(params.direction, PaneDirection::Left);
+ assert_eq!(params.amount, Some(0.125));
+ }
+}
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index 299af6cb..cc054995 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -291,6 +291,10 @@ pub struct Keybinds {
pub focus_pane_down: ActionKeybinds,
pub focus_pane_up: ActionKeybinds,
pub focus_pane_right: ActionKeybinds,
+ pub swap_pane_left: ActionKeybinds,
+ pub swap_pane_down: ActionKeybinds,
+ pub swap_pane_up: ActionKeybinds,
+ pub swap_pane_right: ActionKeybinds,
pub cycle_pane_next: ActionKeybinds,
pub cycle_pane_previous: ActionKeybinds,
pub last_pane: ActionKeybinds,
@@ -470,6 +474,10 @@ impl Config {
focus_pane_down: action!("keys.focus_pane_down", &self.keys.focus_pane_down),
focus_pane_up: action!("keys.focus_pane_up", &self.keys.focus_pane_up),
focus_pane_right: action!("keys.focus_pane_right", &self.keys.focus_pane_right),
+ swap_pane_left: action!("keys.swap_pane_left", &self.keys.swap_pane_left),
+ swap_pane_down: action!("keys.swap_pane_down", &self.keys.swap_pane_down),
+ swap_pane_up: action!("keys.swap_pane_up", &self.keys.swap_pane_up),
+ swap_pane_right: action!("keys.swap_pane_right", &self.keys.swap_pane_right),
last_pane: action!("keys.last_pane", &self.keys.last_pane),
cycle_pane_next: action!("keys.cycle_pane_next", &self.keys.cycle_pane_next),
cycle_pane_previous: action!(
@@ -1734,6 +1742,34 @@ switch_workspace = "prefix+shift+1..9"
.bindings
.iter()
.all(|binding| binding.trigger.is_prefix()));
+ assert_eq!(
+ binding_triggers(&kb.swap_pane_left),
+ vec![BindingTrigger::Prefix((
+ KeyCode::Char('h'),
+ KeyModifiers::SHIFT
+ ))]
+ );
+ assert_eq!(
+ binding_triggers(&kb.swap_pane_down),
+ vec![BindingTrigger::Prefix((
+ KeyCode::Char('j'),
+ KeyModifiers::SHIFT
+ ))]
+ );
+ assert_eq!(
+ binding_triggers(&kb.swap_pane_up),
+ vec![BindingTrigger::Prefix((
+ KeyCode::Char('k'),
+ KeyModifiers::SHIFT
+ ))]
+ );
+ assert_eq!(
+ binding_triggers(&kb.swap_pane_right),
+ vec![BindingTrigger::Prefix((
+ KeyCode::Char('l'),
+ KeyModifiers::SHIFT
+ ))]
+ );
}
#[test]
diff --git a/src/config/model.rs b/src/config/model.rs
index 9fda88f4..3b8b2cb7 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -315,6 +315,14 @@ pub struct KeysConfig {
pub focus_pane_up: BindingConfig,
/// Focus the pane to the right. Default: "prefix+l".
pub focus_pane_right: BindingConfig,
+ /// Swap the focused pane with the pane to the left. Default: "prefix+shift+h".
+ pub swap_pane_left: BindingConfig,
+ /// Swap the focused pane with the pane below. Default: "prefix+shift+j".
+ pub swap_pane_down: BindingConfig,
+ /// Swap the focused pane with the pane above. Default: "prefix+shift+k".
+ pub swap_pane_up: BindingConfig,
+ /// Swap the focused pane with the pane to the right. Default: "prefix+shift+l".
+ pub swap_pane_right: BindingConfig,
/// Cycle to the next pane. Default: "prefix+tab".
pub cycle_pane_next: BindingConfig,
/// Cycle to the previous pane. Default: "prefix+shift+tab".
@@ -527,6 +535,10 @@ impl Default for KeysConfig {
focus_pane_down: BindingConfig::one("prefix+j"),
focus_pane_up: BindingConfig::one("prefix+k"),
focus_pane_right: BindingConfig::one("prefix+l"),
+ swap_pane_left: BindingConfig::one("prefix+shift+h"),
+ swap_pane_down: BindingConfig::one("prefix+shift+j"),
+ swap_pane_up: BindingConfig::one("prefix+shift+k"),
+ swap_pane_right: BindingConfig::one("prefix+shift+l"),
cycle_pane_next: BindingConfig::one("prefix+tab"),
cycle_pane_previous: BindingConfig::one("prefix+shift+tab"),
last_pane: BindingConfig::empty(),
diff --git a/src/layout.rs b/src/layout.rs
index fe608f01..220fd8b7 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -1,5 +1,7 @@
//! BSP tree layout for tiling panes within a workspace.
+use std::cmp::Reverse;
+
use ratatui::layout::{Direction, Rect};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -45,6 +47,8 @@ pub struct SplitBorder {
pub pos: u16,
/// Direction of the split that created this border.
pub direction: Direction,
+ /// Ratio assigned to the first child of this split.
+ pub ratio: f32,
/// Total area of the split node.
pub area: Rect,
/// Path from root to this split node (false=first, true=second).
@@ -115,10 +119,15 @@ impl TileLayout {
/// Split the focused pane. Returns the new pane's id.
pub fn split_focused(&mut self, direction: Direction) -> PaneId {
+ self.split_focused_with_ratio(direction, 0.5)
+ }
+
+ /// Split the focused pane with a custom first-child ratio.
+ pub fn split_focused_with_ratio(&mut self, direction: Direction, ratio: f32) -> PaneId {
let new_id = PaneId::alloc();
let placeholder = PaneId::from_raw(0);
let old = std::mem::replace(&mut self.root, Node::Pane(placeholder));
- self.root = split_at(old, self.focus, direction, new_id);
+ self.root = split_at(old, self.focus, direction, new_id, valid_split_ratio(ratio));
self.focus = new_id;
new_id
}
@@ -153,6 +162,20 @@ impl TileLayout {
}
}
+ /// Swap two pane ids in the layout tree while preserving split shape and
+ /// ratios. Returns true only when both panes exist and are different.
+ pub fn swap_panes(&mut self, first: PaneId, second: PaneId) -> bool {
+ if first == second {
+ return false;
+ }
+ let ids = self.pane_ids();
+ if !ids.contains(&first) || !ids.contains(&second) {
+ return false;
+ }
+ swap_pane_ids(&mut self.root, first, second);
+ true
+ }
+
/// Set the ratio of a split node at the given path.
pub fn set_ratio_at(&mut self, path: &[bool], ratio: f32) {
set_ratio_at(&mut self.root, path, ratio.clamp(0.1, 0.9));
@@ -168,52 +191,18 @@ impl TileLayout {
let focused_rect = focused.rect;
let splits = self.splits(area);
- // Find the split whose border is adjacent to the focused pane in the given direction
let target_dir = match nav {
NavDirection::Left | NavDirection::Right => Direction::Horizontal,
NavDirection::Up | NavDirection::Down => Direction::Vertical,
};
let grows = matches!(nav, NavDirection::Right | NavDirection::Down);
- // Find the closest matching split border
let best = splits
.iter()
.filter(|s| s.direction == target_dir)
- .filter(|s| match target_dir {
- Direction::Horizontal => {
- // Border must be near the focused pane's left or right edge
- let near_right = (s.pos as i32 - (focused_rect.x + focused_rect.width) as i32)
- .unsigned_abs()
- <= 1;
- let near_left = (s.pos as i32 - focused_rect.x as i32).unsigned_abs() <= 1;
- near_right || near_left
- }
- Direction::Vertical => {
- let near_bottom = (s.pos as i32
- - (focused_rect.y + focused_rect.height) as i32)
- .unsigned_abs()
- <= 1;
- let near_top = (s.pos as i32 - focused_rect.y as i32).unsigned_abs() <= 1;
- near_bottom || near_top
- }
- })
- .min_by_key(|s| {
- // Prefer the border in the direction we're resizing toward
- match (target_dir, grows) {
- (Direction::Horizontal, true) => {
- ((focused_rect.x + focused_rect.width) as i32 - s.pos as i32).unsigned_abs()
- }
- (Direction::Horizontal, false) => {
- (focused_rect.x as i32 - s.pos as i32).unsigned_abs()
- }
- (Direction::Vertical, true) => ((focused_rect.y + focused_rect.height) as i32
- - s.pos as i32)
- .unsigned_abs(),
- (Direction::Vertical, false) => {
- (focused_rect.y as i32 - s.pos as i32).unsigned_abs()
- }
- }
- });
+ .filter(|s| split_area_overlaps_focused_pane(s, focused_rect, nav))
+ .filter(|s| split_on_requested_edge(s, focused_rect, nav))
+ .min_by_key(|s| split_edge_distance(s, focused_rect, nav));
if let Some(split) = best {
let path = split.path.clone();
@@ -223,6 +212,24 @@ impl TileLayout {
}
}
+ pub fn resize_pane(
+ &mut self,
+ pane_id: PaneId,
+ nav: NavDirection,
+ delta: f32,
+ area: Rect,
+ ) -> bool {
+ if !self.pane_ids().contains(&pane_id) {
+ return false;
+ }
+ let before = split_ratios(&self.root);
+ let previous_focus = self.focus;
+ self.focus = pane_id;
+ self.resize_focused(nav, delta, area);
+ self.focus = previous_focus;
+ split_ratios(&self.root) != before
+ }
+
pub fn pane_ids(&self) -> Vec<PaneId> {
let mut ids = Vec::new();
collect_ids(&self.root, &mut ids);
@@ -253,8 +260,9 @@ pub fn find_in_direction(
panes
.iter()
- .filter(|p| p.id != focused.id)
- .filter(|p| {
+ .enumerate()
+ .filter(|(_, p)| p.id != focused.id)
+ .filter(|(_, p)| {
let r = p.rect;
match direction {
NavDirection::Left => {
@@ -271,22 +279,79 @@ pub fn find_in_direction(
}
}
})
- .min_by_key(|p| {
+ .min_by_key(|(index, p)| {
let r = p.rect;
- match direction {
+ let edge_distance = match direction {
NavDirection::Left => fr.x.saturating_sub(r.x + r.width),
NavDirection::Right => r.x.saturating_sub(fr.x + fr.width),
NavDirection::Up => fr.y.saturating_sub(r.y + r.height),
NavDirection::Down => r.y.saturating_sub(fr.y + fr.height),
- }
+ };
+ let overlap = match direction {
+ NavDirection::Left | NavDirection::Right => {
+ range_overlap_amount(r.y, r.height, fr.y, fr.height)
+ }
+ NavDirection::Up | NavDirection::Down => {
+ range_overlap_amount(r.x, r.width, fr.x, fr.width)
+ }
+ };
+ let center_distance = match direction {
+ NavDirection::Left | NavDirection::Right => {
+ range_center_distance(r.y, r.height, fr.y, fr.height)
+ }
+ NavDirection::Up | NavDirection::Down => {
+ range_center_distance(r.x, r.width, fr.x, fr.width)
+ }
+ };
+ (edge_distance, Reverse(overlap), center_distance, *index)
})
- .map(|p| p.id)
+ .map(|(_, p)| p.id)
}
fn ranges_overlap(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> bool {
a_start < b_start + b_len && a_start + a_len > b_start
}
+fn split_on_requested_edge(split: &SplitBorder, focused: Rect, nav: NavDirection) -> bool {
+ split_edge_distance(split, focused, nav) <= 1
+}
+
+fn split_area_overlaps_focused_pane(split: &SplitBorder, focused: Rect, nav: NavDirection) -> bool {
+ match nav {
+ NavDirection::Left | NavDirection::Right => {
+ ranges_overlap(split.area.y, split.area.height, focused.y, focused.height)
+ }
+ NavDirection::Up | NavDirection::Down => {
+ ranges_overlap(split.area.x, split.area.width, focused.x, focused.width)
+ }
+ }
+}
+
+fn split_edge_distance(split: &SplitBorder, focused: Rect, nav: NavDirection) -> u32 {
+ match nav {
+ NavDirection::Left => (split.pos as i32 - focused.x as i32).unsigned_abs(),
+ NavDirection::Right => {
+ (split.pos as i32 - (focused.x + focused.width) as i32).unsigned_abs()
+ }
+ NavDirection::Up => (split.pos as i32 - focused.y as i32).unsigned_abs(),
+ NavDirection::Down => {
+ (split.pos as i32 - (focused.y + focused.height) as i32).unsigned_abs()
+ }
+ }
+}
+
+fn range_overlap_amount(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> u16 {
+ let a_end = a_start.saturating_add(a_len);
+ let b_end = b_start.saturating_add(b_len);
+ a_end.min(b_end).saturating_sub(a_start.max(b_start))
+}
+
+fn range_center_distance(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> u16 {
+ let a_center = a_start.saturating_mul(2).saturating_add(a_len);
+ let b_center = b_start.saturating_mul(2).saturating_add(b_len);
+ a_center.abs_diff(b_center)
+}
+
// --- Tree operations ---
fn count_panes(node: &Node) -> usize {
@@ -337,6 +402,7 @@ fn collect_splits(node: &Node, area: Rect, path: Vec<bool>, result: &mut Vec<Spl
result.push(SplitBorder {
pos,
direction: *direction,
+ ratio: *ratio,
area,
path: path.clone(),
});
@@ -359,11 +425,59 @@ fn collect_ids(node: &Node, ids: &mut Vec<PaneId>) {
}
}
-fn split_at(node: Node, target: PaneId, direction: Direction, new_id: PaneId) -> Node {
+fn split_ratios(node: &Node) -> Vec<(Vec<bool>, f32)> {
+ fn collect(node: &Node, path: &mut Vec<bool>, out: &mut Vec<(Vec<bool>, f32)>) {
+ match node {
+ Node::Pane(_) => {}
+ Node::Split {
+ ratio,
+ first,
+ second,
+ ..
+ } => {
+ out.push((path.clone(), *ratio));
+ path.push(false);
+ collect(first, path, out);
+ path.pop();
+ path.push(true);
+ collect(second, path, out);
+ path.pop();
+ }
+ }
+ }
+
+ let mut out = Vec::new();
+ collect(node, &mut Vec::new(), &mut out);
+ out
+}
+
+fn swap_pane_ids(node: &mut Node, first: PaneId, second: PaneId) {
+ match node {
+ Node::Pane(id) if *id == first => *id = second,
+ Node::Pane(id) if *id == second => *id = first,
+ Node::Pane(_) => {}
+ Node::Split {
+ first: first_child,
+ second: second_child,
+ ..
+ } => {
+ swap_pane_ids(first_child, first, second);
+ swap_pane_ids(second_child, first, second);
+ }
+ }
+}
+
+fn split_at(
+ node: Node,
+ target: PaneId,
+ direction: Direction,
+ new_id: PaneId,
+ split_ratio: f32,
+) -> Node {
match node {
Node::Pane(id) if id == target => Node::Split {
direction,
- ratio: 0.5,
+ ratio: split_ratio,
first: Box::new(Node::Pane(id)),
second: Box::new(Node::Pane(new_id)),
},
@@ -376,12 +490,20 @@ fn split_at(node: Node, target: PaneId, direction: Direction, new_id: PaneId) ->
} => Node::Split {
direction: d,
ratio,
- first: Box::new(split_at(*first, target, direction, new_id)),
- second: Box::new(split_at(*second, target, direction, new_id)),
+ first: Box::new(split_at(*first, target, direction, new_id, split_ratio)),
+ second: Box::new(split_at(*second, target, direction, new_id, split_ratio)),
},
}
}
+fn valid_split_ratio(ratio: f32) -> f32 {
+ if ratio.is_finite() {
+ ratio.clamp(0.1, 0.9)
+ } else {
+ 0.5
+ }
+}
+
fn remove_pane(node: Node, target: PaneId) -> Option<Node> {
match node {
Node::Pane(id) if id == target => None,
@@ -463,3 +585,225 @@ fn split_rect(area: Rect, direction: Direction, ratio: f32) -> (Rect, Rect) {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn pane(id: u32) -> PaneId {
+ PaneId::from_raw(id)
+ }
+
+ fn sample_layout() -> TileLayout {
+ TileLayout::from_saved(
+ Node::Split {
+ direction: Direction::Horizontal,
+ ratio: 0.3,
+ first: Box::new(Node::Pane(pane(1))),
+ second: Box::new(Node::Split {
+ direction: Direction::Vertical,
+ ratio: 0.6,
+ first: Box::new(Node::Pane(pane(2))),
+ second: Box::new(Node::Split {
+ direction: Direction::Horizontal,
+ ratio: 0.4,
+ first: Box::new(Node::Pane(pane(3))),
+ second: Box::new(Node::Pane(pane(4))),
+ }),
+ }),
+ },
+ pane(2),
+ )
+ }
+
+ fn pane_rects(layout: &TileLayout) -> Vec<(PaneId, Rect)> {
+ layout
+ .panes(Rect::new(0, 0, 100, 40))
+ .into_iter()
+ .map(|info| (info.id, info.rect))
+ .collect()
+ }
+
+ fn split_snapshot(layout: &TileLayout) -> Vec<(Direction, f32)> {
+ fn collect(node: &Node, out: &mut Vec<(Direction, f32)>) {
+ match node {
+ Node::Pane(_) => {}
+ Node::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => {
+ out.push((*direction, *ratio));
+ collect(first, out);
+ collect(second, out);
+ }
+ }
+ }
+
+ let mut out = Vec::new();
+ collect(layout.root(), &mut out);
+ out
+ }
+
+ #[test]
+ fn swap_panes_exchanges_leaf_ids_without_changing_cells() {
+ let mut layout = sample_layout();
+ let before_rects = pane_rects(&layout);
+ let before_splits = split_snapshot(&layout);
+
+ assert!(layout.swap_panes(pane(2), pane(4)));
+
+ assert_eq!(layout.pane_count(), 4);
+ assert_eq!(split_snapshot(&layout), before_splits);
+ assert_eq!(layout.focused(), pane(2));
+
+ let after_rects = pane_rects(&layout);
+ assert_eq!(after_rects[0], before_rects[0]);
+ assert_eq!(after_rects[1], (pane(4), before_rects[1].1));
+ assert_eq!(after_rects[2], before_rects[2]);
+ assert_eq!(after_rects[3], (pane(2), before_rects[3].1));
+ }
+
+ #[test]
+ fn swap_panes_is_noop_for_same_or_missing_pane() {
+ let mut layout = sample_layout();
+ let before_rects = pane_rects(&layout);
+ let before_splits = split_snapshot(&layout);
+ let before_focus = layout.focused();
+
+ assert!(!layout.swap_panes(pane(2), pane(2)));
+ assert!(!layout.swap_panes(pane(2), pane(99)));
+ assert!(!layout.swap_panes(pane(99), pane(2)));
+
+ assert_eq!(pane_rects(&layout), before_rects);
+ assert_eq!(split_snapshot(&layout), before_splits);
+ assert_eq!(layout.focused(), before_focus);
+ }
+
+ #[test]
+ fn split_focused_with_ratio_sets_new_split_ratio() {
+ let (mut layout, root) = TileLayout::new();
+ layout.focus_pane(root);
+
+ layout.split_focused_with_ratio(Direction::Horizontal, 0.333);
+
+ let splits = split_snapshot(&layout);
+ assert_eq!(splits.len(), 1);
+ assert_eq!(splits[0].0, Direction::Horizontal);
+ assert!((splits[0].1 - 0.333).abs() < f32::EPSILON);
+ }
+
+ #[test]
+ fn resize_pane_preserves_focus_and_reports_change() {
+ let mut layout = sample_layout();
+ let original_focus = layout.focused();
+
+ assert!(layout.resize_pane(pane(1), NavDirection::Right, 0.05, Rect::new(0, 0, 100, 40),));
+
+ assert_eq!(layout.focused(), original_focus);
+ let split = split_snapshot(&layout)[0];
+ assert_eq!(split.0, Direction::Horizontal);
+ assert!((split.1 - 0.35).abs() < f32::EPSILON);
+ }
+
+ #[test]
+ fn resize_second_child_toward_split_decreases_ratio() {
+ let (mut layout, root) = TileLayout::new();
+ let right = layout.split_focused(Direction::Horizontal);
+ layout.focus_pane(root);
+
+ assert!(layout.resize_pane(right, NavDirection::Left, 0.05, Rect::new(0, 0, 100, 40),));
+
+ let split = split_snapshot(&layout)[0];
+ assert_eq!(split.0, Direction::Horizontal);
+ assert!((split.1 - 0.45).abs() < f32::EPSILON);
+ assert_eq!(layout.focused(), root);
+ }
+
+ #[test]
+ fn resize_outer_edges_are_noops() {
+ let (mut horizontal, left) = TileLayout::new();
+ let right = horizontal.split_focused(Direction::Horizontal);
+ let horizontal_before = split_snapshot(&horizontal);
+
+ assert!(!horizontal.resize_pane(left, NavDirection::Left, 0.05, Rect::new(0, 0, 100, 40),));
+ assert!(!horizontal.resize_pane(
+ right,
+ NavDirection::Right,
+ 0.05,
+ Rect::new(0, 0, 100, 40),
+ ));
+ assert_eq!(split_snapshot(&horizontal), horizontal_before);
+
+ let (mut vertical, top) = TileLayout::new();
+ let bottom = vertical.split_focused(Direction::Vertical);
+ let vertical_before = split_snapshot(&vertical);
+
+ assert!(!vertical.resize_pane(top, NavDirection::Up, 0.05, Rect::new(0, 0, 100, 40),));
+ assert!(!vertical.resize_pane(bottom, NavDirection::Down, 0.05, Rect::new(0, 0, 100, 40),));
+ assert_eq!(split_snapshot(&vertical), vertical_before);
+ }
+
+ #[test]
+ fn resize_uses_split_in_same_branch_when_borders_share_coordinate() {
+ let mut layout = TileLayout::from_saved(
+ Node::Split {
+ direction: Direction::Vertical,
+ ratio: 0.5,
+ first: Box::new(Node::Split {
+ direction: Direction::Horizontal,
+ ratio: 0.5,
+ first: Box::new(Node::Pane(pane(1))),
+ second: Box::new(Node::Pane(pane(2))),
+ }),
+ second: Box::new(Node::Split {
+ direction: Direction::Horizontal,
+ ratio: 0.5,
+ first: Box::new(Node::Pane(pane(3))),
+ second: Box::new(Node::Pane(pane(4))),
+ }),
+ },
+ pane(3),
+ );
+
+ assert!(layout.resize_pane(pane(3), NavDirection::Right, 0.05, Rect::new(0, 0, 100, 40),));
+
+ let splits = split_snapshot(&layout);
+ assert_eq!(splits[0], (Direction::Vertical, 0.5));
+ assert_eq!(splits[1], (Direction::Horizontal, 0.5));
+ assert_eq!(splits[2].0, Direction::Horizontal);
+ assert!((splits[2].1 - 0.55).abs() < f32::EPSILON);
+ }
+
+ #[test]
+ fn find_in_direction_tiebreaks_by_larger_overlap_before_layout_order() {
+ let focused = PaneInfo {
+ id: pane(1),
+ rect: Rect::new(10, 10, 10, 10),
+ inner_rect: Rect::new(10, 10, 10, 10),
+ scrollbar_rect: None,
+ is_focused: true,
+ };
+ let small_overlap_first = PaneInfo {
+ id: pane(2),
+ rect: Rect::new(0, 10, 10, 2),
+ inner_rect: Rect::new(0, 10, 10, 2),
+ scrollbar_rect: None,
+ is_focused: false,
+ };
+ let larger_overlap_second = PaneInfo {
+ id: pane(3),
+ rect: Rect::new(0, 10, 10, 8),
+ inner_rect: Rect::new(0, 10, 10, 8),
+ scrollbar_rect: None,
+ is_focused: false,
+ };
+ let panes = vec![focused.clone(), small_overlap_first, larger_overlap_second];
+
+ assert_eq!(
+ find_in_direction(&focused, NavDirection::Left, &panes),
+ Some(pane(3))
+ );
+ }
+}
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index 7cd01a64..bc24a322 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -878,7 +878,9 @@ mod tests {
#[test]
fn capture_contract_tracks_resize_ratio_changes() {
let mut state = state_with_workspaces(&["one"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
state.workspaces[0].test_split(Direction::Horizontal);
+ state.workspaces[0].layout.focus_pane(root);
crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20));
let before = capture_from_state(&state);
diff --git a/src/workspace.rs b/src/workspace.rs
index 8a660b02..b01b5891 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -428,6 +428,36 @@ impl Workspace {
self.split_pane_with_runtime(
pane_id,
direction,
+ None,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ shell_config,
+ focus_new_pane,
+ None,
+ )
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn split_pane_with_ratio(
+ &mut self,
+ pane_id: PaneId,
+ direction: Direction,
+ ratio: f32,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ shell_config: crate::pane::PaneShellConfig<'_>,
+ focus_new_pane: bool,
+ ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
+ self.split_pane_with_runtime(
+ pane_id,
+ direction,
+ Some(ratio),
rows,
cols,
cwd,
@@ -455,6 +485,7 @@ impl Workspace {
self.split_pane_with_runtime(
pane_id,
direction,
+ None,
rows,
cols,
cwd,
@@ -471,6 +502,7 @@ impl Workspace {
&mut self,
pane_id: PaneId,
direction: Direction,
+ ratio: Option<f32>,
rows: u16,
cols: u16,
cwd: Option<PathBuf>,
@@ -495,15 +527,27 @@ impl Workspace {
host_terminal_theme,
)
} else {
- tab.split_focused(
- direction,
- rows,
- cols,
- cwd,
- scrollback_limit_bytes,
- host_terminal_theme,
- shell_config,
- )
+ match ratio {
+ Some(ratio) => tab.split_focused_with_ratio(
+ direction,
+ ratio,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ shell_config,
+ ),
+ None => tab.split_focused(
+ direction,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ shell_config,
+ ),
+ }
} {
Ok(new_pane) => new_pane,
Err(err) => {
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
index f3ad594e..cdb86136 100644
--- a/src/workspace/tab.rs
+++ b/src/workspace/tab.rs
@@ -198,6 +198,31 @@ impl Tab {
) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
+ None,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ shell_config,
+ None,
+ )
+ }
+
+ pub fn split_focused_with_ratio(
+ &mut self,
+ direction: Direction,
+ ratio: f32,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ shell_config: crate::pane::PaneShellConfig<'_>,
+ ) -> std::io::Result<NewPane> {
+ self.split_focused_with_runtime(
+ direction,
+ Some(ratio),
rows,
cols,
cwd,
@@ -221,6 +246,7 @@ impl Tab {
) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
+ None,
rows,
cols,
cwd,
@@ -243,6 +269,7 @@ impl Tab {
) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
+ None,
rows,
cols,
cwd,
@@ -256,6 +283,7 @@ impl Tab {
fn split_focused_with_runtime(
&mut self,
direction: Direction,
+ ratio: Option<f32>,
rows: u16,
cols: u16,
cwd: Option<PathBuf>,
@@ -265,7 +293,10 @@ impl Tab {
command: Option<SplitCommand<'_>>,
) -> std::io::Result<NewPane> {
let previous_focus = self.layout.focused();
- let new_id = self.layout.split_focused(direction);
+ let new_id = match ratio {
+ Some(ratio) => self.layout.split_focused_with_ratio(direction, ratio),
+ None => self.layout.split_focused(direction),
+ };
let actual_cwd =
cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let launch_argv = if let Some(SplitCommand::Argv { argv }) = &command {