diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-05 21:59:10 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-05 22:06:02 +0300 |
| commit | 556b16b2df7ea2c3e9b8ad13db4ad7370c1ad838 (patch) | |
| tree | 0a6f61701f2c8992c6cd1ee6d61171ef92a92533 /src | |
| parent | ae3c88ab8c64931740d89623f688f7d3f408da5e (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.rs | 3 | ||||
| -rw-r--r-- | src/api/schema.rs | 208 | ||||
| -rw-r--r-- | src/api/server.rs | 6 | ||||
| -rw-r--r-- | src/app/actions.rs | 120 | ||||
| -rw-r--r-- | src/app/api.rs | 8 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 920 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 107 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 138 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 41 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 10 | ||||
| -rw-r--r-- | src/app/mod.rs | 125 | ||||
| -rw-r--r-- | src/app/state.rs | 38 | ||||
| -rw-r--r-- | src/cli/pane.rs | 530 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 36 | ||||
| -rw-r--r-- | src/config/model.rs | 12 | ||||
| -rw-r--r-- | src/layout.rs | 440 | ||||
| -rw-r--r-- | src/persist/snapshot.rs | 2 | ||||
| -rw-r--r-- | src/workspace.rs | 62 | ||||
| -rw-r--r-- | src/workspace/tab.rs | 33 |
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(¶ms.target_pane_id) else { - return pane_not_found(id, ¶ms.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, ¶ms.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, ¶ms.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(¶ms.pane_id) else { return pane_not_found(id, ¶ms.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 { |
