diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-05 21:59:10 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-05 22:06:02 +0300 |
| commit | 556b16b2df7ea2c3e9b8ad13db4ad7370c1ad838 (patch) | |
| tree | 0a6f61701f2c8992c6cd1ee6d61171ef92a92533 /src/cli/pane.rs | |
| parent | ae3c88ab8c64931740d89623f688f7d3f408da5e (diff) | |
feat: add pane navigation controls
refs #421
refs #330
related discussions:
https://github.com/ogulcancelik/herdr/discussions/422
https://github.com/ogulcancelik/herdr/discussions/438
https://github.com/ogulcancelik/herdr/discussions/442
Diffstat (limited to 'src/cli/pane.rs')
| -rw-r--r-- | src/cli/pane.rs | 530 |
1 files changed, 501 insertions, 29 deletions
diff --git a/src/cli/pane.rs b/src/cli/pane.rs index d759fa0d..1a9b20e1 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -1,7 +1,9 @@ use crate::api::schema::{ - Method, PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, - PaneReportMetadataParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, - PaneSplitParams, PaneTarget, ReadFormat, ReadSource, Request, + Method, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, PaneLayoutParams, + PaneListParams, PaneNeighborParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, + PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, + PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, ReadFormat, ReadSource, + Request, }; pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { @@ -13,9 +15,15 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { match subcommand { "list" => pane_list(&args[1..]), "get" => pane_get(&args[1..]), + "layout" => pane_layout(&args[1..]), + "neighbor" => pane_neighbor(&args[1..]), + "edges" => pane_edges(&args[1..]), + "focus" => pane_focus(&args[1..]), + "resize" => pane_resize(&args[1..]), "read" => pane_read(&args[1..]), "rename" => pane_rename(&args[1..]), "split" => pane_split(&args[1..]), + "swap" => pane_swap(&args[1..]), "close" => pane_close(&args[1..]), "send-text" => pane_send_text(&args[1..]), "send-keys" => pane_send_keys(&args[1..]), @@ -78,6 +86,209 @@ fn pane_get(args: &[String]) -> std::io::Result<i32> { })?) } +fn pane_layout(args: &[String]) -> std::io::Result<i32> { + let pane_id = match parse_optional_current_pane_args(args) { + Ok(pane_id) => pane_id, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + + super::print_response(&super::send_request(&Request { + id: "cli:pane:layout".into(), + method: Method::PaneLayout(PaneLayoutParams { pane_id }), + })?) +} + +fn pane_edges(args: &[String]) -> std::io::Result<i32> { + let pane_id = match parse_optional_current_pane_args(args) { + Ok(pane_id) => pane_id, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + + super::print_response(&super::send_request(&Request { + id: "cli:pane:edges".into(), + method: Method::PaneEdges(PaneEdgesParams { pane_id }), + })?) +} + +fn pane_neighbor(args: &[String]) -> std::io::Result<i32> { + let params = match parse_pane_neighbor_args(args) { + Ok(params) => params, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + + super::print_response(&super::send_request(&Request { + id: "cli:pane:neighbor".into(), + method: Method::PaneNeighbor(params), + })?) +} + +fn pane_focus(args: &[String]) -> std::io::Result<i32> { + let params = match parse_pane_focus_args(args) { + Ok(params) => params, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + + super::print_response(&super::send_request(&Request { + id: "cli:pane:focus".into(), + method: Method::PaneFocusDirection(params), + })?) +} + +fn pane_resize(args: &[String]) -> std::io::Result<i32> { + let params = match parse_pane_resize_args(args) { + Ok(params) => params, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + + super::print_response(&super::send_request(&Request { + id: "cli:pane:resize".into(), + method: Method::PaneResize(params), + })?) +} + +fn parse_optional_current_pane_args(args: &[String]) -> Result<Option<String>, String> { + let mut pane_id = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--pane" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --pane".into()); + }; + pane_id = Some(super::normalize_pane_id(value)); + index += 2; + } + "--current" => { + pane_id = None; + index += 1; + } + other => return Err(format!("unknown option: {other}")), + } + } + Ok(pane_id) +} + +fn parse_pane_neighbor_args(args: &[String]) -> Result<PaneNeighborParams, String> { + let mut pane_id = None; + let mut direction = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--pane" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --pane".into()); + }; + pane_id = Some(super::normalize_pane_id(value)); + index += 2; + } + "--current" => { + pane_id = None; + index += 1; + } + "--direction" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --direction".into()); + }; + direction = Some(parse_pane_direction(value)?); + index += 2; + } + other => return Err(format!("unknown option: {other}")), + } + } + + let Some(direction) = direction else { + return Err( + "usage: herdr pane neighbor --direction left|right|up|down [--pane ID|--current]" + .into(), + ); + }; + + Ok(PaneNeighborParams { pane_id, direction }) +} + +fn parse_pane_focus_args(args: &[String]) -> Result<PaneFocusDirectionParams, String> { + let params = parse_pane_neighbor_args(args).map_err(|_| { + "usage: herdr pane focus --direction left|right|up|down [--pane ID|--current]".to_string() + })?; + Ok(PaneFocusDirectionParams { + pane_id: params.pane_id, + direction: params.direction, + }) +} + +fn parse_pane_resize_args(args: &[String]) -> Result<PaneResizeParams, String> { + let mut pane_id = None; + let mut direction = None; + let mut amount = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--pane" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --pane".into()); + }; + pane_id = Some(super::normalize_pane_id(value)); + index += 2; + } + "--current" => { + pane_id = None; + index += 1; + } + "--direction" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --direction".into()); + }; + direction = Some(parse_pane_direction(value)?); + index += 2; + } + "--amount" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --amount".into()); + }; + let parsed = value + .parse::<f32>() + .map_err(|_| format!("invalid amount: {value}"))?; + if !parsed.is_finite() { + return Err(format!("invalid amount: {value}")); + } + amount = Some(parsed); + index += 2; + } + other => return Err(format!("unknown option: {other}")), + } + } + + let Some(direction) = direction else { + return Err( + "usage: herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current]" + .into(), + ); + }; + + Ok(PaneResizeParams { + pane_id, + direction, + amount, + }) +} + fn pane_rename(args: &[String]) -> std::io::Result<i32> { let Some(raw_pane_id) = args.first() else { eprintln!("usage: herdr pane rename <pane_id> <label>|--clear"); @@ -180,33 +391,72 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> { } fn pane_split(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!( - "usage: herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]" - ); - return Ok(2); + let params = match parse_pane_split_args(args) { + Ok(params) => params, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } }; - let pane_id = super::normalize_pane_id(raw_pane_id); + super::print_response(&super::send_request(&Request { + id: "cli:pane:split".into(), + method: Method::PaneSplit(params), + })?) +} + +fn parse_pane_split_args(args: &[String]) -> Result<PaneSplitParams, String> { + let mut pane_id = None; let mut direction = None; + let mut ratio = None; let mut cwd = None; let mut focus = false; - let mut index = 1; + let mut index = 0; + if args + .first() + .is_some_and(|arg| !arg.as_str().starts_with("--")) + { + pane_id = args.first().map(|arg| super::normalize_pane_id(arg)); + index = 1; + } while index < args.len() { match args[index].as_str() { + "--pane" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --pane".into()); + }; + pane_id = Some(super::normalize_pane_id(value)); + index += 2; + } + "--current" => { + pane_id = None; + index += 1; + } "--direction" => { let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --direction"); - return Ok(2); + return Err("missing value for --direction".into()); + }; + direction = + Some(super::parse_split_direction(value).map_err(|err| err.to_string())?); + index += 2; + } + "--ratio" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --ratio".into()); }; - direction = Some(super::parse_split_direction(value)?); + 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; } "--cwd" => { let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --cwd"); - return Ok(2); + return Err("missing value for --cwd".into()); }; cwd = Some(value.clone()); index += 2; @@ -219,28 +469,119 @@ fn pane_split(args: &[String]) -> std::io::Result<i32> { focus = false; index += 1; } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } + other => return Err(format!("unknown option: {other}")), } } let Some(direction) = direction else { - eprintln!("missing required --direction"); - return Ok(2); + return Err( + "usage: herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus]" + .into(), + ); + }; + + Ok(PaneSplitParams { + workspace_id: None, + target_pane_id: pane_id, + direction, + ratio, + cwd, + focus, + }) +} + +fn pane_swap(args: &[String]) -> std::io::Result<i32> { + let params = match parse_pane_swap_args(args) { + Ok(params) => params, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } }; super::print_response(&super::send_request(&Request { - id: "cli:pane:split".into(), - method: Method::PaneSplit(PaneSplitParams { - workspace_id: None, - target_pane_id: pane_id, + id: "cli:pane:swap".into(), + method: Method::PaneSwap(params), + })?) +} + +fn parse_pane_swap_args(args: &[String]) -> Result<PaneSwapParams, String> { + let mut pane_id = None; + let mut direction = None; + let mut source_pane_id = None; + let mut target_pane_id = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--pane" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --pane".into()); + }; + pane_id = Some(super::normalize_pane_id(value)); + index += 2; + } + "--current" => { + pane_id = None; + index += 1; + } + "--direction" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --direction".into()); + }; + direction = Some(parse_pane_direction(value)?); + index += 2; + } + "--source-pane" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --source-pane".into()); + }; + source_pane_id = Some(super::normalize_pane_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; + } + other => return Err(format!("unknown option: {other}")), + } + } + + let directional = direction.is_some(); + let explicit = source_pane_id.is_some() || target_pane_id.is_some(); + match (directional, explicit) { + (true, false) => Ok(PaneSwapParams { + pane_id, direction, - cwd, - focus, + ..PaneSwapParams::default() }), - })?) + (false, true) if pane_id.is_none() && source_pane_id.is_some() && target_pane_id.is_some() => { + Ok(PaneSwapParams { + source_pane_id, + target_pane_id, + ..PaneSwapParams::default() + }) + } + _ => Err( + "usage: herdr pane swap --direction left|right|up|down [--pane ID|--current]\n herdr pane swap --source-pane ID --target-pane ID" + .into(), + ), + } +} + +fn parse_pane_direction(value: &str) -> Result<PaneDirection, String> { + match value { + "left" => Ok(PaneDirection::Left), + "right" => Ok(PaneDirection::Right), + "up" => Ok(PaneDirection::Up), + "down" => Ok(PaneDirection::Down), + _ => Err(format!( + "invalid pane direction: {value} (expected left, right, up, or down)" + )), + } } fn pane_close(args: &[String]) -> std::io::Result<i32> { @@ -605,11 +946,20 @@ fn print_pane_help() { eprintln!("herdr pane commands:"); eprintln!(" herdr pane list [--workspace <workspace_id>]"); eprintln!(" herdr pane get <pane_id>"); + eprintln!(" herdr pane layout [--pane ID|--current]"); + eprintln!(" herdr pane neighbor --direction left|right|up|down [--pane ID|--current]"); + eprintln!(" herdr pane edges [--pane ID|--current]"); + eprintln!(" herdr pane focus --direction left|right|up|down [--pane ID|--current]"); + eprintln!( + " herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current]" + ); eprintln!(" herdr pane rename <pane_id> <label>|--clear"); eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); eprintln!( - " herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]" + " herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus]" ); + 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 close <pane_id>"); eprintln!(" herdr pane send-text <pane_id> <text>"); eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); @@ -617,3 +967,125 @@ fn print_pane_help() { eprintln!(" herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--custom-status TEXT|--clear-custom-status] [--state-label STATUS=TEXT] [--clear-state-labels] [--seq N] [--ttl-ms N]"); eprintln!(" herdr pane run <pane_id> <command>"); } + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec<String> { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn parse_pane_split_args_accepts_ratio() { + let params = parse_pane_split_args(&args(&[ + "issue-1", + "--direction", + "right", + "--ratio", + "0.333", + ])) + .unwrap(); + + assert_eq!(params.target_pane_id, Some("issue-1".into())); + assert_eq!(params.direction, crate::api::schema::SplitDirection::Right); + assert_eq!(params.ratio, Some(0.333)); + } + + #[test] + fn parse_pane_split_args_accepts_current_target() { + let params = parse_pane_split_args(&args(&["--direction", "down", "--current"])).unwrap(); + + assert_eq!(params.target_pane_id, None); + assert_eq!(params.direction, crate::api::schema::SplitDirection::Down); + } + + #[test] + fn parse_pane_split_args_accepts_pane_option() { + let params = + parse_pane_split_args(&args(&["--pane", "issue-2", "--direction", "right"])).unwrap(); + + assert_eq!(params.target_pane_id, Some("issue-2".into())); + assert_eq!(params.direction, crate::api::schema::SplitDirection::Right); + } + + #[test] + fn parse_pane_swap_args_accepts_directional_current() { + let params = parse_pane_swap_args(&args(&["--direction", "right"])).unwrap(); + + assert_eq!(params.pane_id, None); + assert_eq!(params.direction, Some(PaneDirection::Right)); + assert_eq!(params.source_pane_id, None); + assert_eq!(params.target_pane_id, None); + } + + #[test] + fn parse_pane_swap_args_accepts_explicit_source_and_target() { + let params = parse_pane_swap_args(&args(&[ + "--source-pane", + "issue-1", + "--target-pane", + "issue-2", + ])) + .unwrap(); + + assert_eq!(params.direction, None); + assert_eq!(params.source_pane_id, Some("issue-1".into())); + assert_eq!(params.target_pane_id, Some("issue-2".into())); + } + + #[test] + fn parse_pane_swap_args_rejects_mixed_forms() { + let err = parse_pane_swap_args(&args(&[ + "--direction", + "left", + "--source-pane", + "issue-1", + "--target-pane", + "issue-2", + ])) + .unwrap_err(); + + assert!(err.contains("usage: herdr pane swap")); + } + + #[test] + fn parse_pane_neighbor_args_accepts_directional_current() { + let params = parse_pane_neighbor_args(&args(&["--direction", "down"])).unwrap(); + + assert_eq!(params.pane_id, None); + assert_eq!(params.direction, PaneDirection::Down); + } + + #[test] + fn parse_optional_current_pane_args_accepts_explicit_pane() { + let pane_id = parse_optional_current_pane_args(&args(&["--pane", "issue-2"])).unwrap(); + + assert_eq!(pane_id, Some("issue-2".into())); + } + + #[test] + fn parse_pane_focus_args_accepts_directional_current() { + let params = parse_pane_focus_args(&args(&["--direction", "up"])).unwrap(); + + assert_eq!(params.pane_id, None); + assert_eq!(params.direction, PaneDirection::Up); + } + + #[test] + fn parse_pane_resize_args_accepts_amount_and_pane() { + let params = parse_pane_resize_args(&args(&[ + "--pane", + "issue-2", + "--direction", + "left", + "--amount", + "0.125", + ])) + .unwrap(); + + assert_eq!(params.pane_id, Some("issue-2".into())); + assert_eq!(params.direction, PaneDirection::Left); + assert_eq!(params.amount, Some(0.125)); + } +} |
