aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/schema.rs2
-rw-r--r--src/api/schema/tests.rs28
-rw-r--r--src/api/schema/workspaces.rs7
-rw-r--r--src/app/actions.rs67
-rw-r--r--src/app/api/workspaces.rs200
-rw-r--r--src/app/input/modal.rs65
-rw-r--r--src/app/input/mouse.rs2
-rw-r--r--src/app/input/navigate.rs25
-rw-r--r--src/app/mod.rs1
-rw-r--r--src/app/runtime_mutations.rs15
-rw-r--r--src/app/state.rs3
-rw-r--r--src/cli/runtime.rs11
-rw-r--r--src/cli/workspace.rs21
-rw-r--r--src/protocol/wire.rs2
14 files changed, 352 insertions, 97 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 6f76e3d3..e06b701c 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -80,7 +80,7 @@ pub enum Method {
#[serde(rename = "workspace.report_metadata")]
WorkspaceReportMetadata(WorkspaceReportMetadataParams),
#[serde(rename = "workspace.close")]
- WorkspaceClose(WorkspaceTarget),
+ WorkspaceClose(WorkspaceCloseParams),
#[serde(rename = "worktree.list")]
WorktreeList(WorktreeListParams),
#[serde(rename = "worktree.create")]
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index 5fe6dd27..5199038d 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -62,6 +62,34 @@ fn request_uses_dot_method_names() {
}
#[test]
+fn workspace_close_group_intent_defaults_false_and_round_trips() {
+ let request: Request = serde_json::from_value(serde_json::json!({
+ "id": "close",
+ "method": "workspace.close",
+ "params": { "workspace_id": "w1" }
+ }))
+ .unwrap();
+ assert!(matches!(
+ request.method,
+ Method::WorkspaceClose(WorkspaceCloseParams {
+ close_group: false,
+ ..
+ })
+ ));
+
+ let explicit = Request {
+ id: "close-group".into(),
+ method: Method::WorkspaceClose(WorkspaceCloseParams {
+ workspace_id: "w1".into(),
+ close_group: true,
+ }),
+ };
+ let json = serde_json::to_value(&explicit).unwrap();
+ assert_eq!(json["params"]["close_group"], true);
+ assert_eq!(serde_json::from_value::<Request>(json).unwrap(), explicit);
+}
+
+#[test]
fn agent_start_and_prompt_requests_round_trip() {
let start = Request {
id: "start".into(),
diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs
index f9ad4ced..bfc63252 100644
--- a/src/api/schema/workspaces.rs
+++ b/src/api/schema/workspaces.rs
@@ -17,6 +17,13 @@ pub struct WorkspaceCreateParams {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+pub struct WorkspaceCloseParams {
+ pub workspace_id: String,
+ #[serde(default, skip_serializing_if = "super::is_false")]
+ pub close_group: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceRenameParams {
pub workspace_id: String,
pub label: String,
diff --git a/src/app/actions.rs b/src/app/actions.rs
index e3fc2639..07f7278b 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1677,24 +1677,7 @@ impl AppState {
self.selection = None;
self.selection_autoscroll = None;
self.mark_session_dirty();
- let close_indices = self
- .workspaces
- .get(self.selected)
- .and_then(|ws| ws.worktree_space())
- .filter(|space| !space.is_linked_worktree)
- .map(|space| {
- self.workspaces
- .iter()
- .enumerate()
- .filter_map(|(idx, ws)| {
- ws.worktree_space()
- .is_some_and(|member| member.key == space.key)
- .then_some(idx)
- })
- .collect::<Vec<_>>()
- })
- .filter(|indices| indices.len() >= 2)
- .unwrap_or_else(|| vec![self.selected]);
+ let close_indices = self.workspace_close_indices(self.selected);
let mut terminal_ids = Vec::new();
let mut pane_ids = Vec::new();
@@ -1984,31 +1967,55 @@ impl AppState {
self.apply_pane_zoom(ws_idx, pane_id, PaneZoomCommand::Toggle);
}
- pub(crate) fn workspace_close_would_close_worktree_group(&self, ws_idx: usize) -> bool {
+ pub(crate) fn workspace_close_indices(&self, ws_idx: usize) -> Vec<usize> {
self.workspaces
.get(ws_idx)
.and_then(|ws| ws.worktree_space())
.filter(|space| !space.is_linked_worktree)
- .is_some_and(|space| {
+ .map(|space| {
self.workspaces
.iter()
- .filter(|ws| {
+ .enumerate()
+ .filter_map(|(idx, ws)| {
ws.worktree_space()
.is_some_and(|member| member.key == space.key)
+ .then_some(idx)
})
- .count()
- >= 2
+ .collect::<Vec<_>>()
})
+ .filter(|indices| indices.len() >= 2)
+ .unwrap_or_else(|| vec![ws_idx])
+ }
+
+ pub(crate) fn workspace_close_would_close_worktree_group(&self, ws_idx: usize) -> bool {
+ self.workspace_close_indices(ws_idx).len() >= 2
+ }
+
+ pub(crate) fn begin_workspace_close_confirmation(&mut self, ws_idx: usize) -> bool {
+ let Some(workspace_id) = self
+ .workspaces
+ .get(ws_idx)
+ .map(|workspace| workspace.id.clone())
+ else {
+ return false;
+ };
+ self.selected = ws_idx;
+ self.confirm_close_workspace_id = Some(workspace_id);
+ self.mode = Mode::ConfirmClose;
+ true
+ }
+
+ pub(crate) fn take_confirmed_workspace_close_index(&mut self) -> Option<usize> {
+ let workspace_id = self.confirm_close_workspace_id.take()?;
+ self.workspaces
+ .iter()
+ .position(|workspace| workspace.id == workspace_id)
}
pub(crate) fn confirm_implicit_worktree_group_close(&mut self, ws_idx: usize) -> bool {
- if self.confirm_close && self.workspace_close_would_close_worktree_group(ws_idx) {
- self.selected = ws_idx;
- self.mode = Mode::ConfirmClose;
- true
- } else {
- false
- }
+ self.confirm_close
+ && self.workspace_close_would_close_worktree_group(ws_idx)
+ && self.begin_workspace_close_confirmation(ws_idx)
}
#[cfg(test)]
diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs
index cf50d3ad..d9e689e3 100644
--- a/src/app/api/workspaces.rs
+++ b/src/app/api/workspaces.rs
@@ -1,8 +1,8 @@
use std::path::PathBuf;
use crate::api::schema::{
- EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams,
- WorkspaceMoveBlockParams, WorkspaceMoveParams, WorkspaceRenameParams,
+ EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCloseParams,
+ WorkspaceCreateParams, WorkspaceMoveBlockParams, WorkspaceMoveParams, WorkspaceRenameParams,
WorkspaceReportMetadataParams, WorkspaceTarget,
};
use crate::app::App;
@@ -295,37 +295,46 @@ impl App {
encode_success(id, ResponseResult::Ok {})
}
- pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String {
- let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
- return workspace_not_found(id, &target.workspace_id);
+ pub(super) fn handle_workspace_close(
+ &mut self,
+ id: String,
+ params: WorkspaceCloseParams,
+ ) -> String {
+ let Some(index) = self.parse_workspace_id(&params.workspace_id) else {
+ return workspace_not_found(id, &params.workspace_id);
};
if self.state.workspaces.get(index).is_none() {
- return workspace_not_found(id, &target.workspace_id);
+ return workspace_not_found(id, &params.workspace_id);
}
- let workspace_id = self.public_workspace_id(index);
- let workspace = self.workspace_info(index);
- let pane_ids = self
- .state
- .workspaces
- .get(index)
- .map(|ws| {
- ws.tabs
- .iter()
- .flat_map(|tab| tab.layout.pane_ids())
- .collect::<Vec<_>>()
+ let close_indices = self.state.workspace_close_indices(index);
+ if close_indices.len() >= 2 && !params.close_group {
+ return encode_error(
+ id,
+ "workspace_group_close_required",
+ "workspace has linked worktree workspaces; use --group (close_group=true in the API) to close the group",
+ );
+ }
+ let closed_workspaces = close_indices
+ .iter()
+ .map(|index| {
+ (
+ self.public_workspace_id(*index),
+ self.workspace_info(*index),
+ )
})
- .unwrap_or_default();
+ .collect::<Vec<_>>();
self.state.selected = index;
self.state.close_selected_workspace();
- self.state.remove_plugin_pane_records(pane_ids);
self.shutdown_detached_terminal_runtimes();
- self.emit_event(EventEnvelope {
- event: EventKind::WorkspaceClosed,
- data: EventData::WorkspaceClosed {
- workspace_id,
- workspace: Some(workspace),
- },
- });
+ for (workspace_id, workspace) in closed_workspaces {
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceClosed,
+ data: EventData::WorkspaceClosed {
+ workspace_id,
+ workspace: Some(workspace),
+ },
+ });
+ }
encode_success(id, ResponseResult::Ok {})
}
@@ -455,21 +464,149 @@ mod tests {
app
}
+ fn app_with_worktree_group() -> App {
+ let mut app = app_with_linked_worktree();
+ let mut parent = Workspace::test_new("parent");
+ parent.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: "/repo/herdr".into(),
+ is_linked_worktree: false,
+ });
+ app.state.workspaces.insert(0, parent);
+ app.state.active = Some(1);
+ app.state.selected = 1;
+ app.state.mode = crate::app::Mode::Terminal;
+ app
+ }
+
+ #[test]
+ fn api_workspace_close_parent_group_requires_explicit_group_intent() {
+ for confirm_close in [true, false] {
+ let mut app = app_with_worktree_group();
+ app.state.confirm_close = confirm_close;
+ let parent_id = app.public_workspace_id(0);
+ let workspace_ids = app
+ .state
+ .workspaces
+ .iter()
+ .map(|workspace| workspace.id.clone())
+ .collect::<Vec<_>>();
+
+ let request: crate::api::schema::Request = serde_json::from_value(serde_json::json!({
+ "id": "req",
+ "method": "workspace.close",
+ "params": { "workspace_id": parent_id }
+ }))
+ .unwrap();
+ let response = app.handle_api_request(request);
+
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(response["error"]["code"], "workspace_group_close_required");
+ assert!(app.event_hub.events_after(0).is_empty());
+ assert_eq!(app.state.mode, crate::app::Mode::Terminal);
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(app.state.selected, 1);
+ assert_eq!(
+ app.state
+ .workspaces
+ .iter()
+ .map(|workspace| workspace.id.clone())
+ .collect::<Vec<_>>(),
+ workspace_ids
+ );
+ }
+ }
+
+ #[test]
+ fn api_workspace_close_noncontiguous_group_preserves_adversarial_identity_state() {
+ let mut app = app_with_worktree_group();
+ let parent = app.state.workspaces.remove(0);
+ let linked = app.state.workspaces.remove(0);
+ app.state = crate::app::state::AppState::test_with_adversarial_identity_state();
+ let survivor_id = app.state.workspaces[0].id.clone();
+ app.state.workspaces.insert(0, parent);
+ app.state.workspaces.push(linked);
+ app.state.active = Some(1);
+ app.state.selected = 1;
+ app.state.mode = crate::app::Mode::Terminal;
+ app.state.ensure_test_terminals();
+ let closed_pane_ids = [0, 2].map(|index| app.state.workspaces[index].tabs[0].root_pane);
+ let closed_terminal_ids = [0, 2].map(|index| {
+ app.state
+ .terminal_id_for_pane(index, app.state.workspaces[index].tabs[0].root_pane)
+ .expect("closed workspace pane has a terminal")
+ });
+ for pane_id in closed_pane_ids {
+ app.state.plugin_panes.insert(
+ pane_id,
+ crate::app::state::PluginPaneRecord {
+ plugin_id: "example.pane".into(),
+ entrypoint: "board".into(),
+ },
+ );
+ }
+ app.state.assert_invariants_for_test();
+
+ let parent_id = app.public_workspace_id(0);
+ let closed = [0, 2]
+ .into_iter()
+ .map(|index| (app.public_workspace_id(index), app.workspace_info(index)))
+ .collect::<Vec<_>>();
+
+ let response = app.handle_workspace_close(
+ "req".into(),
+ WorkspaceCloseParams {
+ workspace_id: parent_id,
+ close_group: true,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.id, "req");
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(app.state.workspaces[0].id, survivor_id);
+ for terminal_id in closed_terminal_ids {
+ assert!(!app.state.terminals.contains_key(&terminal_id));
+ }
+ for pane_id in closed_pane_ids {
+ assert!(!app.state.plugin_panes.contains_key(&pane_id));
+ }
+ assert!(app.state.terminal_runtime_shutdowns.is_empty());
+ app.state.assert_invariants_for_test();
+ let events = app.event_hub.events_after(0);
+ assert_eq!(events.len(), closed.len());
+ for ((_, event), (workspace_id, workspace)) in events.iter().zip(closed) {
+ assert!(matches!(event.event, EventKind::WorkspaceClosed));
+ assert!(matches!(
+ &event.data,
+ EventData::WorkspaceClosed {
+ workspace_id: closed_id,
+ workspace: Some(closed_workspace),
+ } if closed_id == &workspace_id && closed_workspace == &workspace
+ ));
+ }
+ }
+
#[test]
fn api_workspace_close_closes_linked_worktree_workspace_only() {
- let mut app = app_with_linked_worktree();
+ let mut app = app_with_worktree_group();
+ let linked_id = app.public_workspace_id(1);
let response = app.handle_workspace_close(
"req".into(),
- WorkspaceTarget {
- workspace_id: app.state.workspaces[0].id.clone(),
+ WorkspaceCloseParams {
+ workspace_id: linked_id,
+ close_group: true,
},
);
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
assert_eq!(success.id, "req");
assert_eq!(app.state.request_remove_linked_worktree, None);
- assert!(app.state.workspaces.is_empty());
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(app.state.workspaces[0].display_name(), "parent");
}
#[test]
@@ -482,8 +619,9 @@ mod tests {
let response = app.handle_workspace_close(
"req".into(),
- WorkspaceTarget {
+ WorkspaceCloseParams {
workspace_id: workspace_id.clone(),
+ close_group: false,
},
);
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index 343fa99c..e710459b 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -733,12 +733,15 @@ pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) {
}
pub(super) fn open_confirm_close(state: &mut AppState) {
- state.mode = Mode::ConfirmClose;
+ state.begin_workspace_close_confirmation(state.selected);
}
#[cfg(test)]
pub(super) fn confirm_close_accept(state: &mut AppState) {
- state.close_selected_workspace();
+ if let Some(ws_idx) = state.take_confirmed_workspace_close_index() {
+ state.selected = ws_idx;
+ state.close_selected_workspace();
+ }
if state.workspaces.is_empty() {
state.mode = Mode::Navigate;
} else {
@@ -747,6 +750,7 @@ pub(super) fn confirm_close_accept(state: &mut AppState) {
}
pub(super) fn confirm_close_cancel(state: &mut AppState) {
+ state.confirm_close_workspace_id = None;
state.mode = Mode::Navigate;
}
@@ -1113,9 +1117,8 @@ impl App {
}
pub(super) fn confirm_close_accept_via_api(&mut self) {
- let ws_idx = self.state.selected;
- if ws_idx < self.state.workspaces.len() {
- self.close_workspace_idx_via_api(ws_idx);
+ if let Some(ws_idx) = self.state.take_confirmed_workspace_close_index() {
+ self.close_workspace_idx_with_group_via_api(ws_idx);
}
self.state.mode = if self.state.active.is_some() {
Mode::Terminal
@@ -1245,7 +1248,7 @@ impl App {
if self.state.confirm_close {
open_confirm_close(&mut self.state);
} else {
- self.close_workspace_idx_via_api(ws_idx);
+ self.close_workspace_idx_with_group_via_api(ws_idx);
self.state.mode = Mode::Navigate;
}
}
@@ -2139,8 +2142,8 @@ mod tests {
#[test]
fn confirm_close_keyboard_actions_are_direct_not_focused() {
let mut state = state_with_workspaces(&["a", "b"]);
- state.mode = Mode::ConfirmClose;
state.selected = 1;
+ open_confirm_close(&mut state);
handle_confirm_close_key(
&mut state,
@@ -2149,7 +2152,7 @@ mod tests {
assert_eq!(state.mode, Mode::Navigate);
assert_eq!(state.workspaces.len(), 2);
- state.mode = Mode::ConfirmClose;
+ open_confirm_close(&mut state);
handle_confirm_close_key(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
@@ -2160,7 +2163,6 @@ mod tests {
#[test]
fn confirm_close_for_linked_worktree_closes_workspace_only() {
let mut state = state_with_workspaces(&["main", "issue"]);
- state.mode = Mode::ConfirmClose;
state.selected = 1;
state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
key: "repo-key".into(),
@@ -2170,6 +2172,7 @@ mod tests {
is_linked_worktree: true,
});
+ open_confirm_close(&mut state);
handle_confirm_close_key(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
@@ -2305,6 +2308,50 @@ mod tests {
}
#[test]
+ fn api_confirm_close_accept_closes_parent_worktree_group() {
+ let mut app = app_with_test_workspaces(&["main", "issue"]);
+ mark_worktree_space_member(&mut app.state, 0, "repo-key");
+ mark_worktree_space_member(&mut app.state, 1, "repo-key");
+ app.state.selected = 0;
+ open_confirm_close(&mut app.state);
+
+ app.handle_confirm_close_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
+
+ assert!(app.state.workspaces.is_empty());
+ assert_eq!(app.state.mode, Mode::Navigate);
+ assert_eq!(app.event_hub.events_after(0).len(), 2);
+ }
+
+ #[test]
+ fn api_confirm_close_accept_keeps_the_original_workspace_target() {
+ let mut app = app_with_test_workspaces(&["main", "issue", "other"]);
+ mark_worktree_space_member(&mut app.state, 0, "repo-key");
+ mark_worktree_space_member(&mut app.state, 1, "repo-key");
+ app.state.selected = 0;
+ open_confirm_close(&mut app.state);
+
+ app.focus_workspace_idx_via_api(2);
+ assert_eq!(app.state.selected, 2);
+ assert_eq!(app.state.mode, Mode::ConfirmClose);
+
+ app.handle_confirm_close_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
+
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(app.state.workspaces[0].display_name(), "other");
+ assert_eq!(
+ app.event_hub
+ .events_after(0)
+ .iter()
+ .filter(|(_, event)| matches!(
+ event.event,
+ crate::api::schema::EventKind::WorkspaceClosed
+ ))
+ .count(),
+ 2
+ );
+ }
+
+ #[test]
fn api_context_menu_close_tab_last_parent_group_workspace_keeps_confirmation_mode() {
let mut app = app_with_test_workspaces(&["main", "issue"]);
mark_worktree_space_member(&mut app.state, 0, "repo-key");
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 3c6c86f4..bf82126b 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -3394,7 +3394,7 @@ mod tests {
app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
app.state.active = Some(0);
app.state.selected = 1;
- app.state.mode = Mode::ConfirmClose;
+ app.state.begin_workspace_close_confirmation(1);
let popup = app.state.confirm_close_rect();
let inner = Rect::new(
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 8e3d7d6e..517e267d 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -214,7 +214,7 @@ impl App {
if self.state.confirm_close {
super::modal::open_confirm_close(&mut self.state);
} else {
- self.close_workspace_idx_via_api(ws_idx);
+ self.close_workspace_idx_with_group_via_api(ws_idx);
leave_navigate_mode(&mut self.state);
}
}
@@ -443,9 +443,9 @@ impl App {
self.runtime_workspace_focus("tui.workspace.focus", workspace_id);
}
- pub(crate) fn close_workspace_idx_via_api(&mut self, ws_idx: usize) {
+ pub(crate) fn close_workspace_idx_with_group_via_api(&mut self, ws_idx: usize) {
let workspace_id = self.public_workspace_id(ws_idx);
- self.runtime_workspace_close("tui.workspace.close", workspace_id);
+ self.runtime_workspace_close_group("tui.workspace.close", workspace_id);
}
pub(crate) fn move_workspace_via_api(&mut self, source_ws_idx: usize, insert_idx: usize) {
@@ -489,7 +489,7 @@ impl App {
if self.state.confirm_implicit_worktree_group_close(ws_idx) {
return true;
}
- self.close_workspace_idx_via_api(ws_idx);
+ self.close_workspace_idx_with_group_via_api(ws_idx);
return false;
}
let tab_idx = self.state.workspaces[ws_idx].active_tab_index();
@@ -3518,6 +3518,23 @@ navigate_pane_down = "ctrl+j"
}
#[test]
+ fn tui_close_parent_group_closes_immediately_when_confirmation_disabled() {
+ let mut app = app_with_test_workspaces(&["main", "issue"]);
+ mark_worktree_space_member(&mut app.state, 0, "repo-key");
+ mark_worktree_space_member(&mut app.state, 1, "repo-key");
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Navigate;
+ app.state.confirm_close = false;
+
+ app.execute_tui_navigate_action(NavigateAction::CloseWorkspace, ActionContext::Navigate);
+
+ assert!(app.state.workspaces.is_empty());
+ assert_eq!(app.state.mode, Mode::Navigate);
+ assert_eq!(app.event_hub.events_after(0).len(), 2);
+ }
+
+ #[test]
fn prefix_close_pane_last_parent_group_pane_opens_confirmation() {
let mut state = state_with_workspaces(&["main", "issue"]);
mark_worktree_space_member(&mut state, 0, "repo-key");
diff --git a/src/app/mod.rs b/src/app/mod.rs
index ff286c3f..c28f62da 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -718,6 +718,7 @@ impl App {
host_mouse_pixels: None,
session_dirty: false,
terminal_runtime_shutdowns: Vec::new(),
+ confirm_close_workspace_id: None,
};
state.terminals = restored_terminals;
diff --git a/src/app/runtime_mutations.rs b/src/app/runtime_mutations.rs
index e9b3fde1..d3d767e7 100644
--- a/src/app/runtime_mutations.rs
+++ b/src/app/runtime_mutations.rs
@@ -2,8 +2,9 @@ use crate::api::schema::{
EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneInputSetParams,
PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget,
PaneZoomParams, TabCreateParams, TabMoveParams, TabRenameParams, TabTarget,
- WorkspaceCreateParams, WorkspaceMoveBlockParams, WorkspaceMoveParams, WorkspaceRenameParams,
- WorkspaceTarget, WorktreeCreateParams, WorktreeOpenParams, WorktreeRemoveParams,
+ WorkspaceCloseParams, WorkspaceCreateParams, WorkspaceMoveBlockParams, WorkspaceMoveParams,
+ WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, WorktreeOpenParams,
+ WorktreeRemoveParams,
};
use super::App;
@@ -61,12 +62,18 @@ impl App {
self.dispatch_runtime_mutation(id, Method::WorkspaceMoveBlock(params))
}
- pub(crate) fn runtime_workspace_close(
+ pub(crate) fn runtime_workspace_close_group(
&mut self,
id: &'static str,
workspace_id: String,
) -> String {
- self.dispatch_runtime_mutation(id, Method::WorkspaceClose(WorkspaceTarget { workspace_id }))
+ self.dispatch_runtime_mutation(
+ id,
+ Method::WorkspaceClose(WorkspaceCloseParams {
+ workspace_id,
+ close_group: true,
+ }),
+ )
}
pub(crate) fn runtime_tab_create(
diff --git a/src/app/state.rs b/src/app/state.rs
index 21ee8899..782b8ae5 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1445,6 +1445,8 @@ pub struct AppState {
pub(crate) previous_pane_focus: Option<PaneFocusTarget>,
pub selected: usize,
pub mode: Mode,
+ /// Stable workspace identity captured when the close confirmation opens.
+ pub(crate) confirm_close_workspace_id: Option<String>,
pub should_quit: bool,
/// In monolithic --no-session mode, detach exits the app because there is no server to detach from.
pub detach_exits: bool,
@@ -1836,6 +1838,7 @@ impl AppState {
previous_pane_focus: None,
selected: 0,
mode: Mode::Navigate,
+ confirm_close_workspace_id: None,
should_quit: false,
detach_exits: false,
detach_requested: false,
diff --git a/src/cli/runtime.rs b/src/cli/runtime.rs
index 16d2d8b3..793ca24e 100644
--- a/src/cli/runtime.rs
+++ b/src/cli/runtime.rs
@@ -2,8 +2,8 @@ use crate::api::schema::{
EmptyParams, Method, PaneFocusDirectionParams, PaneInputSetParams, PaneMoveParams,
PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget,
PaneZoomParams, Request, TabCreateParams, TabListParams, TabRenameParams, TabTarget,
- WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams,
- WorktreeListParams, WorktreeOpenParams, WorktreeRemoveParams,
+ WorkspaceCloseParams, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget,
+ WorktreeCreateParams, WorktreeListParams, WorktreeOpenParams, WorktreeRemoveParams,
};
fn print_method_response(id: &'static str, method: Method) -> std::io::Result<i32> {
@@ -42,11 +42,8 @@ pub(super) fn workspace_rename(params: WorkspaceRenameParams) -> std::io::Result
print_method_response("cli:workspace:rename", Method::WorkspaceRename(params))
}
-pub(super) fn workspace_close(workspace_id: String) -> std::io::Result<i32> {
- print_method_response(
- "cli:workspace:close",
- Method::WorkspaceClose(WorkspaceTarget { workspace_id }),
- )
+pub(super) fn workspace_close(params: WorkspaceCloseParams) -> std::io::Result<i32> {
+ print_method_response("cli:workspace:close", Method::WorkspaceClose(params))
}
pub(super) fn tab_list(params: TabListParams) -> std::io::Result<i32> {
diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs
index 525fc971..c24c9b73 100644
--- a/src/cli/workspace.rs
+++ b/src/cli/workspace.rs
@@ -225,16 +225,19 @@ fn workspace_report_metadata(args: &[String]) -> std::io::Result<i32> {
}
fn workspace_close(args: &[String]) -> std::io::Result<i32> {
- let Some(raw_workspace_id) = args.first() else {
- eprintln!("usage: herdr workspace close <workspace_id>");
- return Ok(2);
+ let (raw_workspace_id, close_group) = match args {
+ [workspace_id] => (workspace_id, false),
+ [workspace_id, flag] if flag == "--group" => (workspace_id, true),
+ _ => {
+ eprintln!("usage: herdr workspace close <workspace_id> [--group]");
+ return Ok(2);
+ }
};
- if args.len() != 1 {
- eprintln!("usage: herdr workspace close <workspace_id>");
- return Ok(2);
- }
- super::runtime::workspace_close(super::normalize_workspace_id(raw_workspace_id))
+ super::runtime::workspace_close(crate::api::schema::WorkspaceCloseParams {
+ workspace_id: super::normalize_workspace_id(raw_workspace_id),
+ close_group,
+ })
}
fn print_workspace_help() {
@@ -245,5 +248,5 @@ fn print_workspace_help() {
eprintln!(" herdr workspace focus <workspace_id>");
eprintln!(" herdr workspace rename <workspace_id> <label>");
eprintln!(" herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]");
- eprintln!(" herdr workspace close <workspace_id>");
+ eprintln!(" herdr workspace close <workspace_id> [--group]");
}
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 4a445361..3680a557 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 20;
+pub const PROTOCOL_VERSION: u32 = 21;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.