diff options
| -rw-r--r-- | SOCKET_API.md | 18 | ||||
| -rw-r--r-- | src/app/api.rs | 25 | ||||
| -rw-r--r-- | src/app/mod.rs | 121 | ||||
| -rw-r--r-- | src/cli.rs | 28 | ||||
| -rw-r--r-- | src/workspace.rs | 36 |
5 files changed, 206 insertions, 22 deletions
diff --git a/SOCKET_API.md b/SOCKET_API.md index 3baf31e3..dbb2c379 100644 --- a/SOCKET_API.md +++ b/SOCKET_API.md @@ -273,7 +273,7 @@ notes: - `cwd` is optional - if `cwd` is omitted, herdr uses its current working directory and falls back to `/` if needed - `focus` is optional in raw socket requests and defaults to `false` -- the cli wrapper is more ergonomic here: `herdr workspace create` focuses by default unless you pass `--no-focus` +- the cli wrapper also defaults to no focus; pass `--focus` to switch to the new workspace example response: @@ -399,7 +399,7 @@ notes: - `workspace_id` is optional and defaults to the active workspace - `cwd` is optional; if omitted, herdr uses the focused pane cwd in that workspace when available - `focus` is optional in raw socket requests and defaults to `false` -- the cli wrapper focuses by default unless you pass `--no-focus` +- the cli wrapper also defaults to no focus; pass `--focus` to switch to the new tab returns `tab_info` for the new tab. @@ -542,7 +542,7 @@ notes: - `direction` must be `right` or `down` - `cwd` is optional - `focus` is optional in raw socket requests and defaults to `false` -- the cli wrapper is more ergonomic here too: `herdr pane split ...` focuses by default unless you pass `--no-focus` +- the cli wrapper also defaults to no focus; pass `--focus` to switch to the new pane returns `pane_info` for the new pane. @@ -918,7 +918,7 @@ workspace commands: ```text herdr workspace list -herdr workspace create [--cwd PATH] [--label TEXT] [--no-focus] +herdr workspace create [--cwd PATH] [--label TEXT] [--focus] [--no-focus] herdr workspace get <workspace_id> herdr workspace focus <workspace_id> herdr workspace rename <workspace_id> <label> @@ -929,7 +929,7 @@ tab commands: ```text herdr tab list [--workspace <workspace_id>] -herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--no-focus] +herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--focus] [--no-focus] herdr tab get <tab_id> herdr tab focus <tab_id> herdr tab rename <tab_id> <label> @@ -942,7 +942,7 @@ pane commands: herdr pane list [--workspace <workspace_id>] herdr pane get <pane_id> herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--raw] -herdr pane split <pane_id> --direction right|down [--cwd PATH] [--no-focus] +herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus] herdr pane close <pane_id> herdr pane send-text <pane_id> <text> herdr pane send-keys <pane_id> <key> [key ...] @@ -961,15 +961,15 @@ herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [ - `status` prints local client version/protocol, running server version/protocol when reachable, socket path, compatibility, and whether a restart is needed - `status server` prints only the running server side; if no server is reachable it exits successfully and prints `status: not running` - `status client` prints only the local executable version/protocol and binary path without contacting the server -- `workspace create` focuses by default; pass `--no-focus` to keep focus where it is +- `workspace create` keeps focus where it is by default; pass `--focus` to switch to the new workspace - `workspace create` without `--label` keeps the default cwd-based workspace naming - `workspace create --label` applies the custom workspace name immediately - `workspace create` returns `result.workspace`, `result.tab`, and `result.root_pane` -- `tab create` focuses by default; pass `--no-focus` to keep focus where it is +- `tab create` keeps focus where it is by default; pass `--focus` to switch to the new tab - `tab create` without `--label` keeps the default numbered tab naming - `tab create --label` applies the custom tab name immediately - `tab create` returns `result.tab` and `result.root_pane` -- `pane split` focuses the new pane by default; pass `--no-focus` to keep focus on the original pane +- `pane split` keeps focus where it is by default; pass `--focus` to switch to the new pane - `pane read` prints **text**, not json - `pane read --source recent-unwrapped` returns recent terminal text with soft wraps joined back together - `pane send-text`, `pane send-keys`, and `pane run` print nothing on success diff --git a/src/app/api.rs b/src/app/api.rs index e689d89f..ab8058cf 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -819,7 +819,6 @@ impl App { }) .unwrap(); }; - ws.layout.focus_pane(target_pane_id); let direction = match params.direction { crate::api::schema::SplitDirection::Right => { ratatui::layout::Direction::Horizontal @@ -828,16 +827,18 @@ impl App { ratatui::layout::Direction::Vertical } }; - let new_pane_id = match ws.split_focused( + let (target_tab_idx, new_pane_id) = match ws.split_pane( + target_pane_id, direction, rows, cols, params.cwd.map(std::path::PathBuf::from), self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, + params.focus, ) { - Ok(new_pane_id) => new_pane_id, - Err(err) => { + Some(Ok(result)) => result, + Some(Err(err)) => { return serde_json::to_string(&ErrorResponse { id: request.id, error: ErrorBody { @@ -847,9 +848,21 @@ impl App { }) .unwrap(); } + None => { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "pane_not_found".into(), + message: format!("pane {} not found", params.target_pane_id), + }, + }) + .unwrap(); + } }; - if !params.focus { - ws.layout.focus_pane(target_pane_id); + if params.focus { + self.state.switch_workspace(ws_idx); + self.state.switch_tab(target_tab_idx); + self.state.mode = Mode::Terminal; } self.schedule_session_save(); let pane = self.pane_info(ws_idx, new_pane_id).unwrap(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 32e89f0e..1d09230a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1436,6 +1436,127 @@ mod tests { assert!(app.state.should_quit); } + #[tokio::test] + async fn pane_split_request_targets_pane_in_background_tab() { + let _guard = config_env_lock().lock().unwrap(); + let original_shell = std::env::var_os("SHELL"); + std::env::set_var("SHELL", "/usr/bin/true"); + + let mut app = test_app(); + let mut workspace = Workspace::test_new("api-pane-split-background-tab"); + let active_pane = workspace.tabs[0].root_pane; + let background_tab = workspace.test_add_tab(Some("worker")); + let target_pane = workspace.tabs[background_tab].root_pane; + workspace.switch_tab(background_tab); + let background_previous_focus = + workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.switch_tab(0); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + + let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id; + let target_tab_id = app.public_tab_id(0, background_tab).unwrap(); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req_pane_split_background_tab".into(), + method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams { + workspace_id: None, + target_pane_id, + direction: crate::api::schema::SplitDirection::Right, + cwd: None, + focus: false, + }), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "pane_info"); + assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id); + assert_eq!(response["result"]["pane"]["focused"], false); + assert_eq!(app.state.active, Some(0)); + assert_eq!(app.state.workspaces[0].active_tab, 0); + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + active_pane + ); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + assert_eq!( + app.state.workspaces[0].tabs[background_tab] + .layout + .focused(), + background_previous_focus + ); + assert_eq!( + app.state.workspaces[0].tabs[background_tab] + .layout + .pane_count(), + 3 + ); + + let runtimes: Vec<_> = app.state.workspaces[0] + .tabs + .iter_mut() + .flat_map(|tab| tab.runtimes.drain()) + .collect(); + for (pane_id, runtime) in runtimes { + runtime.shutdown(pane_id); + } + match original_shell { + Some(value) => std::env::set_var("SHELL", value), + None => std::env::remove_var("SHELL"), + } + } + + #[tokio::test] + async fn pane_split_request_focuses_new_pane_when_requested() { + let _guard = config_env_lock().lock().unwrap(); + let original_shell = std::env::var_os("SHELL"); + std::env::set_var("SHELL", "/usr/bin/true"); + + let mut app = test_app(); + let mut workspace = Workspace::test_new("api-pane-split-focus-background-tab"); + let background_tab = workspace.test_add_tab(Some("worker")); + workspace.switch_tab(0); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + + let target_pane = app.state.workspaces[0].tabs[background_tab].root_pane; + let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id; + let target_tab_id = app.public_tab_id(0, background_tab).unwrap(); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req_pane_split_focus_background_tab".into(), + method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams { + workspace_id: None, + target_pane_id, + direction: crate::api::schema::SplitDirection::Right, + cwd: None, + focus: true, + }), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "pane_info"); + assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id); + assert_eq!(response["result"]["pane"]["focused"], true); + assert_eq!(app.state.active, Some(0)); + assert_eq!(app.state.workspaces[0].active_tab, background_tab); + + let runtimes: Vec<_> = app.state.workspaces[0] + .tabs + .iter_mut() + .flat_map(|tab| tab.runtimes.drain()) + .collect(); + for (pane_id, runtime) in runtimes { + runtime.shutdown(pane_id); + } + match original_shell { + Some(value) => std::env::set_var("SHELL", value), + None => std::env::remove_var("SHELL"), + } + } + #[test] fn pane_close_request_closes_only_the_target_tab_when_other_tabs_exist() { let mut app = test_app(); @@ -475,7 +475,7 @@ fn workspace_list(args: &[String]) -> std::io::Result<i32> { fn workspace_create(args: &[String]) -> std::io::Result<i32> { let mut cwd = None; - let mut focus = true; + let mut focus = false; let mut label = None; let mut index = 0; @@ -497,6 +497,10 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> { label = Some(value.clone()); index += 2; } + "--focus" => { + focus = true; + index += 1; + } "--no-focus" => { focus = false; index += 1; @@ -613,7 +617,7 @@ fn tab_list(args: &[String]) -> std::io::Result<i32> { fn tab_create(args: &[String]) -> std::io::Result<i32> { let mut workspace_id = None; let mut cwd = None; - let mut focus = true; + let mut focus = false; let mut label = None; let mut index = 0; @@ -643,6 +647,10 @@ fn tab_create(args: &[String]) -> std::io::Result<i32> { label = Some(value.clone()); index += 2; } + "--focus" => { + focus = true; + index += 1; + } "--no-focus" => { focus = false; index += 1; @@ -844,7 +852,7 @@ 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] [--no-focus]" + "usage: herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]" ); return Ok(2); }; @@ -852,7 +860,7 @@ fn pane_split(args: &[String]) -> std::io::Result<i32> { let pane_id = normalize_pane_id(raw_pane_id); let mut direction = None; let mut cwd = None; - let mut focus = true; + let mut focus = false; let mut index = 1; while index < args.len() { @@ -873,6 +881,10 @@ fn pane_split(args: &[String]) -> std::io::Result<i32> { cwd = Some(value.clone()); index += 2; } + "--focus" => { + focus = true; + index += 1; + } "--no-focus" => { focus = false; index += 1; @@ -1403,7 +1415,7 @@ fn print_status_help() { fn print_workspace_help() { eprintln!("herdr workspace commands:"); eprintln!(" herdr workspace list"); - eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--no-focus]"); + eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--focus] [--no-focus]"); eprintln!(" herdr workspace get <workspace_id>"); eprintln!(" herdr workspace focus <workspace_id>"); eprintln!(" herdr workspace rename <workspace_id> <label>"); @@ -1414,7 +1426,7 @@ fn print_tab_help() { eprintln!("herdr tab commands:"); eprintln!(" herdr tab list [--workspace <workspace_id>]"); eprintln!( - " herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--no-focus]" + " herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--focus] [--no-focus]" ); eprintln!(" herdr tab get <tab_id>"); eprintln!(" herdr tab focus <tab_id>"); @@ -1427,7 +1439,9 @@ fn print_pane_help() { eprintln!(" herdr pane list [--workspace <workspace_id>]"); eprintln!(" herdr pane get <pane_id>"); eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--raw]"); - eprintln!(" herdr pane split <pane_id> --direction right|down [--cwd PATH] [--no-focus]"); + eprintln!( + " herdr pane split <pane_id> --direction right|down [--cwd PATH] [--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 ...]"); diff --git a/src/workspace.rs b/src/workspace.rs index e429ec10..f9fb0493 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -244,6 +244,42 @@ impl Workspace { Ok(new_id) } + pub fn split_pane( + &mut self, + pane_id: PaneId, + direction: Direction, + rows: u16, + cols: u16, + cwd: Option<PathBuf>, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + focus_new_pane: bool, + ) -> Option<std::io::Result<(usize, PaneId)>> { + let tab_idx = self.find_tab_index_for_pane(pane_id)?; + let tab = &mut self.tabs[tab_idx]; + let previous_focus = tab.layout.focused(); + tab.layout.focus_pane(pane_id); + let new_id = match tab.split_focused( + direction, + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + ) { + Ok(new_id) => new_id, + Err(err) => { + tab.layout.focus_pane(previous_focus); + return Some(Err(err)); + } + }; + if !focus_new_pane { + tab.layout.focus_pane(previous_focus); + } + self.register_new_pane(new_id); + Some(Ok((tab_idx, new_id))) + } + /// Close the focused pane. Returns true if the workspace should close. pub fn close_focused(&mut self) -> bool { let pane_count = self |
