aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/shell/actions.rs13
-rw-r--r--src/client/shell/context_menu.rs2
-rw-r--r--src/client/shell/mouse.rs15
-rw-r--r--src/client/shell/overlay_input.rs91
-rw-r--r--src/client/shell/state.rs7
-rw-r--r--src/client/shell/tests/close_tab.rs241
-rw-r--r--src/client/shell/tests/graphics.rs1
-rw-r--r--src/client/shell/tests/mod.rs1
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;