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
This commit is contained in:
Season Saw 2026-07-27 07:25:29 +08:00 committed by GitHub
parent 7e96b1b7e8
commit a79b3d5580
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 86 additions and 13 deletions

View File

@ -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.

View File

@ -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,
@ -305,6 +331,53 @@ mod tests {
workspace::Workspace,
};
#[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();