aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-08 15:37:01 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-08 15:37:01 +0300
commitbb0dad86f81768c8b7d5894acf7cd53bd05d302b (patch)
treebf132301605fcfc40c992848b85da9d453647aaf /src
parentac32c1a19bff6373a3b24618f8e553834e960bdc (diff)
fix: split panes in target tab without stealing focus
fixes #75
Diffstat (limited to 'src')
-rw-r--r--src/app/api.rs25
-rw-r--r--src/app/mod.rs121
-rw-r--r--src/cli.rs28
-rw-r--r--src/workspace.rs36
4 files changed, 197 insertions, 13 deletions
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<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