diff options
| author | akbash <akbash@herdr.dev> | 2026-08-25 23:44:56 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-25 23:44:56 +0300 |
| commit | d79fd746a96ddb5642939c9727baefce642d78e6 (patch) | |
| tree | f6ca7416b082168db6607952d5fb6672f5173826 /src/app | |
| parent | da5357d23f8450d4609f72d079876d7d439ef5a0 (diff) | |
fix: require explicit workspace group close (#3206)
refs #2874
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 67 | ||||
| -rw-r--r-- | src/app/api/workspaces.rs | 200 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 65 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 2 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 25 | ||||
| -rw-r--r-- | src/app/mod.rs | 1 | ||||
| -rw-r--r-- | src/app/runtime_mutations.rs | 15 | ||||
| -rw-r--r-- | src/app/state.rs | 3 |
8 files changed, 299 insertions, 79 deletions
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(¶ms.workspace_id) else { + return workspace_not_found(id, ¶ms.workspace_id); }; if self.state.workspaces.get(index).is_none() { - return workspace_not_found(id, &target.workspace_id); + return workspace_not_found(id, ¶ms.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, |
