diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-13 01:59:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-13 01:59:24 +0300 |
| commit | d35c642ef36ee5ac2afe7c1b18ad5a717bc807d8 (patch) | |
| tree | 89f176d076ff65e1cb141f914707a552c17776fc | |
| parent | 469cf9cede389036f5799e2f41483413f1d7e60b (diff) | |
feat: add pane move api and cli
refs #299
| -rw-r--r-- | docs/next/CHANGELOG.md | 4 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/cli-reference.mdx | 3 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/socket-api.mdx | 34 | ||||
| -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 | ||||
| -rw-r--r-- | tests/api_ping.rs | 2 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 12 | ||||
| -rw-r--r-- | tests/client_mode.rs | 36 | ||||
| -rw-r--r-- | tests/cross_area.rs | 16 | ||||
| -rw-r--r-- | tests/detach_reattach.rs | 44 | ||||
| -rw-r--r-- | tests/multi_client.rs | 2 | ||||
| -rw-r--r-- | tests/server_headless.rs | 14 |
26 files changed, 1782 insertions, 75 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 9f44d529..c4271cf6 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,7 +2,11 @@ ## Unreleased +### Added +- Added `pane.move` and `herdr pane move` to relocate a running pane into another tab, a new tab, or a new workspace without restarting its terminal process. (#299) + ### Changed +- Bumped the client/server protocol version to 14 for `pane.move` compatibility. (#299) - Public workspace, tab, and pane ids are now short stable handles such as `w1`, `w1:t1`, and `w1:p1`; closed tab and pane ids no longer retarget later resources. (#569) ## [0.6.10] - 2026-06-11 diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 32baa746..fc6be710 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -121,6 +121,9 @@ herdr pane rename <pane_id> <label>|--clear herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus] herdr pane swap --direction left|right|up|down [--pane ID|--current] herdr pane swap --source-pane ID --target-pane ID +herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus] +herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus] +herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--focus|--no-focus] herdr pane close <pane_id> ``` diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 058513f5..fb124ac9 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -85,15 +85,16 @@ Raw socket method names use dot notation: | Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.close` | | Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` | | Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.close` | -| Pane | `pane.split`, `pane.swap`, `pane.zoom`, `pane.layout`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | +| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | | Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send`, `agent.rename`, `agent.focus`, `agent.start` | | Events | `events.subscribe`, `events.wait` | | Integrations | `integration.install`, `integration.uninstall` | Some CLI commands are conveniences around these methods. For example, `herdr agent wait` resolves an agent target and then subscribes to pane agent state events. -Pane control methods use public pane ids such as `w1:p1`. Omit `pane_id` to use -the server's active focused pane. +Pane control methods use public pane ids such as `w1:p1`. Methods whose +schema makes `pane_id` optional use the server's active focused pane when it is +omitted. `pane.move` always requires the source `pane_id`. ```json {"id":"req_layout","method":"pane.layout","params":{"pane_id":"w1:p1"}} @@ -124,6 +125,30 @@ running processes. The response is `type: "pane_swap"` with `changed`, optional `cross_tab`. When a tab is zoomed, swap keeps zoom active and mutates the hidden full-tab layout. +`pane.move` moves a running pane to a different tab, a new tab, or a new +workspace: + +```json +{"id":"req_move_tab","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"tab","tab_id":"w1:t2","target_pane_id":"w1:p3","split":"right","ratio":0.5},"focus":true}} +{"id":"req_move_new_tab","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"new_tab","workspace_id":"w1","label":"logs"},"focus":true}} +{"id":"req_move_new_workspace","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"new_workspace","label":"logs","tab_label":"main"},"focus":true}} +``` + +Existing-tab moves require `split: "right" | "down"`. `target_pane_id` is +optional and defaults to the target tab's focused pane. Same-tab layout changes +remain `pane.swap`; moving to the source tab returns `changed: false` with +`reason: "same_tab"`. Moves involving a zoomed source or target tab return +`changed: false` with `reason: "zoomed_tab"`. + +The response is `type: "pane_move"` with `changed`, optional `reason`, +`previous_pane_id`, `previous_workspace_id`, `previous_tab_id`, the moved +`pane`, optional `source_layout`, `target_layout`, optional created workspace or +tab records, optional closed workspace or tab ids, and `focused_pane_id`. +Cross-workspace moves keep the internal pane and terminal alive but assign a new +public pane id in the destination workspace. Subscribers can listen for +`pane.moved`; Herdr does not emit fake pane close/create events for the moved +terminal process. + `pane.zoom` toggles, enables, or disables zoom for the target pane's tab: ```json @@ -319,6 +344,9 @@ Subscribe to events when you need a long-lived stream: The first response acknowledges the subscription. Later lines are pushed events. Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.renamed`, `workspace.closed`, and `workspace.focused`. +Pane event subscriptions include `pane.created`, `pane.closed`, `pane.focused`, +`pane.moved`, `pane.exited`, `pane.agent_detected`, +`pane.output_matched`, and `pane.agent_status_changed`. Use `events.wait` when you want one matching event and then a response. 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; diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 7f4271bc..1b5ebdfa 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -304,7 +304,7 @@ fn ping_over_socket_returns_version() { assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION")); // Intentionally hardcoded so wire protocol bumps require updating this test. // Changing this value means old clients/servers are no longer compatible. - assert_eq!(value["result"]["protocol"], 13); + assert_eq!(value["result"]["protocol"], 14); cleanup_spawned_herdr(child, base); } diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 287be14b..da730b80 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -1273,7 +1273,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {full_stdout}" ); assert!( - full_stdout.contains(" protocol: 13"), + full_stdout.contains(" protocol: 14"), "stdout: {full_stdout}" ); assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); @@ -1306,7 +1306,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {server_stdout}" ); assert!( - server_stdout.contains("protocol: 13"), + server_stdout.contains("protocol: 14"), "stdout: {server_stdout}" ); @@ -1318,7 +1318,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); assert!( - client_stdout.contains("protocol: 13"), + client_stdout.contains("protocol: 14"), "stdout: {client_stdout}" ); assert!( @@ -1328,7 +1328,7 @@ fn status_commands_report_client_and_server_versions() { let full_json = run_cli_json(&socket_path, &["status", "--json"]); assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(full_json["client"]["protocol"], 13); + assert_eq!(full_json["client"]["protocol"], 14); assert_eq!(full_json["server"]["status"], "running"); assert_eq!(full_json["server"]["running"], true); assert_eq!(full_json["server"]["compatible"], true); @@ -1342,12 +1342,12 @@ fn status_commands_report_client_and_server_versions() { let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]); assert_eq!(server_json["status"], "running"); assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(server_json["protocol"], 13); + assert_eq!(server_json["protocol"], 14); assert_eq!(server_json["compatible"], true); let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]); assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(client_json["protocol"], 13); + assert_eq!(client_json["protocol"], 14); assert!(client_json["binary"] .as_str() .is_some_and(|path| !path.is_empty())); diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 99c6e8ad..64e28c58 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -274,8 +274,8 @@ fn client_connects_and_receives_frame() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13, "server should report protocol version 13"); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14, "server should report protocol version 14"); assert!( error.is_none(), "handshake should not have error: {:?}", @@ -342,8 +342,8 @@ fn client_sees_headless_startup_config_diagnostic() { let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); stream @@ -391,8 +391,8 @@ fn client_input_forwarded_to_pane() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Send an Input message containing "echo hello\n". @@ -445,8 +445,8 @@ fn client_resize_sends_message() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Drain the initial frame(s). @@ -504,8 +504,8 @@ fn server_shutdown_sends_message_to_client() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Send SIGINT so the server takes the graceful shutdown path and @@ -736,8 +736,8 @@ fn client_receives_frame_after_pane_output() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); read_next_frame_payload(&mut stream, Duration::from_secs(10)) @@ -783,8 +783,8 @@ fn navigate_mode_keybind_dispatch_in_server() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -901,8 +901,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Drain initial frame(s). @@ -1000,8 +1000,8 @@ fn client_receives_notify_on_agent_state_change() { // Connect as a client and perform handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Drain initial frame(s). diff --git a/tests/cross_area.rs b/tests/cross_area.rs index fcac6f81..d596a8fc 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -687,7 +687,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Local attach (client A). let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, 13, 100, 30); + client_handshake(&mut client_a, 14, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); // Use herdr: create a workspace and write output into its pane. @@ -724,7 +724,7 @@ fn cross_area_detach_and_reattach_preserves_state() { // Reattach from another terminal/session (client B). let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, 13, 80, 24); + client_handshake(&mut client_b, 14, 80, 24); assert!( wait_for_frame(&mut client_b, Duration::from_secs(5)), "reattached client should receive frame" @@ -780,7 +780,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, 13, 100, 30); + client_handshake(&mut client_a, 14, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); let created = workspace_create(&api_socket, "agent-persist"); @@ -833,7 +833,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() { // Reattach and ensure client-side state reflects the persisted working status. let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, 13, 80, 24); + client_handshake(&mut client_b, 14, 80, 24); let saw_working_on_client = wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| { frame_contains_text(frame, "working") @@ -878,7 +878,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client = UnixStream::connect(&client_socket).expect("client should connect"); - client_handshake(&mut client, 13, 100, 30); + client_handshake(&mut client, 14, 100, 30); assert!(wait_for_frame(&mut client, Duration::from_secs(2))); drain_server_messages(&mut client, Duration::from_millis(300)); @@ -941,9 +941,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() { wait_for_socket(&client_socket, Duration::from_secs(10)); let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect"); - client_handshake(&mut client_a, 13, 110, 30); + client_handshake(&mut client_a, 14, 110, 30); let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect"); - client_handshake(&mut client_b, 13, 100, 30); + client_handshake(&mut client_b, 14, 100, 30); assert!(wait_for_frame(&mut client_a, Duration::from_secs(2))); assert!(wait_for_frame(&mut client_b, Duration::from_secs(2))); @@ -1112,7 +1112,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() { let mut reconnect_client = UnixStream::connect(&client_socket).expect("new client should connect after restart"); - client_handshake(&mut reconnect_client, 13, 80, 24); + client_handshake(&mut reconnect_client, 14, 80, 24); assert!( wait_for_frame(&mut reconnect_client, Duration::from_secs(5)), "new client should receive frame after restart" diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs index d8313914..a6c3ce84 100644 --- a/tests/detach_reattach.rs +++ b/tests/detach_reattach.rs @@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -338,8 +338,8 @@ fn explicit_detach_message_causes_clean_disconnect() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -397,8 +397,8 @@ fn reattach_after_detach_shows_current_state() { // --- Client A --- let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); let (version, error) = - client_handshake(&mut stream_a, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream_a, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -436,8 +436,8 @@ fn reattach_after_detach_shows_current_state() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); let (version, error) = - client_handshake(&mut stream_b, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream_b, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!( error.is_none(), "reattach handshake should succeed: {:?}", @@ -516,8 +516,8 @@ fn processes_survive_during_and_after_detach() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -555,8 +555,8 @@ fn processes_survive_during_and_after_detach() { // Reattach — verify we can connect and receive a frame. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); let (version, error) = - client_handshake(&mut stream_b, 13, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream_b, 14, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Verify the reattached client receives a frame. @@ -604,8 +604,8 @@ fn server_persists_after_client_connection_drop() { // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Drain initial frames. @@ -631,8 +631,8 @@ fn server_persists_after_client_connection_drop() { // Reattach — verify we can connect and handshake again. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); let (version, error) = - client_handshake(&mut stream_b, 13, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream_b, 14, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "reattach should succeed: {:?}", error); cleanup_spawned_herdr(spawned, base); @@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() { let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); let (version, error) = - client_handshake(&mut stream, 13, 120, 40).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream, 14, 120, 40).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); drain_messages(&mut stream); @@ -722,8 +722,8 @@ fn output_accumulated_while_detached_visible_on_reattach() { // Connect and handshake client A. let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); let (version, error) = - client_handshake(&mut stream_a, 13, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream_a, 14, 80, 24).expect("handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Detach client A immediately. @@ -780,8 +780,8 @@ fn output_accumulated_while_detached_visible_on_reattach() { // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); let (version, error) = - client_handshake(&mut stream_b, 13, 80, 24).expect("reattach handshake should succeed"); - assert_eq!(version, 13); + client_handshake(&mut stream_b, 14, 80, 24).expect("reattach handshake should succeed"); + assert_eq!(version, 14); assert!(error.is_none(), "{:?}", error); // Client B should receive a frame with the current state. diff --git a/tests/multi_client.rs b/tests/multi_client.rs index aedb01dc..cecac406 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -566,7 +566,7 @@ fn client_handshake( fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream { let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket"); - client_handshake(&mut stream, 13, cols, rows).expect("handshake should succeed"); + client_handshake(&mut stream, 14, cols, rows).expect("handshake should succeed"); stream } diff --git a/tests/server_headless.rs b/tests/server_headless.rs index 84a0c29e..9420ed65 100644 --- a/tests/server_headless.rs +++ b/tests/server_headless.rs @@ -595,9 +595,9 @@ fn client_handshake_succeeds() { // Send Hello with the current protocol version, 80 cols, 24 rows. let (version, error) = - client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed"); + client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed"); - assert_eq!(version, 13, "server should report protocol version 13"); + assert_eq!(version, 14, "server should report protocol version 14"); assert!( error.is_none(), "handshake should not have an error: {:?}", @@ -626,7 +626,7 @@ fn client_handshake_rejects_incompatible_version() { let (version, error) = client_handshake(&mut stream, 0, 80, 24) .expect("should read Welcome response even on rejection"); - assert_eq!(version, 13, "server should report its version 13"); + assert_eq!(version, 14, "server should report its version 14"); assert!( error.is_some(), "version 0 should be rejected with an error" @@ -651,10 +651,10 @@ fn client_handshake_clamps_small_terminal_size() { // Send Hello with 0x0 terminal size — should be clamped. let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket"); - let (version, error) = client_handshake(&mut stream, 13, 0, 0) + let (version, error) = client_handshake(&mut stream, 14, 0, 0) .expect("handshake with 0x0 should succeed (server clamps)"); - assert_eq!(version, 13); + assert_eq!(version, 14); assert!( error.is_none(), "0x0 size should be accepted (clamped): {:?}", @@ -714,9 +714,9 @@ fn no_hello_client_closed_within_five_seconds() { // Verify the server is still healthy — a proper client can still connect. let mut good_stream = UnixStream::connect(&client_socket).expect("should connect after no-hello client"); - let (version, error) = client_handshake(&mut good_stream, 13, 80, 24) + let (version, error) = client_handshake(&mut good_stream, 14, 80, 24) .expect("proper handshake should still work after no-hello client"); - assert_eq!(version, 13); + assert_eq!(version, 14); assert!(error.is_none()); // API should still work. |
