aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-05 21:59:10 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-05 22:06:02 +0300
commit556b16b2df7ea2c3e9b8ad13db4ad7370c1ad838 (patch)
tree0a6f61701f2c8992c6cd1ee6d61171ef92a92533 /src/app/api
parentae3c88ab8c64931740d89623f688f7d3f408da5e (diff)
feat: add pane navigation controls
refs #421 refs #330 related discussions: https://github.com/ogulcancelik/herdr/discussions/422 https://github.com/ogulcancelik/herdr/discussions/438 https://github.com/ogulcancelik/herdr/discussions/442
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/panes.rs920
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(&params.target_pane_id) else {
- return pane_not_found(id, &params.target_pane_id);
+ let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() {
+ self.parse_pane_id(target_pane_id)
+ } else if let Some(workspace_id) = params.workspace_id.as_deref() {
+ self.parse_workspace_id(workspace_id).and_then(|ws_idx| {
+ let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?;
+ Some((ws_idx, pane_id))
+ })
+ } else {
+ self.state.active.and_then(|ws_idx| {
+ let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?;
+ Some((ws_idx, pane_id))
+ })
+ };
+ let Some((ws_idx, target_pane_id)) = target else {
+ return encode_error(id, "pane_not_found", "pane not found");
};
let (rows, cols) = self.state.estimate_pane_size();
let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| {
@@ -37,26 +55,42 @@ impl App {
let host_terminal_theme = self.state.host_terminal_theme;
let previous_focus = self.state.current_pane_focus_target();
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
- return pane_not_found(id, &params.target_pane_id);
+ return encode_error(id, "pane_not_found", "pane not found");
};
let direction = match params.direction {
crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal,
crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical,
};
- let (target_tab_idx, new_pane) = match ws.split_pane(
- target_pane_id,
- direction,
- rows,
- cols,
- split_cwd,
- scrollback_limit_bytes,
- host_terminal_theme,
- crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode),
- params.focus,
- ) {
+ let shell_config = crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode);
+ let split_result = match params.ratio {
+ Some(ratio) => ws.split_pane_with_ratio(
+ target_pane_id,
+ direction,
+ ratio,
+ rows,
+ cols,
+ split_cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ shell_config,
+ params.focus,
+ ),
+ None => ws.split_pane(
+ target_pane_id,
+ direction,
+ rows,
+ cols,
+ split_cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ shell_config,
+ params.focus,
+ ),
+ };
+ let (target_tab_idx, new_pane) = match split_result {
Some(Ok(result)) => result,
Some(Err(err)) => return encode_error(id, "pane_split_failed", err.to_string()),
- None => return pane_not_found(id, &params.target_pane_id),
+ None => return encode_error(id, "pane_not_found", "pane not found"),
};
if params.focus {
self.state.switch_workspace_tab(ws_idx, target_tab_idx);
@@ -99,6 +133,390 @@ impl App {
encode_success(id, ResponseResult::PaneInfo { pane })
}
+ pub(super) fn handle_pane_layout(&mut self, id: String, params: PaneLayoutParams) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+
+ encode_success(id, ResponseResult::PaneLayout { layout })
+ }
+
+ pub(super) fn handle_pane_neighbor(
+ &mut self,
+ id: String,
+ params: PaneNeighborParams,
+ ) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(source_public_id) = self.public_pane_id(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let neighbor_pane_id = self
+ .directional_pane_target(ws_idx, tab_idx, pane_id, params.direction)
+ .and_then(|pane_id| self.public_pane_id(ws_idx, pane_id));
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+
+ encode_success(
+ id,
+ ResponseResult::PaneNeighbor {
+ neighbor: PaneNeighborResult {
+ pane_id: source_public_id,
+ direction: params.direction,
+ neighbor_pane_id,
+ layout,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_pane_edges(&mut self, id: String, params: PaneEdgesParams) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(tab) = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.get(tab_idx))
+ else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let area = self.state.view.terminal_area;
+ let Some(info) = tab
+ .layout
+ .panes(area)
+ .into_iter()
+ .find(|info| info.id == pane_id)
+ else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(pane_public_id) = self.public_pane_id(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+
+ encode_success(
+ id,
+ ResponseResult::PaneEdges {
+ edges: PaneEdgesResult {
+ pane_id: pane_public_id,
+ left: info.rect.x <= area.x,
+ right: info.rect.x + info.rect.width >= area.x + area.width,
+ up: info.rect.y <= area.y,
+ down: info.rect.y + info.rect.height >= area.y + area.height,
+ layout,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_pane_focus_direction(
+ &mut self,
+ id: String,
+ params: PaneFocusDirectionParams,
+ ) -> String {
+ let Some((ws_idx, source_pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref())
+ else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(source_pane_id)
+ else {
+ return pane_not_found(
+ id,
+ &self
+ .public_pane_id(ws_idx, source_pane_id)
+ .unwrap_or_default(),
+ );
+ };
+ let Some(source_public_id) = self.public_pane_id(ws_idx, source_pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let target =
+ self.directional_pane_target(ws_idx, tab_idx, source_pane_id, params.direction);
+ let reason = target
+ .is_none()
+ .then_some(PaneFocusDirectionReason::NoNeighbor);
+
+ if let Some(target_pane_id) = target {
+ self.state.focus_pane_in_workspace(ws_idx, target_pane_id);
+ self.state.switch_workspace_tab(ws_idx, tab_idx);
+ self.state.mode = Mode::Terminal;
+ }
+ let focused_pane_id = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.get(tab_idx))
+ .map(|tab| tab.layout.focused())
+ .and_then(|pane_id| self.public_pane_id(ws_idx, pane_id));
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+
+ encode_success(
+ id,
+ ResponseResult::PaneFocusDirection {
+ focus: PaneFocusDirectionResult {
+ changed: target.is_some(),
+ reason,
+ source_pane_id: source_public_id,
+ focused_pane_id,
+ layout,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_pane_resize(&mut self, id: String, params: PaneResizeParams) -> String {
+ let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else {
+ return pane_not_found(
+ id,
+ &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(),
+ );
+ };
+ let Some(pane_public_id) = self.public_pane_id(ws_idx, pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+
+ let amount = params
+ .amount
+ .filter(|amount| amount.is_finite())
+ .unwrap_or(0.05)
+ .abs()
+ .min(0.5);
+ let direction: NavDirection = params.direction.into();
+ let area = self.state.view.terminal_area;
+ let changed = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ .is_some_and(|tab| tab.layout.resize_pane(pane_id, direction, amount, area));
+ if changed {
+ self.schedule_session_save();
+ }
+
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let focused_pane_id = layout.focused_pane_id.clone();
+
+ encode_success(
+ id,
+ ResponseResult::PaneResize {
+ resize: PaneResizeResult {
+ changed,
+ reason: (!changed).then_some(PaneResizeReason::Unchanged),
+ pane_id: pane_public_id,
+ focused_pane_id,
+ layout,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_pane_swap(&mut self, id: String, params: PaneSwapParams) -> String {
+ let directional = params.direction.is_some();
+ let explicit = params.source_pane_id.is_some() || params.target_pane_id.is_some();
+ if directional == explicit {
+ return encode_error(
+ id,
+ "invalid_pane_swap",
+ "provide either direction with optional pane_id, or source_pane_id and target_pane_id",
+ );
+ }
+
+ let (ws_idx, tab_idx, source_pane_id, target_pane_id, reason) = if let Some(direction) =
+ params.direction
+ {
+ let Some((ws_idx, source_pane_id)) =
+ self.resolve_swap_source(params.pane_id.as_deref())
+ else {
+ return encode_error(id, "pane_not_found", "source pane not found");
+ };
+ let Some(tab_idx) =
+ self.state.workspaces[ws_idx].find_tab_index_for_pane(source_pane_id)
+ else {
+ return pane_not_found(
+ id,
+ &self
+ .public_pane_id(ws_idx, source_pane_id)
+ .unwrap_or_default(),
+ );
+ };
+ let target = self.directional_pane_target(ws_idx, tab_idx, source_pane_id, direction);
+ match target {
+ Some(target_pane_id) => {
+ (ws_idx, tab_idx, source_pane_id, Some(target_pane_id), None)
+ }
+ None => (
+ ws_idx,
+ tab_idx,
+ source_pane_id,
+ None,
+ Some(PaneSwapReason::NoNeighbor),
+ ),
+ }
+ } else {
+ let Some(source_raw) = params.source_pane_id.as_deref() else {
+ return encode_error(id, "invalid_pane_swap", "missing source_pane_id");
+ };
+ let Some(target_raw) = params.target_pane_id.as_deref() else {
+ return encode_error(id, "invalid_pane_swap", "missing target_pane_id");
+ };
+ let source = self
+ .parse_pane_id(source_raw)
+ .and_then(|(ws_idx, pane_id)| {
+ let tab_idx = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id)?;
+ Some((ws_idx, tab_idx, pane_id))
+ });
+ let target = self
+ .parse_pane_id(target_raw)
+ .and_then(|(ws_idx, pane_id)| {
+ let tab_idx = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id)?;
+ Some((ws_idx, tab_idx, pane_id))
+ });
+ let response_context = source
+ .map(|(ws_idx, tab_idx, _)| (ws_idx, tab_idx))
+ .or_else(|| target.map(|(ws_idx, tab_idx, _)| (ws_idx, tab_idx)))
+ .or_else(|| {
+ let ws_idx = self.state.active?;
+ let tab_idx = self.state.workspaces.get(ws_idx)?.active_tab_index();
+ Some((ws_idx, tab_idx))
+ });
+ let Some((ws_idx, tab_idx)) = response_context else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let source_pane_id = source
+ .map(|(_, _, pane_id)| pane_id)
+ .or_else(|| {
+ self.state
+ .workspaces
+ .get(ws_idx)?
+ .tabs
+ .get(tab_idx)
+ .map(|tab| tab.layout.focused())
+ })
+ .unwrap_or(PaneId::from_raw(0));
+ let target_pane_id = target.map(|(_, _, pane_id)| pane_id);
+ let reason = match (source, target) {
+ (None, _) | (_, None) => Some(PaneSwapReason::NotFound),
+ (Some((_, _, source)), Some((_, _, target))) if source == target => {
+ Some(PaneSwapReason::SamePane)
+ }
+ (Some((source_ws, source_tab, _)), Some((target_ws, target_tab, _)))
+ if source_ws != target_ws || source_tab != target_tab =>
+ {
+ Some(PaneSwapReason::CrossTab)
+ }
+ _ => None,
+ };
+ (ws_idx, tab_idx, source_pane_id, target_pane_id, reason)
+ };
+
+ let mut changed = false;
+ if reason.is_none() {
+ if let Some(target_pane_id) = target_pane_id {
+ let previous_focus = self.state.current_pane_focus_target();
+ if let Some(tab) = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ {
+ changed = tab.layout.swap_panes(source_pane_id, target_pane_id);
+ tab.layout.focus_pane(source_pane_id);
+ if changed {
+ self.state.switch_workspace_tab(ws_idx, tab_idx);
+ self.state
+ .record_pane_focus_change(previous_focus, ws_idx, source_pane_id);
+ self.state.mark_session_dirty();
+ self.schedule_session_save();
+ }
+ }
+ }
+ }
+
+ let source_public_id = match params.source_pane_id {
+ Some(raw) => self
+ .parse_pane_id(&raw)
+ .and_then(|(idx, pane_id)| {
+ self.state
+ .workspaces
+ .get(idx)?
+ .find_tab_index_for_pane(pane_id)?;
+ self.public_pane_id(idx, pane_id)
+ })
+ .unwrap_or(raw),
+ None => self
+ .public_pane_id(ws_idx, source_pane_id)
+ .unwrap_or_default(),
+ };
+ let target_public_id = match params.target_pane_id {
+ Some(raw) => self
+ .parse_pane_id(&raw)
+ .and_then(|(idx, pane_id)| {
+ self.state
+ .workspaces
+ .get(idx)?
+ .find_tab_index_for_pane(pane_id)?;
+ self.public_pane_id(idx, pane_id)
+ })
+ .or(Some(raw)),
+ None => target_pane_id.and_then(|pane_id| self.public_pane_id(ws_idx, pane_id)),
+ };
+ let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let focused_pane_id = layout.focused_pane_id.clone();
+
+ encode_success(
+ id,
+ ResponseResult::PaneSwap {
+ swap: PaneSwapResult {
+ changed,
+ reason,
+ source_pane_id: source_public_id,
+ target_pane_id: target_public_id,
+ focused_pane_id,
+ layout,
+ },
+ },
+ )
+ }
+
pub(super) fn handle_pane_rename(&mut self, id: String, params: PaneRenameParams) -> String {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
@@ -526,6 +944,118 @@ fn pane_not_found(id: String, pane_id: &str) -> String {
encode_error(id, "pane_not_found", format!("pane {pane_id} not found"))
}
+impl App {
+ fn resolve_optional_pane(&self, pane_id: Option<&str>) -> Option<(usize, PaneId)> {
+ match pane_id {
+ Some(pane_id) => self.parse_pane_id(pane_id),
+ None => {
+ let ws_idx = self.state.active?;
+ let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?;
+ Some((ws_idx, pane_id))
+ }
+ }
+ }
+
+ fn resolve_swap_source(&self, pane_id: Option<&str>) -> Option<(usize, PaneId)> {
+ self.resolve_optional_pane(pane_id)
+ }
+
+ fn directional_pane_target(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ source_pane_id: PaneId,
+ direction: PaneDirection,
+ ) -> Option<PaneId> {
+ let tab = self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?;
+ let panes = tab.layout.panes(self.state.view.terminal_area);
+ let source = panes.iter().find(|pane| pane.id == source_pane_id)?;
+ find_in_direction(source, direction.into(), &panes)
+ }
+
+ fn pane_layout_snapshot(&self, ws_idx: usize, tab_idx: usize) -> Option<PaneLayoutSnapshot> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ let area = self.state.view.terminal_area;
+ let focused_pane_id = self.public_pane_id(ws_idx, tab.layout.focused())?;
+ let panes = tab
+ .layout
+ .panes(area)
+ .into_iter()
+ .filter_map(|pane| {
+ Some(PaneLayoutPane {
+ pane_id: self.public_pane_id(ws_idx, pane.id)?,
+ focused: pane.is_focused,
+ rect: pane.rect.into(),
+ })
+ })
+ .collect();
+ let splits = tab
+ .layout
+ .splits(area)
+ .into_iter()
+ .enumerate()
+ .map(|(idx, split)| PaneLayoutSplit {
+ id: split_path_id(idx, &split.path),
+ direction: match split.direction {
+ ratatui::layout::Direction::Horizontal => {
+ crate::api::schema::SplitDirection::Right
+ }
+ ratatui::layout::Direction::Vertical => {
+ crate::api::schema::SplitDirection::Down
+ }
+ },
+ ratio: split.ratio,
+ rect: split.area.into(),
+ })
+ .collect();
+
+ Some(PaneLayoutSnapshot {
+ workspace_id: self.public_workspace_id(ws_idx),
+ tab_id: self.public_tab_id(ws_idx, tab_idx)?,
+ zoomed: tab.zoomed,
+ area: area.into(),
+ focused_pane_id,
+ panes,
+ splits,
+ })
+ }
+}
+
+impl From<PaneDirection> for NavDirection {
+ fn from(direction: PaneDirection) -> Self {
+ match direction {
+ PaneDirection::Left => NavDirection::Left,
+ PaneDirection::Right => NavDirection::Right,
+ PaneDirection::Up => NavDirection::Up,
+ PaneDirection::Down => NavDirection::Down,
+ }
+ }
+}
+
+impl From<ratatui::layout::Rect> for PaneLayoutRect {
+ fn from(rect: ratatui::layout::Rect) -> Self {
+ Self {
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height,
+ }
+ }
+}
+
+fn split_path_id(idx: usize, path: &[bool]) -> String {
+ if path.is_empty() {
+ return format!("split_{idx}_root");
+ }
+ let path = path
+ .iter()
+ .map(|right| if *right { "1" } else { "0" })
+ .collect::<Vec<_>>()
+ .join("");
+ format!("split_{idx}_{path}")
+}
+
fn invalid_agent(id: String) -> String {
encode_error(id, "invalid_agent", "agent label must not be empty")
}
@@ -573,4 +1103,354 @@ mod tests {
assert_eq!(app.state.request_remove_linked_worktree, None);
assert!(app.state.workspaces.is_empty());
}
+
+ #[test]
+ fn api_pane_swap_explicit_source_and_target_preserves_focus_and_returns_layout() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(source);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let target_public = app.public_pane_id(0, target).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ source_pane_id: Some(source_public.clone()),
+ target_pane_id: Some(target_public.clone()),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(swap.changed);
+ assert_eq!(swap.reason, None);
+ assert_eq!(swap.source_pane_id, source_public);
+ assert_eq!(swap.target_pane_id, Some(target_public));
+ assert_eq!(swap.focused_pane_id, swap.source_pane_id);
+ assert_eq!(swap.layout.focused_pane_id, swap.source_pane_id);
+ assert_eq!(swap.layout.panes.len(), 2);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source));
+ }
+
+ #[test]
+ fn api_pane_swap_unfocused_source_updates_last_pane_history() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let focused = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Vertical);
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.workspaces[0].tabs[0].layout.focus_pane(focused);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let target_public = app.public_pane_id(0, target).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ source_pane_id: Some(source_public),
+ target_pane_id: Some(target_public),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(swap.changed);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source));
+
+ app.state.last_pane();
+
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(focused));
+ }
+
+ #[test]
+ fn api_pane_swap_direction_no_neighbor_returns_unchanged_layout() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ app.state.workspaces[0].tabs[0].layout.focus_pane(source);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let source_public = app.public_pane_id(0, source).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ pane_id: Some(source_public.clone()),
+ direction: Some(PaneDirection::Left),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(!swap.changed);
+ assert_eq!(swap.reason, Some(PaneSwapReason::NoNeighbor));
+ assert_eq!(swap.source_pane_id, source_public);
+ assert_eq!(swap.target_pane_id, None);
+ assert_eq!(swap.layout.panes.len(), 1);
+ }
+
+ #[test]
+ fn api_pane_swap_explicit_missing_target_returns_not_found_noop() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let source_public = app.public_pane_id(0, source).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ source_pane_id: Some(source_public.clone()),
+ target_pane_id: Some("missing-pane".into()),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(!swap.changed);
+ assert_eq!(swap.reason, Some(PaneSwapReason::NotFound));
+ assert_eq!(swap.source_pane_id, source_public);
+ assert_eq!(swap.target_pane_id, Some("missing-pane".into()));
+ assert_eq!(swap.layout.panes.len(), 1);
+ }
+
+ #[test]
+ fn api_pane_swap_explicit_missing_source_returns_not_found_noop() {
+ let mut app = app_with_linked_worktree();
+ let target = app.state.workspaces[0].tabs[0].root_pane;
+ let target_public = app.public_pane_id(0, target).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ source_pane_id: Some("missing-pane".into()),
+ target_pane_id: Some(target_public.clone()),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(!swap.changed);
+ assert_eq!(swap.reason, Some(PaneSwapReason::NotFound));
+ assert_eq!(swap.source_pane_id, "missing-pane");
+ assert_eq!(swap.target_pane_id, Some(target_public));
+ assert_eq!(swap.layout.panes.len(), 1);
+ }
+
+ #[test]
+ fn api_pane_swap_explicit_cross_workspace_preserves_target_id() {
+ let mut app = app_with_linked_worktree();
+ app.state.workspaces.push(Workspace::test_new("other"));
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let target = app.state.workspaces[1].tabs[0].root_pane;
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let target_public = app.public_pane_id(1, target).unwrap();
+
+ let response = app.handle_pane_swap(
+ "req".into(),
+ PaneSwapParams {
+ source_pane_id: Some(source_public.clone()),
+ target_pane_id: Some(target_public.clone()),
+ ..PaneSwapParams::default()
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneSwap { swap } = success.result else {
+ panic!("expected pane swap response");
+ };
+ assert!(!swap.changed);
+ assert_eq!(swap.reason, Some(PaneSwapReason::CrossTab));
+ assert_eq!(swap.source_pane_id, source_public);
+ assert_eq!(swap.target_pane_id, Some(target_public));
+ assert_eq!(swap.layout.workspace_id, app.public_workspace_id(0));
+ }
+
+ #[test]
+ fn api_pane_layout_returns_public_ids_rects_and_splits() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_layout(
+ "req".into(),
+ crate::api::schema::PaneLayoutParams {
+ pane_id: Some(root_public.clone()),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneLayout { layout } = success.result else {
+ panic!("expected pane layout response");
+ };
+ assert_eq!(layout.focused_pane_id, root_public);
+ assert!(layout.panes.iter().any(|pane| pane.pane_id == root_public));
+ assert!(layout.panes.iter().any(|pane| pane.pane_id == right_public));
+ assert_eq!(layout.splits.len(), 1);
+ assert_eq!(
+ layout.splits[0].direction,
+ crate::api::schema::SplitDirection::Right
+ );
+ }
+
+ #[test]
+ fn api_pane_neighbor_returns_directional_neighbor_public_id() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_neighbor(
+ "req".into(),
+ crate::api::schema::PaneNeighborParams {
+ pane_id: Some(root_public.clone()),
+ direction: PaneDirection::Right,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneNeighbor { neighbor } = success.result else {
+ panic!("expected pane neighbor response");
+ };
+ assert_eq!(neighbor.pane_id, root_public);
+ assert_eq!(neighbor.direction, PaneDirection::Right);
+ assert_eq!(neighbor.neighbor_pane_id, Some(right_public));
+ }
+
+ #[test]
+ fn api_pane_edges_reports_physical_layout_edges() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_edges(
+ "req".into(),
+ crate::api::schema::PaneEdgesParams {
+ pane_id: Some(right_public.clone()),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneEdges { edges } = success.result else {
+ panic!("expected pane edges response");
+ };
+ assert_eq!(edges.pane_id, right_public);
+ assert!(!edges.left);
+ assert!(edges.right);
+ assert!(edges.up);
+ assert!(edges.down);
+ }
+
+ #[test]
+ fn api_pane_resize_changes_target_ratio_without_changing_focus() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(right);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_resize(
+ "req".into(),
+ crate::api::schema::PaneResizeParams {
+ pane_id: Some(root_public.clone()),
+ direction: PaneDirection::Right,
+ amount: Some(0.1),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneResize { resize } = success.result else {
+ panic!("expected pane resize response");
+ };
+ assert!(resize.changed);
+ assert_eq!(resize.reason, None);
+ assert_eq!(resize.pane_id, root_public);
+ assert_eq!(resize.focused_pane_id, right_public);
+ assert_eq!(resize.layout.focused_pane_id, right_public);
+ assert!((resize.layout.splits[0].ratio - 0.6).abs() < f32::EPSILON);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right));
+ }
+
+ #[test]
+ fn api_pane_focus_direction_focuses_neighbor() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_public = app.public_pane_id(0, root).unwrap();
+ let right_public = app.public_pane_id(0, right).unwrap();
+
+ let response = app.handle_pane_focus_direction(
+ "req".into(),
+ crate::api::schema::PaneFocusDirectionParams {
+ pane_id: Some(root_public.clone()),
+ direction: PaneDirection::Right,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneFocusDirection { focus } = success.result else {
+ panic!("expected pane focus direction response");
+ };
+ assert!(focus.changed);
+ assert_eq!(focus.reason, None);
+ assert_eq!(focus.source_pane_id, root_public);
+ assert_eq!(focus.focused_pane_id, Some(right_public.clone()));
+ assert_eq!(focus.layout.focused_pane_id, right_public);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right));
+ }
+
+ #[test]
+ fn api_pane_focus_direction_no_neighbor_is_noop() {
+ let mut app = app_with_linked_worktree();
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ app.state.workspaces[0].tabs[0].layout.focus_pane(root);
+ crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ let root_public = app.public_pane_id(0, root).unwrap();
+
+ let response = app.handle_pane_focus_direction(
+ "req".into(),
+ crate::api::schema::PaneFocusDirectionParams {
+ pane_id: Some(root_public.clone()),
+ direction: PaneDirection::Left,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneFocusDirection { focus } = success.result else {
+ panic!("expected pane focus direction response");
+ };
+ assert!(!focus.changed);
+ assert_eq!(focus.reason, Some(PaneFocusDirectionReason::NoNeighbor));
+ assert_eq!(focus.source_pane_id, root_public.clone());
+ assert_eq!(focus.focused_pane_id, Some(root_public));
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
+ }
}