From 08eccb89d2c0717544902b6629b91b930f848461 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 10 Apr 2026 15:00:32 +0300 Subject: [PATCH] fix: preserve workspace when closing the last pane in a tab --- src/app/mod.rs | 75 +++++++++++++++++++++++++++++++------- tests/cli_wrapper.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 12 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index cefd0252..985d2322 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1876,18 +1876,20 @@ impl App { .unwrap(); }; let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return serde_json::to_string(&ErrorResponse { - id: request.id, - error: ErrorBody { - code: "pane_not_found".into(), - message: format!("pane {} not found", target.pane_id), - }, - }) - .unwrap(); + let should_close_workspace = { + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "pane_not_found".into(), + message: format!("pane {} not found", target.pane_id), + }, + }) + .unwrap(); + }; + ws.close_pane(pane_id) }; - let pane_count = ws.layout.pane_count(); - if pane_count <= 1 { + if should_close_workspace { self.state.selected = ws_idx; self.state.close_selected_workspace(); self.emit_event(crate::api::schema::EventEnvelope { @@ -1902,7 +1904,6 @@ impl App { data: crate::api::schema::EventData::WorkspaceClosed { workspace_id }, }); } else { - ws.close_pane(pane_id); self.schedule_session_save(); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneClosed, @@ -2794,6 +2795,56 @@ mod tests { assert_eq!(seed_cwd, std::path::PathBuf::from("/tmp/pion")); } + #[test] + fn pane_close_request_closes_only_the_target_tab_when_other_tabs_exist() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("api-pane-close"); + let second_tab = workspace.test_add_tab(Some("logs")); + workspace.switch_tab(second_tab); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + + let target_pane = app.state.workspaces[0].tabs[second_tab].root_pane; + let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id; + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req_pane_close".into(), + method: crate::api::schema::Method::PaneClose(crate::api::schema::PaneTarget { + pane_id: target_pane_id, + }), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "ok"); + assert_eq!(app.state.workspaces.len(), 1); + assert_eq!(app.state.workspaces[0].tabs.len(), 1); + assert_eq!(app.state.workspaces[0].display_name(), "api-pane-close"); + } + + #[test] + fn pane_close_request_closes_workspace_when_it_removes_the_last_pane() { + let mut app = test_app(); + let workspace = Workspace::test_new("api-pane-close-last"); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + + let target_pane = app.state.workspaces[0].tabs[0].root_pane; + let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id; + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req_pane_close_last".into(), + method: crate::api::schema::Method::PaneClose(crate::api::schema::PaneTarget { + pane_id: target_pane_id, + }), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "ok"); + assert!(app.state.workspaces.is_empty()); + } + #[test] fn session_dirty_flag_schedules_debounced_save() { let mut app = test_app(); diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 36e015e6..9da11061 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -356,6 +356,91 @@ fn tab_management_commands_work() { cleanup_spawned_herdr(herdr, base); } +#[test] +fn pane_close_only_removes_the_target_tab_when_other_tabs_exist() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + let workspace_id = created_json["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + + let created_tab = run_cli( + &socket_path, + &["tab", "create", "--workspace", &workspace_id], + ); + assert!(created_tab.status.success()); + let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap(); + let second_root_pane_id = created_tab_json["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + + let closed = run_cli(&socket_path, &["pane", "close", &second_root_pane_id]); + assert!(closed.status.success()); + let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); + assert_eq!(closed_json["result"]["type"], "ok"); + + let workspaces = run_cli(&socket_path, &["workspace", "list"]); + assert!(workspaces.status.success()); + let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap(); + assert_eq!(workspaces_json["result"]["workspaces"].as_array().unwrap().len(), 1); + assert_eq!(workspaces_json["result"]["workspaces"][0]["workspace_id"], workspace_id); + + let tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]); + assert!(tabs.status.success()); + let tabs_json: serde_json::Value = serde_json::from_slice(&tabs.stdout).unwrap(); + assert_eq!(tabs_json["result"]["tabs"].as_array().unwrap().len(), 1); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn pane_close_removes_the_workspace_when_it_closes_the_last_pane() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + let root_pane_id = created_json["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + + let closed = run_cli(&socket_path, &["pane", "close", &root_pane_id]); + assert!(closed.status.success()); + let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); + assert_eq!(closed_json["result"]["type"], "ok"); + + let workspaces = run_cli(&socket_path, &["workspace", "list"]); + assert!(workspaces.status.success()); + let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap(); + assert!(workspaces_json["result"]["workspaces"].as_array().unwrap().is_empty()); + + cleanup_spawned_herdr(herdr, base); +} + #[test] fn pane_run_read_and_wait_commands_work() { let base = unique_test_dir();