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/app/api | |
| 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/app/api')
| -rw-r--r-- | src/app/api/panes.rs | 920 |
1 files changed, 900 insertions, 20 deletions
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)); + } } |
