diff options
| author | Season Saw <season.saw@gmail.com> | 2026-07-27 07:25:29 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-27 02:25:29 +0300 |
| commit | a79b3d558026d0703f848882d652bb5db65df26e (patch) | |
| tree | 8287cd9eb2d9884b544899c28e0e415c8f9000d0 | |
| parent | 7e96b1b7e8d3dcc1c12ae538598e5a955ac58c87 (diff) | |
fix: close workspace when api closes its last tab (#1899)
herdr tab close on a workspace's only tab returned tab_close_failed,
while the TUI close-tab action on the same tab closes the tab and
cascades to closing the workspace. Cascade in the API too, emitting
tab.closed then workspace.closed. When the cascade would close a whole
worktree group, return confirmation_required instead, matching
pane.close and the TUI prompt.
refs #1760
| -rw-r--r-- | docs/next/website/src/content/docs/cli-reference.mdx | 2 | ||||
| -rw-r--r-- | src/app/api/tabs.rs | 97 |
2 files changed, 86 insertions, 13 deletions
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 19a7ec23..a49a4212 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -156,7 +156,7 @@ herdr tab rename <tab_id> <label> herdr tab close <tab_id> ``` -A tab is another terminal layout inside a workspace. Without `--workspace`, `tab create` uses the active workspace and fails if none exists. Its JSON response exposes `.result.tab.tab_id` and `.result.root_pane.pane_id`. +A tab is another terminal layout inside a workspace. Without `--workspace`, `tab create` uses the active workspace and fails if none exists. Its JSON response exposes `.result.tab.tab_id` and `.result.root_pane.pane_id`. Closing a workspace's last tab also closes the workspace, matching the TUI close-tab action; when `confirm_close` is enabled and that would also close a whole worktree group, `tab close` returns a `confirmation_required` error instead. Workspace and tab creation, and pane splitting, leave focus unchanged by default. `--focus` selects the new layout; `--no-focus` states the default explicitly. Without `--cwd`, new terminals follow the configured `terminal.new_cwd` policy, which follows the source pane or workspace by default. Each `--env KEY=VALUE` adds or replaces that variable in the new root shell. diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 6ae9d26e..2a2f3cdb 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -230,24 +230,50 @@ impl App { return tab_not_found(id, &target.tab_id); }; let workspace_id = self.public_workspace_id(ws_idx); + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return tab_not_found(id, &target.tab_id); + }; + let closes_workspace = ws.tabs.len() <= 1; let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); - let pane_ids = self - .state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.tabs.get(tab_idx)) + let pane_ids = ws + .tabs + .get(tab_idx) .map(|tab| tab.layout.pane_ids()) .unwrap_or_default(); + + if closes_workspace { + if self.state.confirm_implicit_worktree_group_close(ws_idx) { + return encode_error( + id, + "confirmation_required", + "closing this tab would close a worktree group", + ); + } + let workspace = self.workspace_info(ws_idx); + self.state.selected = ws_idx; + self.state.close_selected_workspace(); + self.state.remove_plugin_pane_records(pane_ids); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(EventEnvelope { + event: EventKind::TabClosed, + data: EventData::TabClosed { + tab_id, + workspace_id: workspace_id.clone(), + }, + }); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { + workspace_id, + workspace: Some(workspace), + }, + }); + return encode_success(id, ResponseResult::Ok {}); + } + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return tab_not_found(id, &target.tab_id); }; - if ws.tabs.len() <= 1 { - return encode_error( - id, - "tab_close_failed", - "cannot close the last tab in a workspace", - ); - } if !ws.close_tab(tab_idx) { return encode_error( id, @@ -306,6 +332,53 @@ mod tests { }; #[test] + fn api_tab_close_last_tab_closes_workspace_and_emits_both_events() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("tabs")]; + app.state.active = Some(0); + app.state.selected = 0; + let tab_id = app.public_tab_id(0, 0).unwrap(); + let workspace_id = app.public_workspace_id(0); + + let response = app.handle_tab_close( + "req".into(), + TabTarget { + tab_id: tab_id.clone(), + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + assert!(app.state.workspaces.is_empty()); + assert!(app.state.active.is_none()); + let events = event_hub.events_after(0); + assert_eq!( + events + .iter() + .map(|(_, event)| event.event) + .collect::<Vec<_>>(), + [EventKind::TabClosed, EventKind::WorkspaceClosed] + ); + assert!(matches!( + &events[0].1.data, + EventData::TabClosed { + tab_id: closed_tab_id, + workspace_id: closed_workspace_id, + } if closed_tab_id == &tab_id && closed_workspace_id == &workspace_id + )); + assert!(matches!( + &events[1].1.data, + EventData::WorkspaceClosed { + workspace_id: closed_workspace_id, + workspace: Some(workspace), + } if closed_workspace_id == &workspace_id + && workspace.workspace_id == workspace_id + )); + } + + #[test] fn api_tab_move_reorders_tabs_in_target_workspace() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); |
