diff options
| author | Eric Yue <jiacheng.yue@foxmail.com> | 2026-09-21 01:33:11 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-20 19:33:11 +0200 |
| commit | f090ce95d05d2e7811869be93f9a23bf4ac2c3af (patch) | |
| tree | d5a1a447fd6043056e5ea3cb1a9bd0419957e57b /src/client | |
| parent | f10df75c8a5f1b5e5f90689c3a8ceb6758366e05 (diff) | |
fix: confirm closing the last tab in the tui (#4409)
* fix: confirm closing the last tab in the tui
refs #4379
* docs: clarify last-tab worktree confirmation in translations
refs #4379
---------
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/shell/actions.rs | 13 | ||||
| -rw-r--r-- | src/client/shell/context_menu.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/mouse.rs | 15 | ||||
| -rw-r--r-- | src/client/shell/overlay_input.rs | 91 | ||||
| -rw-r--r-- | src/client/shell/state.rs | 7 | ||||
| -rw-r--r-- | src/client/shell/tests/close_tab.rs | 241 | ||||
| -rw-r--r-- | src/client/shell/tests/graphics.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/tests/mod.rs | 1 |
8 files changed, 338 insertions, 33 deletions
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index da4edd49..9d0a3bd5 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -124,6 +124,16 @@ impl ClientShellState { outcome.repaint = true; return; } + if action == crate::input::KeybindAction::CloseTab { + if let Some(tab_id) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_tab_id.clone()) + { + self.request_tab_close(tab_id, outcome); + } + return; + } if action == crate::input::KeybindAction::NewTab && self.config.prompt_new_tab_name { self.open_new_tab_overlay(); @@ -1040,9 +1050,6 @@ impl ClientShellState { env: Default::default(), })) } - KeybindAction::CloseTab => Some(Method::TabClose(TabTarget { - tab_id: focused_tab?, - })), KeybindAction::ClosePane => Some(Method::PaneClose(PaneTarget { pane_id: focused_pane.clone()?, })), diff --git a/src/client/shell/context_menu.rs b/src/client/shell/context_menu.rs index 96f57178..b57bc843 100644 --- a/src/client/shell/context_menu.rs +++ b/src/client/shell/context_menu.rs @@ -358,7 +358,7 @@ impl ClientShellState { } } ClientContextMenuAction::Close => { - self.push_endpoint_method(Method::TabClose(TabTarget { tab_id }), outcome); + self.request_tab_close(tab_id, outcome); } _ => {} } diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index a7e0dd81..f2df2941 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -1692,20 +1692,7 @@ impl ClientShellState { match self.overlay.as_ref() { Some(ClientShellOverlay::Rename(_)) => self.save_rename_overlay(outcome), Some(ClientShellOverlay::ConfirmClose(_)) => { - let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() - else { - return; - }; - self.push_endpoint_method( - crate::api::schema::Method::WorkspaceClose( - crate::api::schema::WorkspaceCloseParams { - workspace_id: confirm.workspace_id, - close_group: true, - }, - ), - outcome, - ); - outcome.repaint = true; + self.accept_close_confirmation(outcome); } _ => {} } diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index e4248304..10bd4a45 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -877,19 +877,7 @@ impl ClientShellState { if matches!(self.overlay, Some(ClientShellOverlay::ConfirmClose(_))) { if key.code == KeyCode::Enter { - let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() else { - return; - }; - self.push_endpoint_method( - crate::api::schema::Method::WorkspaceClose( - crate::api::schema::WorkspaceCloseParams { - workspace_id: confirm.workspace_id, - close_group: true, - }, - ), - outcome, - ); - outcome.repaint = true; + self.accept_close_confirmation(outcome); } else if key.code == KeyCode::Esc { self.overlay = None; self.mode = ClientShellMode::Navigate; @@ -999,16 +987,74 @@ impl ClientShellState { outcome.repaint = true; } + pub(super) fn request_tab_close(&mut self, tab_id: String, outcome: &mut ClientShellInput) { + let workspace_id = self.snapshot.as_deref().and_then(|snapshot| { + let target = snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id)?; + (self.config.confirm_close + && !snapshot + .tabs + .iter() + .any(|tab| tab.workspace_id == target.workspace_id && tab.tab_id != tab_id)) + .then(|| target.workspace_id.clone()) + }); + if let Some(workspace_id) = workspace_id { + if self.open_close_confirmation(workspace_id, Some(tab_id.clone())) { + outcome.repaint = true; + return; + } + } + self.push_endpoint_method( + crate::api::schema::Method::TabClose(crate::api::schema::TabTarget { tab_id }), + outcome, + ); + } + + pub(super) fn accept_close_confirmation(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() else { + return; + }; + outcome.repaint = true; + let method = if let Some(target) = confirm.tab_target { + if target.workspace.endpoint_id != self.active_endpoint_id + || !self.navigation_target_valid(&target.workspace) + || !self.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.tabs.iter().any(|tab| { + tab.tab_id == target.tab_id + && tab.workspace_id == target.workspace.workspace_id + }) + }) + { + self.receive_endpoint_unavailable( + "Close target changed; try closing the tab again".into(), + ); + return; + } + crate::api::schema::Method::TabClose(crate::api::schema::TabTarget { + tab_id: target.tab_id, + }) + } else { + crate::api::schema::Method::WorkspaceClose(crate::api::schema::WorkspaceCloseParams { + workspace_id: confirm.workspace_id, + close_group: true, + }) + }; + self.push_endpoint_method(method, outcome); + } + pub(super) fn open_confirm_close_overlay(&mut self, workspace_id: String) { + self.open_close_confirmation(workspace_id, None); + } + + fn open_close_confirmation(&mut self, workspace_id: String, tab_id: Option<String>) -> bool { let Some(snapshot) = self.snapshot.as_deref() else { - return; + return false; }; let Some(workspace) = snapshot .workspaces .iter() .find(|workspace| workspace.workspace_id == workspace_id) else { - return; + return false; }; let group_key = workspace .worktree @@ -1030,6 +1076,19 @@ impl ClientShellState { }) .unwrap_or_else(|| vec![workspace]); let closes_group = group.len() > 1; + // Keep parent-group tab closes on the existing server confirmation path. + if tab_id.is_some() && closes_group { + return false; + } + let tab_target = if let Some(tab_id) = tab_id { + let Some(workspace) = self.navigation_target(&self.active_endpoint_id, &workspace_id) + else { + return false; + }; + Some(ClientTabCloseConfirmation { tab_id, workspace }) + } else { + None + }; let pane_count = group .iter() .map(|member| { @@ -1053,6 +1112,7 @@ impl ClientShellState { self.overlay = Some(ClientShellOverlay::ConfirmClose( ClientConfirmCloseOverlay { workspace_id, + tab_target, title: if closes_group { "Close worktree group?".to_owned() } else { @@ -1061,5 +1121,6 @@ impl ClientShellState { detail: format!("{} — {scope}", workspace.label), }, )); + true } } diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 55a065d5..2eaf18bf 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -563,8 +563,15 @@ pub(super) struct ClientContextMenuItem { } #[derive(Debug)] +pub(super) struct ClientTabCloseConfirmation { + pub(super) tab_id: String, + pub(super) workspace: WorkspaceNavigationTarget, +} + +#[derive(Debug)] pub(super) struct ClientConfirmCloseOverlay { pub(super) workspace_id: String, + pub(super) tab_target: Option<ClientTabCloseConfirmation>, pub(super) title: String, pub(super) detail: String, } diff --git a/src/client/shell/tests/close_tab.rs b/src/client/shell/tests/close_tab.rs new file mode 100644 index 00000000..d5e897a3 --- /dev/null +++ b/src/client/shell/tests/close_tab.rs @@ -0,0 +1,241 @@ +use super::*; +use crate::api::schema::Method; +use crossterm::event::{MouseButton, MouseEventKind}; + +fn close_state(confirm: bool, tab_count: usize) -> ClientShellState { + let mut projected = snapshot(); + for number in 2..=tab_count { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{number}"); + tab.number = number; + tab.focused = false; + projected.tabs.push(tab); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.confirm_close = confirm; + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).unwrap(); + state +} + +fn click(state: &mut ClientShellState, rect: Rect) -> ClientShellInput { + state.handle_raw_events(vec![crate::raw_input::RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + })]) +} + +fn request_close(state: &mut ClientShellState, menu: bool) -> ClientShellInput { + if menu { + state.open_tab_context_menu("tab_1".into(), 30, 1); + state.compose(106, 24).unwrap(); + let close_row = state.hits.context_menu_rows[2].0; + click(state, close_row) + } else { + let mut outcome = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CloseTab), + &mut outcome, + ); + outcome + } +} + +fn assert_no_close(outcome: &ClientShellInput) { + assert!(outcome.requests.is_empty()); + assert!(outcome.actions.iter().all(|action| { + !matches!(action, ClientShellAction::Endpoint { request, .. } + if matches!(request.method, Method::TabClose(_) | Method::WorkspaceClose(_))) + })); +} + +fn assert_tab_close(outcome: &ClientShellInput) { + let methods = outcome + .actions + .iter() + .filter_map(|action| match action { + ClientShellAction::Endpoint { request, .. } => Some(&request.method), + _ => None, + }) + .filter(|method| !matches!(method, Method::TabFocus(_))) + .collect::<Vec<_>>(); + assert!(matches!(methods.as_slice(), [Method::TabClose(target)] if target.tab_id == "tab_1")); +} + +#[test] +fn last_tab_close_waits_for_keyboard_or_mouse_confirmation() { + for menu in [false, true] { + let mut state = close_state(true, 1); + let requested = request_close(&mut state, menu); + assert_no_close(&requested); + assert!(requested.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + let frame = state.compose(106, 24).unwrap(); + let text = frame_rows(&frame).join("\n"); + assert!(text.contains("Close workspace?")); + assert!(text.contains("1 pane")); + let accepted = if menu { + let primary = state.hits.overlay_primary; + click(&mut state, primary) + } else { + state.handle_input_bytes(b"\r") + }; + assert_tab_close(&accepted); + assert!(state.overlay.is_none()); + } +} + +#[test] +fn last_tab_close_confirmation_can_be_cancelled() { + for mouse in [false, true] { + let mut state = close_state(true, 1); + assert_no_close(&request_close(&mut state, false)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + state.compose(106, 24).unwrap(); + let cancelled = if mouse { + let cancel = state.hits.overlay_cancel; + click(&mut state, cancel) + } else { + state.handle_input_bytes(b"\x1b") + }; + assert_no_close(&cancelled); + assert!(state.overlay.is_none()); + } +} + +#[test] +fn tab_close_stays_immediate_with_confirmation_disabled_or_other_tabs() { + for (confirm, tabs) in [(false, 1), (false, 2), (true, 2)] { + for menu in [false, true] { + let mut state = close_state(confirm, tabs); + assert_tab_close(&request_close(&mut state, menu)); + assert!(state.overlay.is_none()); + } + } +} + +#[test] +fn last_tab_confirmation_preserves_target_across_focus_changes_and_new_tabs() { + let mut state = close_state(true, 1); + let mut projected = state.snapshot.as_deref().unwrap().clone(); + let mut other_workspace = projected.workspaces[0].clone(); + other_workspace.workspace_id = "ws_2".into(); + other_workspace.active_tab_id = "other_tab".into(); + other_workspace.focused = false; + projected.workspaces.push(other_workspace); + let mut other_tab = projected.tabs[0].clone(); + other_tab.workspace_id = "ws_2".into(); + other_tab.tab_id = "other_tab".into(); + other_tab.focused = false; + projected.tabs.push(other_tab); + state.set_snapshot(Box::new(projected.clone())); + + assert_no_close(&request_close(&mut state, false)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + let mut new_tab = projected.tabs[0].clone(); + new_tab.tab_id = "new_tab".into(); + new_tab.focused = false; + projected.tabs.push(new_tab); + projected.focused_workspace_id = Some("ws_2".into()); + projected.focused_tab_id = Some("other_tab".into()); + for workspace in &mut projected.workspaces { + workspace.focused = workspace.workspace_id == "ws_2"; + } + for tab in &mut projected.tabs { + tab.focused = tab.tab_id == "other_tab"; + } + state.set_snapshot(Box::new(projected)); + assert_tab_close(&state.handle_input_bytes(b"\r")); +} + +#[test] +fn last_tab_confirmation_rejects_missing_moved_or_reconnected_targets() { + for change in ["missing", "moved", "reconnected"] { + let mut state = close_state(true, 1); + assert_no_close(&request_close(&mut state, false)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + let mut projected = state.snapshot.as_deref().unwrap().clone(); + match change { + "missing" => projected.tabs.clear(), + "moved" => projected.tabs[0].workspace_id = "different_workspace".into(), + "reconnected" => state.endpoints[0].snapshot_generation = Some(2), + _ => unreachable!(), + } + if change != "reconnected" { + state.set_snapshot(Box::new(projected)); + } + assert_no_close(&state.handle_input_bytes(b"\r")); + assert!(state.overlay.is_none()); + } +} + +#[test] +fn last_tab_close_preserves_parent_group_and_linked_workspace_scope() { + for linked in [false, true] { + let mut state = close_state(true, 1); + let mut projected = state.snapshot.as_deref().unwrap().clone(); + projected.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: linked, + }); + let mut sibling = projected.workspaces[0].clone(); + sibling.workspace_id = "ws_2".into(); + sibling.active_tab_id = "tab_2".into(); + sibling.focused = false; + sibling.worktree.as_mut().unwrap().is_linked_worktree = !linked; + projected.workspaces.push(sibling); + let mut tab = projected.tabs[0].clone(); + tab.tab_id = "tab_2".into(); + tab.workspace_id = "ws_2".into(); + tab.focused = false; + projected.tabs.push(tab); + state.set_snapshot(Box::new(projected)); + + let close = request_close(&mut state, false); + if linked { + assert_no_close(&close); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + assert_tab_close(&state.handle_input_bytes(b"\r")); + } else { + assert_tab_close(&close); + assert!(state.overlay.is_none()); + let [ClientShellAction::Endpoint { request, .. }] = close.actions.as_slice() else { + panic!("tab close request"); + }; + state.handle_endpoint_result( + "boot-1", + &request.id, + Err(ClientShellEndpointError { + code: Some("confirmation_required".into()), + message: "closing this tab would close a worktree group".into(), + }), + ); + assert!(matches!(state.overlay.as_ref(), + Some(ClientShellOverlay::ConfirmClose(confirm)) if confirm.title == "Close worktree group?")); + let accepted = state.handle_input_bytes(b"\r"); + assert!(matches!(accepted.actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, Method::WorkspaceClose(params) + if params.workspace_id == "ws_1" && params.close_group))); + } + } +} diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs index 98fc62fa..240e031c 100644 --- a/src/client/shell/tests/graphics.rs +++ b/src/client/shell/tests/graphics.rs @@ -216,6 +216,7 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() { }), ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay { workspace_id: "ws_1".into(), + tab_target: None, title: "close".into(), detail: "confirm".into(), }), diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index 787d8a75..9f04b527 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -246,6 +246,7 @@ fn surface_with_popup() -> PaneSurfaceFrame { mod agents_worktrees_notifications; mod chrome_context; +mod close_tab; mod copy; mod endpoint_requests; mod endpoints; |
