parent
469cf9cede
commit
d35c642ef3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
@ -274,6 +307,36 @@ pub enum PaneSwapReason {
|
|||
CrossTab,
|
||||
}
|
||||
|
||||
#[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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
1084
src/app/api/panes.rs
1084
src/app/api/panes.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
234
src/cli/pane.rs
234
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 ...]");
|
||||
|
|
@ -1279,6 +1458,53 @@ mod tests {
|
|||
assert!(err.contains("usage: herdr pane swap"));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -708,6 +732,22 @@ mod tests {
|
|||
assert_eq!(layout.focused(), before_focus);
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
146
src/workspace.rs
146
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 {
|
||||
|
|
@ -1037,6 +1165,24 @@ mod tests {
|
|||
assert_eq!(ws.public_tab_number_for_pane(fourth_root), Some(4));
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue