diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 1 | ||||
| -rw-r--r-- | src/api/schema.rs | 25 | ||||
| -rw-r--r-- | src/api/schema/panes.rs | 63 | ||||
| -rw-r--r-- | src/api/server.rs | 1 | ||||
| -rw-r--r-- | src/api/subscriptions.rs | 4 | ||||
| -rw-r--r-- | src/app/actions.rs | 2 | ||||
| -rw-r--r-- | src/app/api.rs | 1 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 1084 | ||||
| -rw-r--r-- | src/app/ids.rs | 4 | ||||
| -rw-r--r-- | src/app/mod.rs | 1 | ||||
| -rw-r--r-- | src/app/state.rs | 2 | ||||
| -rw-r--r-- | src/cli/pane.rs | 234 | ||||
| -rw-r--r-- | src/layout.rs | 40 | ||||
| -rw-r--r-- | src/protocol/wire.rs | 2 | ||||
| -rw-r--r-- | src/workspace.rs | 146 | ||||
| -rw-r--r-- | src/workspace/tab.rs | 80 |
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; |
