aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs25
-rw-r--r--src/api/schema/panes.rs63
-rw-r--r--src/api/server.rs1
-rw-r--r--src/api/subscriptions.rs4
-rw-r--r--src/app/actions.rs2
-rw-r--r--src/app/api.rs1
-rw-r--r--src/app/api/panes.rs1084
-rw-r--r--src/app/ids.rs4
-rw-r--r--src/app/mod.rs1
-rw-r--r--src/app/state.rs2
-rw-r--r--src/cli/pane.rs234
-rw-r--r--src/layout.rs40
-rw-r--r--src/protocol/wire.rs2
-rw-r--r--src/workspace.rs146
-rw-r--r--src/workspace/tab.rs80
16 files changed, 1681 insertions, 9 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index a9a8e25b..4832ac56 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -40,6 +40,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::AgentStart(_)
| Method::PaneSplit(_)
| Method::PaneSwap(_)
+ | Method::PaneMove(_)
| Method::PaneZoom(_)
| Method::PaneFocusDirection(_)
| Method::PaneResize(_)
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 491693ad..512c030f 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -86,6 +86,8 @@ pub enum Method {
PaneSplit(PaneSplitParams),
#[serde(rename = "pane.swap")]
PaneSwap(PaneSwapParams),
+ #[serde(rename = "pane.move")]
+ PaneMove(PaneMoveParams),
#[serde(rename = "pane.zoom")]
PaneZoom(PaneZoomParams),
#[serde(rename = "pane.layout")]
@@ -395,6 +397,8 @@ pub enum Subscription {
PaneClosed {},
#[serde(rename = "pane.focused")]
PaneFocused {},
+ #[serde(rename = "pane.moved")]
+ PaneMoved {},
#[serde(rename = "pane.exited")]
PaneExited {},
#[serde(rename = "pane.agent_detected")]
@@ -521,6 +525,9 @@ pub enum EventMatch {
PaneFocused {
pane_id: String,
},
+ PaneMoved {
+ pane_id: String,
+ },
PaneOutputChanged {
pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -555,6 +562,7 @@ pub enum EventKind {
PaneCreated,
PaneClosed,
PaneFocused,
+ PaneMoved,
PaneOutputChanged,
PaneExited,
PaneAgentDetected,
@@ -655,6 +663,9 @@ pub enum ResponseResult {
PaneSwap {
swap: PaneSwapResult,
},
+ PaneMove {
+ move_result: PaneMoveResult,
+ },
PaneZoom {
zoom: PaneZoomResult,
},
@@ -942,6 +953,20 @@ pub enum EventData {
pane_id: String,
workspace_id: String,
},
+ PaneMoved {
+ previous_pane_id: String,
+ previous_workspace_id: String,
+ previous_tab_id: String,
+ pane: Box<PaneInfo>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ created_workspace: Option<WorkspaceInfo>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ created_tab: Option<TabInfo>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ closed_workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ closed_tab_id: Option<String>,
+ },
PaneOutputChanged {
pane_id: String,
workspace_id: String,
diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs
index 6a1827a2..de330993 100644
--- a/src/api/schema/panes.rs
+++ b/src/api/schema/panes.rs
@@ -47,6 +47,39 @@ pub struct PaneSwapParams {
pub target_pane_id: Option<String>,
}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneMoveParams {
+ pub pane_id: String,
+ pub destination: PaneMoveDestination,
+ #[serde(default)]
+ pub focus: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum PaneMoveDestination {
+ Tab {
+ tab_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ target_pane_id: Option<String>,
+ split: SplitDirection,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ ratio: Option<f32>,
+ },
+ NewTab {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ label: Option<String>,
+ },
+ NewWorkspace {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ label: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ tab_label: Option<String>,
+ },
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PaneZoomParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -275,6 +308,36 @@ pub enum PaneSwapReason {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct PaneMoveResult {
+ pub changed: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reason: Option<PaneMoveReason>,
+ pub previous_pane_id: String,
+ pub previous_workspace_id: String,
+ pub previous_tab_id: String,
+ pub pane: Box<PaneInfo>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source_layout: Option<Box<PaneLayoutSnapshot>>,
+ pub target_layout: Box<PaneLayoutSnapshot>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub created_workspace: Option<super::WorkspaceInfo>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub created_tab: Option<super::TabInfo>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub closed_workspace_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub closed_tab_id: Option<String>,
+ pub focused_pane_id: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PaneMoveReason {
+ SameTab,
+ ZoomedTab,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PaneZoomResult {
pub changed: bool,
pub zoom_changed: bool,
diff --git a/src/api/server.rs b/src/api/server.rs
index 0d49d21d..5650b2d0 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -305,6 +305,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::AgentStart(_) => "agent.start",
Method::PaneSplit(_) => "pane.split",
Method::PaneSwap(_) => "pane.swap",
+ Method::PaneMove(_) => "pane.move",
Method::PaneZoom(_) => "pane.zoom",
Method::PaneLayout(_) => "pane.layout",
Method::PaneNeighbor(_) => "pane.neighbor",
diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs
index e8563468..e8e362fe 100644
--- a/src/api/subscriptions.rs
+++ b/src/api/subscriptions.rs
@@ -156,6 +156,10 @@ impl ActiveSubscription {
event_kind: crate::api::schema::EventKind::PaneFocused,
last_sequence: 0,
})),
+ Subscription::PaneMoved {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::PaneMoved,
+ last_sequence: 0,
+ })),
Subscription::PaneExited {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneExited,
last_sequence: 0,
diff --git a/src/app/actions.rs b/src/app/actions.rs
index c2b35dbd..23b886d9 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -2813,6 +2813,8 @@ impl AppState {
let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id);
let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx);
self.pane_id_aliases.retain(|_, alias| *alias != pane_id);
+ self.public_pane_id_aliases
+ .retain(|_, alias| *alias != pane_id);
let should_close_workspace = {
let ws = &mut self.workspaces[ws_idx];
ws.remove_pane(pane_id)
diff --git a/src/app/api.rs b/src/app/api.rs
index 32192a07..3a7276c4 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -754,6 +754,7 @@ impl App {
Method::AgentSend(params) => return self.handle_agent_send(request.id, params),
Method::PaneSplit(params) => return self.handle_pane_split(request.id, params),
Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params),
+ Method::PaneMove(params) => return self.handle_pane_move(request.id, params),
Method::PaneZoom(params) => return self.handle_pane_zoom(request.id, params),
Method::PaneLayout(params) => return self.handle_pane_layout(request.id, params),
Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params),
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);
diff --git a/src/app/ids.rs b/src/app/ids.rs
index 0ce02c1f..5e20c388 100644
--- a/src/app/ids.rs
+++ b/src/app/ids.rs
@@ -88,6 +88,10 @@ impl App {
}
pub(super) fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> {
+ if let Some(alias) = self.state.public_pane_id_aliases.get(id).copied() {
+ return self.find_pane(alias).map(|(ws_idx, _)| (ws_idx, alias));
+ }
+
if let Some(rest) = id.strip_prefix("p_") {
if let Some((ws_raw, pane_raw)) = rest.rsplit_once('_') {
let ws_idx = self.parse_workspace_id(ws_raw)?;
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 5d09800b..5447a2de 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -405,6 +405,7 @@ impl App {
terminals: std::collections::HashMap::new(),
direct_attach_resize_locks: std::collections::HashSet::new(),
pane_id_aliases: std::collections::HashMap::new(),
+ public_pane_id_aliases: std::collections::HashMap::new(),
workspaces,
active,
previous_pane_focus: None,
diff --git a/src/app/state.rs b/src/app/state.rs
index f37ccb22..ae4295bd 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1249,6 +1249,7 @@ pub struct AppState {
/// Terminal ids whose size is currently owned by a direct attach client.
pub direct_attach_resize_locks: std::collections::HashSet<crate::terminal::TerminalId>,
pub(crate) pane_id_aliases: std::collections::HashMap<u32, PaneId>,
+ pub(crate) public_pane_id_aliases: std::collections::HashMap<String, PaneId>,
pub workspaces: Vec<Workspace>,
pub active: Option<usize>,
pub(crate) previous_pane_focus: Option<PaneFocusTarget>,
@@ -1587,6 +1588,7 @@ impl AppState {
terminals: std::collections::HashMap::new(),
direct_attach_resize_locks: std::collections::HashSet::new(),
pane_id_aliases: std::collections::HashMap::new(),
+ public_pane_id_aliases: std::collections::HashMap::new(),
workspaces: Vec::new(),
active: None,
previous_pane_focus: None,
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index 08d84cf9..5f0b42f2 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -1,9 +1,10 @@
use crate::api::schema::{
Method, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, PaneLayoutParams,
- PaneListParams, PaneNeighborParams, PaneReadParams, PaneReleaseAgentParams, PaneRenameParams,
- PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams,
- PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
- PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request,
+ PaneListParams, PaneMoveDestination, PaneMoveParams, PaneNeighborParams, PaneReadParams,
+ PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams,
+ PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams,
+ PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams,
+ ReadFormat, ReadSource, Request, SplitDirection,
};
pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
@@ -25,6 +26,7 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
"rename" => pane_rename(&args[1..]),
"split" => pane_split(&args[1..]),
"swap" => pane_swap(&args[1..]),
+ "move" => pane_move(&args[1..]),
"close" => pane_close(&args[1..]),
"send-text" => pane_send_text(&args[1..]),
"send-keys" => pane_send_keys(&args[1..]),
@@ -580,6 +582,170 @@ fn pane_swap(args: &[String]) -> std::io::Result<i32> {
})?)
}
+fn pane_move(args: &[String]) -> std::io::Result<i32> {
+ let params = match parse_pane_move_args(args) {
+ Ok(params) => params,
+ Err(message) => {
+ eprintln!("{message}");
+ return Ok(2);
+ }
+ };
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:move".into(),
+ method: Method::PaneMove(params),
+ })?)
+}
+
+fn parse_pane_move_args(args: &[String]) -> Result<PaneMoveParams, String> {
+ let Some(raw_pane_id) = args.first() else {
+ return Err(pane_move_usage());
+ };
+ if raw_pane_id.starts_with('-') {
+ return Err(pane_move_usage());
+ }
+
+ let pane_id = super::normalize_pane_id(raw_pane_id);
+ let mut tab_id = None;
+ let mut new_tab = false;
+ let mut new_workspace = false;
+ let mut workspace_id = None;
+ let mut target_pane_id = None;
+ let mut split = None;
+ let mut ratio = None;
+ let mut label = None;
+ let mut tab_label = None;
+ let mut focus = true;
+
+ let mut index = 1;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--tab" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --tab".into());
+ };
+ tab_id = Some(super::normalize_tab_id(value));
+ index += 2;
+ }
+ "--new-tab" => {
+ new_tab = true;
+ index += 1;
+ }
+ "--new-workspace" => {
+ new_workspace = true;
+ index += 1;
+ }
+ "--workspace" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --workspace".into());
+ };
+ workspace_id = Some(super::normalize_workspace_id(value));
+ index += 2;
+ }
+ "--target-pane" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --target-pane".into());
+ };
+ target_pane_id = Some(super::normalize_pane_id(value));
+ index += 2;
+ }
+ "--split" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --split".into());
+ };
+ split = Some(parse_split_direction(value)?);
+ index += 2;
+ }
+ "--ratio" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --ratio".into());
+ };
+ let parsed = value
+ .parse::<f32>()
+ .map_err(|_| format!("invalid ratio: {value}"))?;
+ if !parsed.is_finite() {
+ return Err(format!("invalid ratio: {value}"));
+ }
+ ratio = Some(parsed);
+ index += 2;
+ }
+ "--label" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --label".into());
+ };
+ label = Some(value.clone());
+ index += 2;
+ }
+ "--tab-label" => {
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --tab-label".into());
+ };
+ tab_label = Some(value.clone());
+ index += 2;
+ }
+ "--focus" => {
+ focus = true;
+ index += 1;
+ }
+ "--no-focus" => {
+ focus = false;
+ index += 1;
+ }
+ other => return Err(format!("unknown option: {other}")),
+ }
+ }
+
+ let destination_count =
+ usize::from(tab_id.is_some()) + usize::from(new_tab) + usize::from(new_workspace);
+ if destination_count != 1 {
+ return Err(pane_move_usage());
+ }
+
+ let destination = if let Some(tab_id) = tab_id {
+ let Some(split) = split else {
+ return Err(pane_move_usage());
+ };
+ if workspace_id.is_some()
+ || new_tab
+ || new_workspace
+ || label.is_some()
+ || tab_label.is_some()
+ {
+ return Err(pane_move_usage());
+ }
+ PaneMoveDestination::Tab {
+ tab_id,
+ target_pane_id,
+ split,
+ ratio,
+ }
+ } else if new_tab {
+ if split.is_some() || target_pane_id.is_some() || new_workspace || tab_label.is_some() {
+ return Err(pane_move_usage());
+ }
+ PaneMoveDestination::NewTab {
+ workspace_id,
+ label,
+ }
+ } else {
+ if split.is_some() || target_pane_id.is_some() || workspace_id.is_some() || new_tab {
+ return Err(pane_move_usage());
+ }
+ PaneMoveDestination::NewWorkspace { label, tab_label }
+ };
+
+ Ok(PaneMoveParams {
+ pane_id,
+ destination,
+ focus,
+ })
+}
+
+fn pane_move_usage() -> String {
+ "usage: herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus]\n herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus]\n herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--focus|--no-focus]"
+ .into()
+}
+
fn parse_pane_swap_args(args: &[String]) -> Result<PaneSwapParams, String> {
let mut pane_id = None;
let mut direction = None;
@@ -647,6 +813,16 @@ fn parse_pane_swap_args(args: &[String]) -> Result<PaneSwapParams, String> {
}
}
+fn parse_split_direction(value: &str) -> Result<SplitDirection, String> {
+ match value {
+ "right" => Ok(SplitDirection::Right),
+ "down" => Ok(SplitDirection::Down),
+ _ => Err(format!(
+ "invalid split direction: {value} (expected right or down)"
+ )),
+ }
+}
+
fn parse_pane_direction(value: &str) -> Result<PaneDirection, String> {
match value {
"left" => Ok(PaneDirection::Left),
@@ -1188,6 +1364,9 @@ fn print_pane_help() {
);
eprintln!(" herdr pane swap --direction left|right|up|down [--pane ID|--current]");
eprintln!(" herdr pane swap --source-pane ID --target-pane ID");
+ eprintln!(" herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus]");
+ eprintln!(" herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus]");
+ eprintln!(" herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--focus|--no-focus]");
eprintln!(" herdr pane close <pane_id>");
eprintln!(" herdr pane send-text <pane_id> <text>");
eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]");
@@ -1280,6 +1459,53 @@ mod tests {
}
#[test]
+ fn parse_pane_move_args_accepts_existing_tab_destination() {
+ let params = parse_pane_move_args(&args(&[
+ "issue-1",
+ "--tab",
+ "issue:2",
+ "--split",
+ "right",
+ "--target-pane",
+ "issue-3",
+ "--ratio",
+ "0.25",
+ "--no-focus",
+ ]))
+ .unwrap();
+
+ assert_eq!(params.pane_id, "issue-1");
+ assert!(!params.focus);
+ assert_eq!(
+ params.destination,
+ PaneMoveDestination::Tab {
+ tab_id: "issue:2".into(),
+ target_pane_id: Some("issue-3".into()),
+ split: SplitDirection::Right,
+ ratio: Some(0.25),
+ }
+ );
+ }
+
+ #[test]
+ fn parse_pane_move_args_rejects_target_pane_without_tab() {
+ let err =
+ parse_pane_move_args(&args(&["issue-1", "--target-pane", "issue-2"])).unwrap_err();
+
+ assert!(err.contains("usage: herdr pane move"));
+ }
+
+ #[test]
+ fn parse_pane_move_args_rejects_non_finite_ratio() {
+ let err = parse_pane_move_args(&args(&[
+ "issue-1", "--tab", "issue:2", "--split", "right", "--ratio", "NaN",
+ ]))
+ .unwrap_err();
+
+ assert!(err.contains("invalid ratio"));
+ }
+
+ #[test]
fn parse_pane_zoom_args_defaults_to_current_toggle() {
let params = parse_pane_zoom_args(&args(&[])).unwrap();
diff --git a/src/layout.rs b/src/layout.rs
index bd7828c6..06426f85 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -132,6 +132,30 @@ impl TileLayout {
new_id
}
+ /// Insert an existing pane id next to a target pane without allocating a new
+ /// pane or spawning a terminal runtime.
+ pub fn insert_pane_near(
+ &mut self,
+ target: PaneId,
+ moved: PaneId,
+ direction: Direction,
+ ratio: f32,
+ ) -> bool {
+ if target == moved {
+ return false;
+ }
+ let ids = self.pane_ids();
+ if !ids.contains(&target) || ids.contains(&moved) {
+ return false;
+ }
+
+ let placeholder = PaneId::from_raw(0);
+ let old = std::mem::replace(&mut self.root, Node::Pane(placeholder));
+ self.root = split_at(old, target, direction, moved, valid_split_ratio(ratio));
+ self.focus = moved;
+ true
+ }
+
/// Close the focused pane. Returns false if it's the last pane.
pub fn close_focused(&mut self) -> bool {
if self.pane_count() <= 1 {
@@ -709,6 +733,22 @@ mod tests {
}
#[test]
+ fn insert_existing_pane_near_target_preserves_existing_ids_and_focuses_moved_pane() {
+ let (mut layout, root) = TileLayout::new();
+ let moved = pane(99);
+
+ assert!(layout.insert_pane_near(root, moved, Direction::Horizontal, 0.25));
+
+ assert_eq!(layout.pane_count(), 2);
+ assert_eq!(layout.pane_ids(), vec![root, moved]);
+ assert_eq!(layout.focused(), moved);
+ let splits = split_snapshot(&layout);
+ assert_eq!(splits, vec![(Direction::Horizontal, 0.25)]);
+ assert_eq!(pane_rect(&layout, root), Rect::new(0, 0, 25, 40));
+ assert_eq!(pane_rect(&layout, moved), Rect::new(25, 0, 75, 40));
+ }
+
+ #[test]
fn split_focused_with_ratio_sets_new_split_ratio() {
let (mut layout, root) = TileLayout::new();
layout.focus_pane(root);
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 357f414e..03492547 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 13;
+pub const PROTOCOL_VERSION: u32 = 14;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
diff --git a/src/workspace.rs b/src/workspace.rs
index 9e342ffd..b734008f 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -20,6 +20,7 @@ mod tab;
#[cfg(test)]
use self::git::git_ahead_behind;
+pub(crate) use self::tab::MovedPane;
pub use self::{
git::{
derive_label_from_cwd, git_branch, git_space_metadata, git_status_cache_key,
@@ -179,6 +180,48 @@ impl DerefMut for Workspace {
}
impl Workspace {
+ fn adjust_active_tab_after_removal(&mut self, removed_idx: usize) {
+ if self.tabs.is_empty() {
+ self.active_tab = 0;
+ } else if self.active_tab >= self.tabs.len() {
+ self.active_tab = self.tabs.len() - 1;
+ } else if removed_idx <= self.active_tab && self.active_tab > 0 {
+ self.active_tab -= 1;
+ }
+ }
+
+ pub(crate) fn from_existing_pane(
+ label: Option<String>,
+ tab_label: Option<String>,
+ identity_cwd: PathBuf,
+ moved: MovedPane,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ ) -> Self {
+ let id = generate_workspace_id();
+ let root_pane = moved.pane_id;
+ let tab = Tab::from_existing_pane(1, tab_label, moved, events, render_notify, render_dirty);
+ let mut public_pane_numbers = HashMap::new();
+ public_pane_numbers.insert(root_pane, 1);
+ Self {
+ id,
+ custom_name: label,
+ identity_cwd: identity_cwd.clone(),
+ cached_git_branch: git_branch(&identity_cwd),
+ cached_git_ahead_behind: None,
+ cached_git_space: git_space_metadata(&identity_cwd),
+ worktree_space: None,
+ public_pane_numbers,
+ next_public_pane_number: 2,
+ next_public_tab_number: 2,
+ tabs: vec![tab],
+ active_tab: 0,
+ #[cfg(test)]
+ test_runtimes: HashMap::new(),
+ }
+ }
+
pub fn new(
initial_cwd: PathBuf,
rows: u16,
@@ -710,6 +753,85 @@ impl Workspace {
false
}
+ pub(crate) fn take_pane_for_move(&mut self, pane_id: PaneId) -> Option<TakenPane> {
+ let tab_idx = self.find_tab_index_for_pane(pane_id)?;
+ let pane_count = self.tabs[tab_idx].layout.pane_count();
+ if pane_count <= 1 {
+ let mut tab = self.tabs.remove(tab_idx);
+ let moved = tab.take_pane_for_move(pane_id)?;
+ self.adjust_active_tab_after_removal(tab_idx);
+ return Some(TakenPane {
+ moved,
+ removed_tab_idx: Some(tab_idx),
+ workspace_empty: self.tabs.is_empty(),
+ });
+ }
+
+ let moved = self.tabs[tab_idx].take_pane_for_move(pane_id)?;
+ Some(TakenPane {
+ moved,
+ removed_tab_idx: None,
+ workspace_empty: false,
+ })
+ }
+
+ pub(crate) fn insert_moved_pane_into_tab(
+ &mut self,
+ tab_idx: usize,
+ target_pane_id: PaneId,
+ moved: MovedPane,
+ direction: Direction,
+ ratio: f32,
+ ) -> Result<PaneId, MovedPane> {
+ let pane_id = moved.pane_id;
+ let Some(tab) = self.tabs.get_mut(tab_idx) else {
+ return Err(moved);
+ };
+ tab.insert_existing_pane(target_pane_id, moved, direction, ratio)?;
+ if !self.public_pane_numbers.contains_key(&pane_id) {
+ self.register_new_pane_with_number(pane_id, self.next_public_pane_number);
+ }
+ Ok(pane_id)
+ }
+
+ pub(crate) fn create_tab_from_existing_pane(
+ &mut self,
+ moved: MovedPane,
+ label: Option<String>,
+ fallback_events: mpsc::Sender<AppEvent>,
+ fallback_render_notify: Arc<Notify>,
+ fallback_render_dirty: Arc<AtomicBool>,
+ ) -> usize {
+ let number = self.next_public_tab_number;
+ self.next_public_tab_number += 1;
+ let pane_id = moved.pane_id;
+ let (events, render_notify, render_dirty) = self
+ .active_tab()
+ .map(|tab| {
+ (
+ tab.events.clone(),
+ tab.render_notify.clone(),
+ tab.render_dirty.clone(),
+ )
+ })
+ .unwrap_or((
+ fallback_events,
+ fallback_render_notify,
+ fallback_render_dirty,
+ ));
+ let tab =
+ Tab::from_existing_pane(number, label, moved, events, render_notify, render_dirty);
+ if !self.public_pane_numbers.contains_key(&pane_id) {
+ self.register_new_pane_with_number(pane_id, self.next_public_pane_number);
+ }
+ self.tabs.push(tab);
+ self.tabs.len() - 1
+ }
+
+ pub(crate) fn unregister_moved_pane(&mut self, pane_id: PaneId) {
+ self.unregister_pane(pane_id);
+ }
+
pub fn public_pane_number(&self, pane_id: PaneId) -> Option<usize> {
self.public_pane_numbers.get(&pane_id).copied()
}
@@ -866,6 +988,12 @@ impl Workspace {
}
}
+pub(crate) struct TakenPane {
+ pub moved: MovedPane,
+ pub removed_tab_idx: Option<usize>,
+ pub workspace_empty: bool,
+}
+
#[cfg(test)]
impl Workspace {
pub(crate) fn test_new(name: &str) -> Self {
@@ -1038,6 +1166,24 @@ mod tests {
}
#[test]
+ fn failed_moved_pane_insert_returns_pane_for_recovery() {
+ let mut source = Workspace::test_new("source");
+ let source_pane = source.tabs[0].root_pane;
+ let taken = source
+ .take_pane_for_move(source_pane)
+ .expect("source pane should be movable");
+ let mut target = Workspace::test_new("target");
+ let missing_target = PaneId::alloc();
+
+ let recovered = target
+ .insert_moved_pane_into_tab(0, missing_target, taken.moved, Direction::Horizontal, 0.5)
+ .expect_err("invalid target should return the moved pane");
+
+ assert_eq!(recovered.pane_id, source_pane);
+ assert!(!target.tabs[0].panes.contains_key(&source_pane));
+ }
+
+ #[test]
fn workspace_identity_follows_first_tab_root_pane_cwd() {
let mut ws = Workspace::test_new("ignored");
ws.custom_name = None;
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
index 4363e05c..0d9e6fdb 100644
--- a/src/workspace/tab.rs
+++ b/src/workspace/tab.rs
@@ -7,12 +7,17 @@ use ratatui::layout::Direction;
use tokio::sync::{mpsc, Notify};
use crate::events::AppEvent;
-use crate::layout::{PaneId, TileLayout};
+use crate::layout::{Node, PaneId, TileLayout};
use crate::pane::PaneState;
use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState};
pub(crate) type DetachedPane = (PaneId, TerminalId);
+pub(crate) struct MovedPane {
+ pub pane_id: PaneId,
+ pub pane_state: PaneState,
+}
+
pub struct NewPane {
pub pane_id: PaneId,
pub terminal: TerminalState,
@@ -401,6 +406,79 @@ impl Tab {
self.detach_pane(pane_id)
}
+ pub(crate) fn from_existing_pane(
+ number: usize,
+ custom_name: Option<String>,
+ moved: MovedPane,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ ) -> Self {
+ let mut panes = HashMap::new();
+ let pane_id = moved.pane_id;
+ panes.insert(pane_id, moved.pane_state);
+ Self {
+ custom_name,
+ number,
+ root_pane: pane_id,
+ layout: TileLayout::from_saved(Node::Pane(pane_id), pane_id),
+ panes,
+ #[cfg(test)]
+ runtimes: HashMap::new(),
+ zoomed: false,
+ events,
+ render_notify,
+ render_dirty,
+ }
+ }
+
+ pub(crate) fn take_pane_for_move(&mut self, pane_id: PaneId) -> Option<MovedPane> {
+ if !self.panes.contains_key(&pane_id) {
+ return None;
+ }
+
+ if self.layout.pane_count() > 1 {
+ let next_root = self.promoted_root_if_needed(pane_id);
+ if self.layout.focused() == pane_id {
+ self.layout.close_focused();
+ } else {
+ let prev_focus = self.layout.focused();
+ self.layout.focus_pane(pane_id);
+ self.layout.close_focused();
+ self.layout.focus_pane(prev_focus);
+ }
+ if let Some(next_root) = next_root {
+ self.root_pane = next_root;
+ }
+ }
+
+ let pane_state = self.panes.remove(&pane_id)?;
+ self.zoomed = false;
+ Some(MovedPane {
+ pane_id,
+ pane_state,
+ })
+ }
+
+ pub(crate) fn insert_existing_pane(
+ &mut self,
+ target_pane_id: PaneId,
+ moved: MovedPane,
+ direction: Direction,
+ ratio: f32,
+ ) -> Result<PaneId, MovedPane> {
+ if !self
+ .layout
+ .insert_pane_near(target_pane_id, moved.pane_id, direction, ratio)
+ {
+ return Err(moved);
+ }
+ let pane_id = moved.pane_id;
+ self.panes.insert(pane_id, moved.pane_state);
+ self.zoomed = false;
+ Ok(pane_id)
+ }
+
fn detach_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> {
if self.layout.pane_count() <= 1 {
return None;