From bb0dad86f81768c8b7d5894acf7cd53bd05d302b Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 8 May 2026 15:37:01 +0300 Subject: fix: split panes in target tab without stealing focus fixes #75 --- src/app/api.rs | 25 +++++++++--- src/app/mod.rs | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 28 +++++++++---- src/workspace.rs | 36 +++++++++++++++++ 4 files changed, 197 insertions(+), 13 deletions(-) (limited to 'src') 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(); diff --git a/src/cli.rs b/src/cli.rs index 1260ee6f..dcc2537c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -475,7 +475,7 @@ fn workspace_list(args: &[String]) -> std::io::Result { fn workspace_create(args: &[String]) -> std::io::Result { 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 { 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 { fn tab_create(args: &[String]) -> std::io::Result { 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 { 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 { fn pane_split(args: &[String]) -> std::io::Result { let Some(raw_pane_id) = args.first() else { eprintln!( - "usage: herdr pane split --direction right|down [--cwd PATH] [--no-focus]" + "usage: herdr pane split --direction right|down [--cwd PATH] [--focus] [--no-focus]" ); return Ok(2); }; @@ -852,7 +860,7 @@ fn pane_split(args: &[String]) -> std::io::Result { 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 { 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 "); eprintln!(" herdr workspace focus "); eprintln!(" herdr workspace rename