aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-13 01:59:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-13 01:59:24 +0300
commitd35c642ef36ee5ac2afe7c1b18ad5a717bc807d8 (patch)
tree89f176d076ff65e1cb141f914707a552c17776fc /src/app/api
parent469cf9cede389036f5799e2f41483413f1d7e60b (diff)
feat: add pane move api and cli
refs #299
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/panes.rs1084
1 files changed, 1081 insertions, 3 deletions
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 326fbd9f..7ef4ed3c 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -3,8 +3,9 @@ use bytes::Bytes;
use crate::api::schema::{
EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneDirection,
PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, PaneFocusDirectionReason,
- PaneFocusDirectionResult, PaneLayoutPane, PaneLayoutParams, PaneLayoutRect, PaneLayoutSnapshot,
- PaneLayoutSplit, PaneListParams, PaneNeighborParams, PaneNeighborResult, PaneReadParams,
+ PaneFocusDirectionResult, PaneInfo, PaneLayoutPane, PaneLayoutParams, PaneLayoutRect,
+ PaneLayoutSnapshot, PaneLayoutSplit, PaneListParams, PaneMoveDestination, PaneMoveParams,
+ PaneMoveReason, PaneMoveResult, PaneNeighborParams, PaneNeighborResult, PaneReadParams,
PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams,
PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason,
PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
@@ -518,6 +519,471 @@ impl App {
)
}
+ pub(super) fn handle_pane_move(&mut self, id: String, params: PaneMoveParams) -> String {
+ let PaneMoveParams {
+ pane_id,
+ destination,
+ focus,
+ } = params;
+ let Some((source_ws_idx, source_pane_id)) = self.parse_pane_id(&pane_id) else {
+ return encode_error(id, "pane_not_found", "source pane not found");
+ };
+ let Some(source_tab_idx) =
+ self.state.workspaces[source_ws_idx].find_tab_index_for_pane(source_pane_id)
+ else {
+ return encode_error(id, "pane_not_found", "source pane not found");
+ };
+ let previous_pane_id = self
+ .public_pane_id(source_ws_idx, source_pane_id)
+ .unwrap_or_else(|| pane_id.clone());
+ let previous_workspace_id = self.public_workspace_id(source_ws_idx);
+ let Some(previous_tab_id) = self.public_tab_id(source_ws_idx, source_tab_idx) else {
+ return encode_error(id, "tab_not_found", "source tab not found");
+ };
+ let Some(source_terminal_id) = self
+ .state
+ .workspaces
+ .get(source_ws_idx)
+ .and_then(|ws| ws.tabs.get(source_tab_idx))
+ .and_then(|tab| tab.terminal_id(source_pane_id))
+ .cloned()
+ else {
+ return encode_error(id, "pane_not_found", "source pane not found");
+ };
+ let recovery_context = PaneMoveRecoveryContext {
+ source_ws_idx,
+ previous_workspace_id: previous_workspace_id.clone(),
+ previous_workspace_label: self.state.workspaces[source_ws_idx].custom_name.clone(),
+ previous_tab_label: self.state.workspaces[source_ws_idx].tabs[source_tab_idx]
+ .custom_name
+ .clone(),
+ previous_worktree_space: self.state.workspaces[source_ws_idx].worktree_space.clone(),
+ identity_cwd: self.state.workspaces[source_ws_idx].identity_cwd.clone(),
+ };
+
+ if self.state.workspaces[source_ws_idx].tabs[source_tab_idx].zoomed {
+ let Some(layout) = self.pane_layout_snapshot(source_ws_idx, source_tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let Some(pane) = self.pane_info(source_ws_idx, source_pane_id) else {
+ return encode_error(id, "pane_not_found", "source pane not found");
+ };
+ return encode_unchanged_pane_move(
+ id,
+ PaneMoveReason::ZoomedTab,
+ previous_pane_id,
+ previous_workspace_id,
+ previous_tab_id,
+ pane,
+ Some(layout.clone()),
+ layout,
+ );
+ }
+
+ let resolved = match destination {
+ PaneMoveDestination::Tab {
+ tab_id,
+ target_pane_id,
+ split,
+ ratio,
+ } => {
+ let Some((target_ws_idx, target_tab_idx)) = self.parse_tab_id(&tab_id) else {
+ return encode_error(id, "tab_not_found", format!("tab {tab_id} not found"));
+ };
+ if source_ws_idx == target_ws_idx && source_tab_idx == target_tab_idx {
+ let Some(layout) = self.pane_layout_snapshot(source_ws_idx, source_tab_idx)
+ else {
+ return encode_error(
+ id,
+ "pane_layout_unavailable",
+ "pane layout unavailable",
+ );
+ };
+ let Some(pane) = self.pane_info(source_ws_idx, source_pane_id) else {
+ return encode_error(id, "pane_not_found", "source pane not found");
+ };
+ return encode_unchanged_pane_move(
+ id,
+ PaneMoveReason::SameTab,
+ previous_pane_id,
+ previous_workspace_id,
+ previous_tab_id,
+ pane,
+ Some(layout.clone()),
+ layout,
+ );
+ }
+ if self.state.workspaces[target_ws_idx].tabs[target_tab_idx].zoomed {
+ let Some(source_layout) =
+ self.pane_layout_snapshot(source_ws_idx, source_tab_idx)
+ else {
+ return encode_error(
+ id,
+ "pane_layout_unavailable",
+ "pane layout unavailable",
+ );
+ };
+ let Some(target_layout) =
+ self.pane_layout_snapshot(target_ws_idx, target_tab_idx)
+ else {
+ return encode_error(
+ id,
+ "pane_layout_unavailable",
+ "pane layout unavailable",
+ );
+ };
+ let Some(pane) = self.pane_info(source_ws_idx, source_pane_id) else {
+ return encode_error(id, "pane_not_found", "source pane not found");
+ };
+ return encode_unchanged_pane_move(
+ id,
+ PaneMoveReason::ZoomedTab,
+ previous_pane_id,
+ previous_workspace_id,
+ previous_tab_id,
+ pane,
+ Some(source_layout),
+ target_layout,
+ );
+ }
+ let target_pane_id = match target_pane_id {
+ Some(raw) => {
+ let Some((pane_ws_idx, pane_id)) = self.parse_pane_id(&raw) else {
+ return encode_error(
+ id,
+ "target_pane_not_found",
+ format!("target pane {raw} not found"),
+ );
+ };
+ let pane_tab_idx =
+ self.state.workspaces[pane_ws_idx].find_tab_index_for_pane(pane_id);
+ if pane_ws_idx != target_ws_idx || pane_tab_idx != Some(target_tab_idx) {
+ return encode_error(
+ id,
+ "target_pane_not_found",
+ format!("target pane {raw} is not in tab {tab_id}"),
+ );
+ }
+ pane_id
+ }
+ None => self.state.workspaces[target_ws_idx].tabs[target_tab_idx]
+ .layout
+ .focused(),
+ };
+ let Some(target_tab_id) = self.public_tab_id(target_ws_idx, target_tab_idx) else {
+ return encode_error(id, "tab_not_found", format!("tab {tab_id} not found"));
+ };
+ ResolvedPaneMoveDestination::ExistingTab {
+ tab_id: target_tab_id,
+ target_pane_id,
+ split,
+ ratio: ratio.unwrap_or(0.5),
+ cross_workspace: source_ws_idx != target_ws_idx,
+ }
+ }
+ PaneMoveDestination::NewTab {
+ workspace_id,
+ label,
+ } => {
+ let target_workspace_id = if let Some(workspace_id) = workspace_id {
+ let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
+ return encode_error(
+ id,
+ "workspace_not_found",
+ format!("workspace {workspace_id} not found"),
+ );
+ };
+ self.public_workspace_id(ws_idx)
+ } else {
+ previous_workspace_id.clone()
+ };
+ ResolvedPaneMoveDestination::NewTab {
+ workspace_id: target_workspace_id,
+ label,
+ }
+ }
+ PaneMoveDestination::NewWorkspace { label, tab_label } => {
+ ResolvedPaneMoveDestination::NewWorkspace { label, tab_label }
+ }
+ };
+
+ let previous_focus = self.state.current_pane_focus_target();
+ let taken = match self
+ .state
+ .workspaces
+ .get_mut(source_ws_idx)
+ .and_then(|ws| ws.take_pane_for_move(source_pane_id))
+ {
+ Some(taken) => taken,
+ None => return encode_error(id, "pane_move_failed", "source pane could not be moved"),
+ };
+ let source_removed_tab_id = taken.removed_tab_idx.map(|_| previous_tab_id.clone());
+ let source_workspace_empty = taken.workspace_empty;
+ let moved = taken.moved;
+ let cross_workspace = match &resolved {
+ ResolvedPaneMoveDestination::ExistingTab {
+ cross_workspace, ..
+ } => *cross_workspace,
+ ResolvedPaneMoveDestination::NewTab { workspace_id, .. } => {
+ workspace_id != &previous_workspace_id
+ }
+ ResolvedPaneMoveDestination::NewWorkspace { .. } => true,
+ };
+ if cross_workspace {
+ if let Some(ws) = self.state.workspaces.get_mut(source_ws_idx) {
+ ws.unregister_moved_pane(source_pane_id);
+ }
+ self.state
+ .public_pane_id_aliases
+ .insert(previous_pane_id.clone(), source_pane_id);
+ }
+
+ let mut closed_workspace_id = None;
+ if source_workspace_empty && cross_workspace {
+ self.state.workspaces.remove(source_ws_idx);
+ closed_workspace_id = Some(previous_workspace_id.clone());
+ if self.state.workspaces.is_empty() {
+ self.state.active = None;
+ self.state.selected = 0;
+ } else {
+ if let Some(active) = self.state.active {
+ if active == source_ws_idx {
+ self.state.active =
+ Some(source_ws_idx.min(self.state.workspaces.len() - 1));
+ } else if active > source_ws_idx {
+ self.state.active = Some(active - 1);
+ }
+ }
+ if self.state.selected == source_ws_idx {
+ self.state.selected = source_ws_idx.min(self.state.workspaces.len() - 1);
+ } else if self.state.selected > source_ws_idx {
+ self.state.selected -= 1;
+ }
+ }
+ }
+
+ let mut created_workspace = false;
+ let mut created_tab = false;
+ let (target_ws_idx, target_tab_idx, moved_pane_id) = match resolved {
+ ResolvedPaneMoveDestination::ExistingTab {
+ tab_id,
+ target_pane_id,
+ split,
+ ratio,
+ cross_workspace: _,
+ } => {
+ let Some((target_ws_idx, target_tab_idx)) = self.parse_tab_id(&tab_id) else {
+ self.recover_failed_pane_move(recovery_context, moved);
+ return encode_error(id, "pane_move_failed", "target tab disappeared");
+ };
+ let previous_target_focus = self.state.workspaces[target_ws_idx].tabs
+ [target_tab_idx]
+ .layout
+ .focused();
+ let direction = split_direction_to_layout(split);
+ let moved_pane_id = match self.state.workspaces[target_ws_idx]
+ .insert_moved_pane_into_tab(
+ target_tab_idx,
+ target_pane_id,
+ moved,
+ direction,
+ ratio,
+ ) {
+ Ok(pane_id) => pane_id,
+ Err(moved) => {
+ self.recover_failed_pane_move(recovery_context, moved);
+ return encode_error(
+ id,
+ "pane_move_failed",
+ "target pane could not be split",
+ );
+ }
+ };
+ if !focus {
+ self.state.workspaces[target_ws_idx].tabs[target_tab_idx]
+ .layout
+ .focus_pane(previous_target_focus);
+ }
+ (target_ws_idx, target_tab_idx, moved_pane_id)
+ }
+ ResolvedPaneMoveDestination::NewTab {
+ workspace_id,
+ label,
+ } => {
+ let Some(target_ws_idx) = self.parse_workspace_id(&workspace_id) else {
+ self.recover_failed_pane_move(recovery_context, moved);
+ return encode_error(id, "pane_move_failed", "target workspace disappeared");
+ };
+ let moved_pane_id = moved.pane_id;
+ let target_tab_idx = self.state.workspaces[target_ws_idx]
+ .create_tab_from_existing_pane(
+ moved,
+ label,
+ self.event_tx.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ );
+ created_tab = true;
+ (target_ws_idx, target_tab_idx, moved_pane_id)
+ }
+ ResolvedPaneMoveDestination::NewWorkspace { label, tab_label } => {
+ let identity_cwd = self
+ .state
+ .terminals
+ .get(&source_terminal_id)
+ .map(|terminal| terminal.cwd.clone())
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+ let moved_pane_id = moved.pane_id;
+ let workspace = crate::workspace::Workspace::from_existing_pane(
+ label,
+ tab_label,
+ identity_cwd,
+ moved,
+ self.event_tx.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ );
+ self.state.workspaces.push(workspace);
+ let target_ws_idx = self.state.workspaces.len() - 1;
+ created_workspace = true;
+ created_tab = true;
+ (target_ws_idx, 0, moved_pane_id)
+ }
+ };
+
+ if focus || self.state.active.is_none() {
+ self.state
+ .switch_workspace_tab(target_ws_idx, target_tab_idx);
+ self.state
+ .record_pane_focus_change(previous_focus, target_ws_idx, moved_pane_id);
+ self.state.mode = Mode::Terminal;
+ }
+ let created_workspace = created_workspace.then(|| self.workspace_info(target_ws_idx));
+ let created_tab = if created_tab {
+ self.tab_info(target_ws_idx, target_tab_idx)
+ } else {
+ None
+ };
+
+ self.state.remove_alias_shadowed_by_new_pane(moved_pane_id);
+ self.state.mark_session_dirty();
+ self.schedule_session_save();
+ let Some(pane) = self.pane_info(target_ws_idx, moved_pane_id) else {
+ return encode_error(id, "pane_move_failed", "moved pane is unavailable");
+ };
+ let source_layout = if closed_workspace_id.is_none() {
+ self.parse_tab_id(&previous_tab_id)
+ .and_then(|(ws_idx, tab_idx)| self.pane_layout_snapshot(ws_idx, tab_idx))
+ } else {
+ None
+ };
+ let Some(target_layout) = self.pane_layout_snapshot(target_ws_idx, target_tab_idx) else {
+ return encode_error(id, "pane_layout_unavailable", "pane layout unavailable");
+ };
+ let focused_pane_id = target_layout.focused_pane_id.clone();
+ let move_result = PaneMoveResult {
+ changed: true,
+ reason: None,
+ previous_pane_id: previous_pane_id.clone(),
+ previous_workspace_id: previous_workspace_id.clone(),
+ previous_tab_id: previous_tab_id.clone(),
+ pane: Box::new(pane.clone()),
+ source_layout: source_layout.map(Box::new),
+ target_layout: Box::new(target_layout),
+ created_workspace: created_workspace.clone(),
+ created_tab: created_tab.clone(),
+ closed_workspace_id: closed_workspace_id.clone(),
+ closed_tab_id: source_removed_tab_id.clone(),
+ focused_pane_id,
+ };
+ if let Some(closed_tab_id) = &source_removed_tab_id {
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabClosed,
+ data: EventData::TabClosed {
+ tab_id: closed_tab_id.clone(),
+ workspace_id: previous_workspace_id.clone(),
+ },
+ });
+ }
+ if let Some(closed_workspace_id) = &closed_workspace_id {
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceClosed,
+ data: EventData::WorkspaceClosed {
+ workspace_id: closed_workspace_id.clone(),
+ },
+ });
+ }
+ if let Some(workspace) = &created_workspace {
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceCreated,
+ data: EventData::WorkspaceCreated {
+ workspace: workspace.clone(),
+ },
+ });
+ }
+ if let Some(tab) = &created_tab {
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabCreated,
+ data: EventData::TabCreated { tab: tab.clone() },
+ });
+ }
+ self.emit_event(EventEnvelope {
+ event: EventKind::PaneMoved,
+ data: EventData::PaneMoved {
+ previous_pane_id,
+ previous_workspace_id,
+ previous_tab_id,
+ pane: Box::new(pane),
+ created_workspace,
+ created_tab,
+ closed_workspace_id,
+ closed_tab_id: source_removed_tab_id,
+ },
+ });
+
+ encode_success(id, ResponseResult::PaneMove { move_result })
+ }
+
+ fn recover_failed_pane_move(
+ &mut self,
+ context: PaneMoveRecoveryContext,
+ moved: crate::workspace::MovedPane,
+ ) {
+ if let Some(ws_idx) = self.parse_workspace_id(&context.previous_workspace_id) {
+ self.state.workspaces[ws_idx].create_tab_from_existing_pane(
+ moved,
+ context.previous_tab_label,
+ self.event_tx.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ );
+ } else {
+ let mut workspace = crate::workspace::Workspace::from_existing_pane(
+ context.previous_workspace_label,
+ context.previous_tab_label,
+ context.identity_cwd,
+ moved,
+ self.event_tx.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ );
+ workspace.id = context.previous_workspace_id;
+ workspace.worktree_space = context.previous_worktree_space;
+ let insert_idx = context.source_ws_idx.min(self.state.workspaces.len());
+ if let Some(active) = self.state.active {
+ if active >= insert_idx {
+ self.state.active = Some(active + 1);
+ }
+ }
+ if self.state.selected >= insert_idx && !self.state.workspaces.is_empty() {
+ self.state.selected += 1;
+ }
+ self.state.workspaces.insert(insert_idx, workspace);
+ }
+ self.state.mark_session_dirty();
+ self.schedule_session_save();
+ }
+
pub(super) fn handle_pane_zoom(&mut self, id: String, params: PaneZoomParams) -> 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");
@@ -1093,6 +1559,75 @@ impl From<PaneDirection> for NavDirection {
}
}
+enum ResolvedPaneMoveDestination {
+ ExistingTab {
+ tab_id: String,
+ target_pane_id: PaneId,
+ split: crate::api::schema::SplitDirection,
+ ratio: f32,
+ cross_workspace: bool,
+ },
+ NewTab {
+ workspace_id: String,
+ label: Option<String>,
+ },
+ NewWorkspace {
+ label: Option<String>,
+ tab_label: Option<String>,
+ },
+}
+
+struct PaneMoveRecoveryContext {
+ source_ws_idx: usize,
+ previous_workspace_id: String,
+ previous_workspace_label: Option<String>,
+ previous_tab_label: Option<String>,
+ previous_worktree_space: Option<crate::workspace::WorktreeSpaceMembership>,
+ identity_cwd: std::path::PathBuf,
+}
+
+fn encode_unchanged_pane_move(
+ id: String,
+ reason: PaneMoveReason,
+ previous_pane_id: String,
+ previous_workspace_id: String,
+ previous_tab_id: String,
+ pane: PaneInfo,
+ source_layout: Option<PaneLayoutSnapshot>,
+ target_layout: PaneLayoutSnapshot,
+) -> String {
+ let focused_pane_id = target_layout.focused_pane_id.clone();
+ encode_success(
+ id,
+ ResponseResult::PaneMove {
+ move_result: PaneMoveResult {
+ changed: false,
+ reason: Some(reason),
+ previous_pane_id,
+ previous_workspace_id,
+ previous_tab_id,
+ pane: Box::new(pane),
+ source_layout: source_layout.map(Box::new),
+ target_layout: Box::new(target_layout),
+ created_workspace: None,
+ created_tab: None,
+ closed_workspace_id: None,
+ closed_tab_id: None,
+ focused_pane_id,
+ },
+ },
+ )
+}
+
+fn split_direction_to_layout(
+ direction: crate::api::schema::SplitDirection,
+) -> ratatui::layout::Direction {
+ match direction {
+ crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal,
+ crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical,
+ }
+}
+
impl From<ratatui::layout::Rect> for PaneLayoutRect {
fn from(rect: ratatui::layout::Rect) -> Self {
Self {
@@ -1123,7 +1658,11 @@ fn invalid_agent(id: String) -> String {
#[cfg(test)]
mod tests {
use super::*;
- use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace};
+ use crate::{
+ api::schema::{SplitDirection, SuccessResponse},
+ config::Config,
+ workspace::Workspace,
+ };
fn app_with_linked_worktree() -> App {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -1145,6 +1684,24 @@ mod tests {
app
}
+ fn seed_terminal_states(app: &mut App) {
+ for ws in &app.state.workspaces {
+ for tab in &ws.tabs {
+ for pane in tab.panes.values() {
+ app.state
+ .terminals
+ .entry(pane.attached_terminal_id.clone())
+ .or_insert_with(|| {
+ crate::terminal::TerminalState::new(
+ pane.attached_terminal_id.clone(),
+ std::path::PathBuf::from("/herdr-test"),
+ )
+ });
+ }
+ }
+ }
+ }
+
#[test]
fn api_pane_close_closes_linked_worktree_workspace_only() {
let mut app = app_with_linked_worktree();
@@ -1341,6 +1898,527 @@ mod tests {
}
#[test]
+ fn api_pane_move_to_existing_tab_preserves_internal_pane_and_terminal() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let source_terminal = app.state.workspaces[0].tabs[0]
+ .terminal_id(source)
+ .unwrap()
+ .clone();
+ let target_tab = app.state.workspaces[0].test_add_tab(Some("target"));
+ let target = app.state.workspaces[0].tabs[target_tab].root_pane;
+ seed_terminal_states(&mut app);
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let source_tab_public = app.public_tab_id(0, 0).unwrap();
+ let target_public = app.public_pane_id(0, target).unwrap();
+ let target_tab_public = app.public_tab_id(0, target_tab).unwrap();
+
+ let response = app.handle_pane_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: source_public.clone(),
+ destination: PaneMoveDestination::Tab {
+ tab_id: target_tab_public.clone(),
+ target_pane_id: Some(target_public),
+ split: SplitDirection::Right,
+ ratio: Some(0.25),
+ },
+ focus: true,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneMove { move_result } = success.result else {
+ panic!("expected pane move response");
+ };
+ assert!(move_result.changed);
+ assert_eq!(move_result.reason, None);
+ assert_eq!(move_result.previous_pane_id, source_public);
+ assert_eq!(move_result.previous_tab_id, source_tab_public);
+ assert_eq!(move_result.pane.pane_id, move_result.previous_pane_id);
+ assert_eq!(move_result.pane.tab_id, target_tab_public);
+ assert_eq!(move_result.pane.terminal_id, source_terminal.to_string());
+ assert_eq!(move_result.closed_tab_id, Some(source_tab_public));
+ assert_eq!(move_result.closed_workspace_id, None);
+ assert_eq!(move_result.target_layout.panes.len(), 2);
+ assert_eq!(app.state.workspaces[0].tabs.len(), 1);
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source);
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].terminal_id(source),
+ Some(&source_terminal)
+ );
+ }
+
+ #[test]
+ fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_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 source_terminal = app.state.workspaces[0].tabs[0]
+ .terminal_id(source)
+ .unwrap()
+ .clone();
+ let target = app.state.workspaces[1].tabs[0].root_pane;
+ seed_terminal_states(&mut app);
+ let previous_pane_id = app.public_pane_id(0, source).unwrap();
+ let previous_workspace_id = app.public_workspace_id(0);
+ let target_workspace_id = app.public_workspace_id(1);
+ let target_tab_id = app.public_tab_id(1, 0).unwrap();
+ let target_pane_id = app.public_pane_id(1, target).unwrap();
+
+ let response = app.handle_pane_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: previous_pane_id.clone(),
+ destination: PaneMoveDestination::Tab {
+ tab_id: target_tab_id.clone(),
+ target_pane_id: Some(target_pane_id),
+ split: SplitDirection::Down,
+ ratio: None,
+ },
+ focus: false,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneMove { move_result } = success.result else {
+ panic!("expected pane move response");
+ };
+ assert!(move_result.changed);
+ assert_eq!(move_result.previous_pane_id, previous_pane_id);
+ assert_eq!(move_result.previous_workspace_id, previous_workspace_id);
+ assert_eq!(move_result.closed_workspace_id, Some(previous_workspace_id));
+ assert_ne!(move_result.pane.pane_id, move_result.previous_pane_id);
+ assert!(move_result
+ .pane
+ .pane_id
+ .starts_with(&format!("{target_workspace_id}:p")));
+ assert_eq!(move_result.pane.workspace_id, target_workspace_id);
+ assert_eq!(move_result.pane.tab_id, target_tab_id);
+ assert_eq!(move_result.pane.terminal_id, source_terminal.to_string());
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].terminal_id(source),
+ Some(&source_terminal)
+ );
+ assert_eq!(app.parse_pane_id(&previous_pane_id), Some((0, source)));
+ }
+
+ #[test]
+ fn api_pane_move_legacy_target_tab_id_survives_source_workspace_removal() {
+ 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 source_terminal = app.state.workspaces[0].tabs[0]
+ .terminal_id(source)
+ .unwrap()
+ .clone();
+ let target = app.state.workspaces[1].tabs[0].root_pane;
+ seed_terminal_states(&mut app);
+ 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_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: source_public,
+ destination: PaneMoveDestination::Tab {
+ tab_id: "t_2_1".into(),
+ target_pane_id: Some(target_public),
+ split: SplitDirection::Right,
+ ratio: None,
+ },
+ focus: true,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneMove { move_result } = success.result else {
+ panic!("expected pane move response");
+ };
+ assert!(move_result.changed);
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(move_result.closed_workspace_id, Some("w1".into()));
+ assert_eq!(move_result.pane.workspace_id, "w2");
+ assert_eq!(move_result.pane.tab_id, "w2:t1");
+ assert_eq!(move_result.pane.terminal_id, source_terminal.to_string());
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].terminal_id(source),
+ Some(&source_terminal)
+ );
+ }
+
+ #[test]
+ fn api_pane_move_to_new_tab_creates_tab_without_spawning_terminal() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ let source_terminal = app.state.workspaces[0].tabs[0]
+ .terminal_id(source)
+ .unwrap()
+ .clone();
+ seed_terminal_states(&mut app);
+ let source_public = app.public_pane_id(0, source).unwrap();
+
+ let response = app.handle_pane_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: source_public.clone(),
+ destination: PaneMoveDestination::NewTab {
+ workspace_id: None,
+ label: Some("moved".into()),
+ },
+ focus: true,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneMove { move_result } = success.result else {
+ panic!("expected pane move response");
+ };
+ assert!(move_result.changed);
+ assert_eq!(
+ move_result
+ .created_tab
+ .as_ref()
+ .map(|tab| tab.label.as_str()),
+ Some("moved")
+ );
+ assert_eq!(
+ move_result.created_tab.as_ref().map(|tab| tab.focused),
+ Some(true)
+ );
+ assert_eq!(move_result.closed_tab_id, None);
+ assert_eq!(move_result.pane.pane_id, source_public);
+ assert_eq!(move_result.pane.terminal_id, source_terminal.to_string());
+ assert_eq!(app.state.workspaces[0].tabs.len(), 2);
+ assert!(app.state.workspaces[0].tabs[0].terminal_id(right).is_some());
+ assert_eq!(
+ app.state.workspaces[0].tabs[1].terminal_id(source),
+ Some(&source_terminal)
+ );
+ let envelopes = app.event_hub.events_after(0);
+ let events: Vec<_> = envelopes
+ .iter()
+ .map(|(_, envelope)| envelope.event)
+ .collect();
+ assert_eq!(events, vec![EventKind::TabCreated, EventKind::PaneMoved]);
+ match &envelopes[0].1.data {
+ EventData::TabCreated { tab } => assert!(tab.focused),
+ other => panic!("expected tab created event, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn api_pane_move_only_pane_to_new_tab_uses_app_render_handles() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ seed_terminal_states(&mut app);
+ let source_public = app.public_pane_id(0, source).unwrap();
+
+ let response = app.handle_pane_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: source_public,
+ destination: PaneMoveDestination::NewTab {
+ workspace_id: None,
+ label: Some("moved".into()),
+ },
+ focus: true,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneMove { move_result } = success.result else {
+ panic!("expected pane move response");
+ };
+ assert!(move_result.changed);
+ assert!(std::sync::Arc::ptr_eq(
+ &app.state.workspaces[0].tabs[0].render_notify,
+ &app.render_notify
+ ));
+ assert!(std::sync::Arc::ptr_eq(
+ &app.state.workspaces[0].tabs[0].render_dirty,
+ &app.render_dirty
+ ));
+ }
+
+ #[test]
+ fn api_pane_move_to_new_workspace_closes_empty_source_workspace() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let source_terminal = app.state.workspaces[0].tabs[0]
+ .terminal_id(source)
+ .unwrap()
+ .clone();
+ seed_terminal_states(&mut app);
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let source_workspace = app.public_workspace_id(0);
+
+ let response = app.handle_pane_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: source_public.clone(),
+ destination: PaneMoveDestination::NewWorkspace {
+ label: Some("promoted".into()),
+ tab_label: Some("main".into()),
+ },
+ focus: true,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneMove { move_result } = success.result else {
+ panic!("expected pane move response");
+ };
+ assert!(move_result.changed);
+ assert_eq!(move_result.closed_workspace_id, Some(source_workspace));
+ assert_eq!(
+ move_result
+ .created_workspace
+ .as_ref()
+ .map(|ws| ws.label.as_str()),
+ Some("promoted")
+ );
+ assert_eq!(
+ move_result.created_workspace.as_ref().map(|ws| ws.focused),
+ Some(true)
+ );
+ assert_eq!(
+ move_result
+ .created_tab
+ .as_ref()
+ .map(|tab| tab.label.as_str()),
+ Some("main")
+ );
+ assert_eq!(
+ move_result.created_tab.as_ref().map(|tab| tab.focused),
+ Some(true)
+ );
+ assert_ne!(move_result.pane.pane_id, source_public);
+ assert_eq!(move_result.pane.terminal_id, source_terminal.to_string());
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].terminal_id(source),
+ Some(&source_terminal)
+ );
+ assert!(std::sync::Arc::ptr_eq(
+ &app.state.workspaces[0].tabs[0].render_notify,
+ &app.render_notify
+ ));
+ assert!(std::sync::Arc::ptr_eq(
+ &app.state.workspaces[0].tabs[0].render_dirty,
+ &app.render_dirty
+ ));
+ let envelopes = app.event_hub.events_after(0);
+ let events: Vec<_> = envelopes
+ .iter()
+ .map(|(_, envelope)| envelope.event)
+ .collect();
+ assert_eq!(
+ events,
+ vec![
+ EventKind::TabClosed,
+ EventKind::WorkspaceClosed,
+ EventKind::WorkspaceCreated,
+ EventKind::TabCreated,
+ EventKind::PaneMoved,
+ ]
+ );
+ match &envelopes[2].1.data {
+ EventData::WorkspaceCreated { workspace } => assert!(workspace.focused),
+ other => panic!("expected workspace created event, got {other:?}"),
+ }
+ match &envelopes[3].1.data {
+ EventData::TabCreated { tab } => assert!(tab.focused),
+ other => panic!("expected tab created event, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn api_pane_move_same_tab_returns_same_tab_noop() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ seed_terminal_states(&mut app);
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let source_tab = app.public_tab_id(0, 0).unwrap();
+
+ let response = app.handle_pane_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: source_public,
+ destination: PaneMoveDestination::Tab {
+ tab_id: source_tab,
+ target_pane_id: None,
+ split: SplitDirection::Right,
+ ratio: None,
+ },
+ focus: true,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneMove { move_result } = success.result else {
+ panic!("expected pane move response");
+ };
+ assert!(!move_result.changed);
+ assert_eq!(move_result.reason, Some(PaneMoveReason::SameTab));
+ assert_eq!(app.state.workspaces[0].tabs.len(), 1);
+ }
+
+ #[test]
+ fn api_pane_move_rejects_target_pane_outside_target_tab() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let target_tab = app.state.workspaces[0].test_add_tab(Some("target"));
+ let other_tab = app.state.workspaces[0].test_add_tab(Some("other"));
+ seed_terminal_states(&mut app);
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let target_tab_public = app.public_tab_id(0, target_tab).unwrap();
+ let wrong_target = app
+ .public_pane_id(0, app.state.workspaces[0].tabs[other_tab].root_pane)
+ .unwrap();
+
+ let response = app.handle_pane_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: source_public,
+ destination: PaneMoveDestination::Tab {
+ tab_id: target_tab_public,
+ target_pane_id: Some(wrong_target),
+ split: SplitDirection::Right,
+ ratio: None,
+ },
+ focus: true,
+ },
+ );
+
+ let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "target_pane_not_found");
+ assert_eq!(app.state.workspaces[0].tabs.len(), 3);
+ }
+
+ #[test]
+ fn api_pane_move_existing_tab_no_focus_preserves_previous_target_focus() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let target_tab = app.state.workspaces[0].test_add_tab(Some("target"));
+ let previously_focused = app.state.workspaces[0].tabs[target_tab].root_pane;
+ app.state.workspaces[0].active_tab = target_tab;
+ let explicit_target =
+ app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[target_tab]
+ .layout
+ .focus_pane(previously_focused);
+ seed_terminal_states(&mut app);
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let target_tab_public = app.public_tab_id(0, target_tab).unwrap();
+ let explicit_target_public = app.public_pane_id(0, explicit_target).unwrap();
+ let previously_focused_public = app.public_pane_id(0, previously_focused).unwrap();
+
+ let response = app.handle_pane_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: source_public,
+ destination: PaneMoveDestination::Tab {
+ tab_id: target_tab_public,
+ target_pane_id: Some(explicit_target_public),
+ split: SplitDirection::Right,
+ ratio: None,
+ },
+ focus: false,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneMove { move_result } = success.result else {
+ panic!("expected pane move response");
+ };
+ assert!(move_result.changed);
+ assert_eq!(move_result.focused_pane_id, previously_focused_public);
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].layout.focused(),
+ previously_focused
+ );
+ }
+
+ #[test]
+ fn api_pane_move_recovery_restores_removed_source_workspace() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let source_terminal = app.state.workspaces[0].tabs[0]
+ .terminal_id(source)
+ .unwrap()
+ .clone();
+ let context = PaneMoveRecoveryContext {
+ source_ws_idx: 0,
+ previous_workspace_id: app.public_workspace_id(0),
+ previous_workspace_label: app.state.workspaces[0].custom_name.clone(),
+ previous_tab_label: app.state.workspaces[0].tabs[0].custom_name.clone(),
+ previous_worktree_space: app.state.workspaces[0].worktree_space.clone(),
+ identity_cwd: app.state.workspaces[0].identity_cwd.clone(),
+ };
+ let taken = app.state.workspaces[0]
+ .take_pane_for_move(source)
+ .expect("source pane should be movable");
+ app.state.workspaces.remove(0);
+ app.state.active = None;
+ app.state.selected = 0;
+
+ app.recover_failed_pane_move(context, taken.moved);
+
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(app.state.workspaces[0].id, "w1");
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].terminal_id(source),
+ Some(&source_terminal)
+ );
+ assert_eq!(app.parse_pane_id("w1:p1"), Some((0, source)));
+ }
+
+ #[test]
+ fn api_pane_move_to_zoomed_target_returns_target_layout() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let target_tab = app.state.workspaces[0].test_add_tab(Some("target"));
+ let target = app.state.workspaces[0].tabs[target_tab].root_pane;
+ app.state.workspaces[0].tabs[target_tab].zoomed = true;
+ seed_terminal_states(&mut app);
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let target_tab_public = app.public_tab_id(0, target_tab).unwrap();
+ let target_public = app.public_pane_id(0, target).unwrap();
+
+ let response = app.handle_pane_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: source_public,
+ destination: PaneMoveDestination::Tab {
+ tab_id: target_tab_public.clone(),
+ target_pane_id: Some(target_public),
+ split: SplitDirection::Right,
+ ratio: None,
+ },
+ focus: true,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::PaneMove { move_result } = success.result else {
+ panic!("expected pane move response");
+ };
+ assert!(!move_result.changed);
+ assert_eq!(move_result.reason, Some(PaneMoveReason::ZoomedTab));
+ assert_eq!(move_result.target_layout.tab_id, target_tab_public);
+ assert_eq!(
+ move_result
+ .source_layout
+ .as_ref()
+ .map(|layout| layout.tab_id.as_str()),
+ app.public_tab_id(0, 0).as_deref()
+ );
+ }
+
+ #[test]
fn api_pane_zoom_current_toggles_zoom() {
let mut app = app_with_linked_worktree();
app.state.active = Some(0);