diff --git a/src/app/actions.rs b/src/app/actions.rs index eccc8d4d..e5742534 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -5,7 +5,9 @@ use tracing::{info, warn}; use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; -use crate::layout::{find_in_direction, NavDirection, PaneId}; +use crate::layout::PaneId; +#[cfg(test)] +use crate::layout::{find_in_direction, NavDirection}; use crate::selection::Selection; use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; @@ -1083,6 +1085,7 @@ impl AppState { .min(crate::ui::mobile_switcher_max_scroll(self)); } + #[cfg(test)] pub fn switch_tab(&mut self, idx: usize) { if let Some(ws_idx) = self.active { let previous_focus = self.current_pane_focus_target(); @@ -1167,6 +1170,7 @@ impl AppState { } } + #[cfg(test)] pub fn next_workspace(&mut self) { if self.workspaces.is_empty() { return; @@ -1178,6 +1182,7 @@ impl AppState { self.switch_workspace(next); } + #[cfg(test)] pub fn previous_workspace(&mut self) { if self.workspaces.is_empty() { return; @@ -1238,6 +1243,7 @@ impl AppState { self.refresh_tab_bar_view(); } + #[cfg(test)] pub fn next_tab(&mut self) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if !ws.tabs.is_empty() { @@ -1247,6 +1253,7 @@ impl AppState { } } + #[cfg(test)] pub fn previous_tab(&mut self) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if !ws.tabs.is_empty() { @@ -1260,14 +1267,17 @@ impl AppState { } } + #[cfg(test)] pub fn next_agent(&mut self) { self.cycle_agent_entry(true); } + #[cfg(test)] pub fn previous_agent(&mut self) { self.cycle_agent_entry(false); } + #[cfg(test)] pub fn focus_agent_entry(&mut self, idx: usize) -> bool { let entries = crate::ui::agent_panel_entries(self); let Some(target) = entries.get(idx) else { @@ -1289,6 +1299,7 @@ impl AppState { false } + #[cfg(test)] fn cycle_agent_entry(&mut self, forward: bool) { let entries = crate::ui::agent_panel_entries(self); if entries.is_empty() { @@ -1536,6 +1547,7 @@ pub(crate) struct PaneZoomOutcome { } impl AppState { + #[cfg(test)] pub fn navigate_pane(&mut self, direction: NavDirection) { let Some(ws_idx) = self.active else { return; @@ -1556,6 +1568,7 @@ impl AppState { } } + #[cfg(test)] pub fn swap_pane(&mut self, direction: NavDirection) -> bool { let Some(ws_idx) = self.active else { return false; @@ -1610,6 +1623,7 @@ impl AppState { } } + #[cfg(test)] pub fn cycle_pane(&mut self, reverse: bool) { let Some(ws_idx) = self.active else { return; @@ -1628,6 +1642,7 @@ impl AppState { } } + #[cfg(test)] pub fn last_pane(&mut self) { let Some(target) = self.previous_pane_focus.clone() else { return; @@ -1708,6 +1723,7 @@ impl AppState { }) } + #[cfg(test)] pub fn toggle_zoom(&mut self) { let Some(ws_idx) = self.active else { return; @@ -1749,6 +1765,7 @@ impl AppState { } } + #[cfg(test)] fn close_focused_pane_would_close_workspace(&self, ws_idx: usize) -> bool { self.workspaces.get(ws_idx).is_some_and(|ws| { let pane_count = ws @@ -1767,6 +1784,7 @@ impl AppState { }) } + #[cfg(test)] /// Close the focused pane. Returns true when the close was deferred to confirmation. pub fn close_pane(&mut self) -> bool { let active = self.active; @@ -1812,6 +1830,7 @@ impl AppState { false } + #[cfg(test)] /// Close the active tab. Returns true when the close was deferred to confirmation. pub fn close_tab(&mut self) -> bool { if self.active.is_some_and(|ws_idx| { diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 164c4dd4..00bbf3cf 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -4,6 +4,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, use crate::app::PaneClickState; use crate::input::TerminalKey; +#[cfg(test)] use ratatui::layout::Direction; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -520,6 +521,7 @@ pub(crate) fn modal_paste_target_active(state: &AppState) -> bool { // Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App impl AppState { + #[cfg(test)] pub(crate) fn split_pane( &mut self, terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 31e0aa1e..ba1fd2d4 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -993,14 +993,12 @@ impl App { (self.state.active, self.state.rename_pane_target) { if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.dispatch_runtime_mutation( + self.runtime_pane_rename( "tui.pane.rename", - crate::api::schema::Method::PaneRename( - crate::api::schema::PaneRenameParams { - pane_id, - label: Some(new_name), - }, - ), + crate::api::schema::PaneRenameParams { + pane_id, + label: Some(new_name), + }, ); } } @@ -1058,13 +1056,13 @@ impl App { _ => None, }; if let Some(direction) = direction { - self.dispatch_runtime_mutation( + self.runtime_pane_resize( "tui.pane.resize", - crate::api::schema::Method::PaneResize(crate::api::schema::PaneResizeParams { + crate::api::schema::PaneResizeParams { pane_id: None, direction: super::navigate::api_pane_direction(direction), amount: None, - }), + }, ); } } @@ -1173,8 +1171,9 @@ impl App { (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { self.focus_workspace_idx_via_api(ws_idx); self.focus_tab_idx_via_api(tab_idx); - self.close_active_tab_via_api(); - leave_modal(&mut self.state); + if !self.close_active_tab_via_api_requires_confirmation() { + leave_modal(&mut self.state); + } } (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { open_rename_pane(&mut self.state, pane_id); @@ -1186,14 +1185,12 @@ impl App { Some("Clear pane name"), ) => { if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.dispatch_runtime_mutation( + self.runtime_pane_rename( "tui.pane.clear_name", - crate::api::schema::Method::PaneRename( - crate::api::schema::PaneRenameParams { - pane_id, - label: None, - }, - ), + crate::api::schema::PaneRenameParams { + pane_id, + label: None, + }, ); } self.state.mode = Mode::Terminal; @@ -1212,14 +1209,14 @@ impl App { if let (Some(source_public_id), Some(target_public_id)) = (source_public_id, target_public_id) { - self.dispatch_runtime_mutation( + self.runtime_pane_swap( "tui.pane.swap_exact", - crate::api::schema::Method::PaneSwap(crate::api::schema::PaneSwapParams { + crate::api::schema::PaneSwapParams { pane_id: None, direction: None, source_pane_id: Some(source_public_id), target_pane_id: Some(target_public_id), - }), + }, ); self.focus_pane_internal_via_api(ws_idx, source_pane_id); } @@ -1262,12 +1259,13 @@ impl App { Some("Close pane"), ) => { self.focus_pane_internal_via_api(ws_idx, pane_id); - self.close_focused_pane_via_api(); - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; + if !self.close_focused_pane_via_api_requires_confirmation() { + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } } _ => leave_modal(&mut self.state), } @@ -1305,6 +1303,7 @@ mod tests { use super::super::{capture_snapshot, state_with_workspaces}; use super::*; + use crate::workspace::Workspace; fn config_env_lock() -> &'static std::sync::Mutex<()> { crate::config::test_config_env_lock() @@ -1322,6 +1321,32 @@ mod tests { std::env::temp_dir().join(unique).join("config.toml") } + fn app_with_test_workspaces(names: &[&str]) -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); + app.state.ensure_test_terminals(); + app.state.active = (!app.state.workspaces.is_empty()).then_some(0); + app.state.selected = 0; + app + } + + fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { + state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: key.into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: format!("/repo/worktree-{ws_idx}").into(), + is_linked_worktree: ws_idx != 0, + }); + } + #[test] fn custom_resize_key_exits_resize_mode() { let mut state = state_with_workspaces(&["test"]); @@ -1865,4 +1890,71 @@ mod tests { assert_eq!(state.mode, Mode::ConfirmClose); assert_eq!(state.workspaces.len(), 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"); + mark_worktree_space_member(&mut app.state, 1, "repo-key"); + app.state.active = Some(0); + app.state.selected = 1; + app.state.mode = Mode::ContextMenu; + let menu = ContextMenuState { + kind: ContextMenuKind::Tab { + ws_idx: 0, + tab_idx: 0, + }, + x: 0, + y: 0, + list: MenuListState::new(0), + }; + let idx = menu + .items() + .iter() + .position(|item| *item == "Close") + .expect("close tab item"); + + app.apply_context_menu_action_via_api(menu, idx); + + assert_eq!(app.state.selected, 0); + assert_eq!(app.state.mode, Mode::ConfirmClose); + assert_eq!(app.state.workspaces.len(), 2); + } + + #[test] + fn api_context_menu_enter_close_pane_last_parent_group_pane_keeps_confirmation_mode() { + 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 = 1; + app.state.mode = Mode::ContextMenu; + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let mut menu = ContextMenuState { + kind: ContextMenuKind::Pane { + ws_idx: 0, + tab_idx: 0, + pane_id, + source_pane_id: None, + has_manual_label: false, + }, + x: 0, + y: 0, + list: MenuListState::new(0), + }; + let close_idx = menu + .items() + .iter() + .position(|item| *item == "Close pane") + .expect("close pane item"); + menu.list.highlighted = close_idx; + app.state.context_menu = Some(menu); + + app.handle_context_menu_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); + + assert_eq!(app.state.selected, 0); + assert_eq!(app.state.mode, Mode::ConfirmClose); + assert_eq!(app.state.workspaces.len(), 2); + assert!(app.state.context_menu.is_none()); + } } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index a4cbc089..95afdbe9 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -1426,6 +1426,7 @@ impl AppState { && rect_contains(self.view.toast_hit_area, col, row) } + #[cfg(test)] pub(crate) fn focus_toast_target(&mut self) { let Some(target) = self.toast.as_ref().and_then(|toast| toast.target.clone()) else { return; @@ -1848,6 +1849,16 @@ mod tests { workspace::Workspace, }; + fn mark_worktree_space_member(workspace: &mut Workspace, ws_idx: usize, key: &str) { + workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: key.into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: format!("/repo/worktree-{ws_idx}").into(), + is_linked_worktree: ws_idx != 0, + }); + } + #[tokio::test] async fn terminal_wheel_uses_configured_mouse_scroll_lines() { let mut app = app_for_mouse_test(); @@ -3239,6 +3250,109 @@ mod tests { .any(|(_, event)| { matches!(event.event, crate::api::schema::EventKind::TabClosed) })); } + #[test] + fn clicking_pane_context_menu_close_leaves_context_menu_mode() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("one"); + let first_pane = ws.tabs[0].root_pane; + let second_pane = ws.test_split(Direction::Horizontal); + ws.tabs[0].layout.focus_pane(second_pane); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let first_info = app + .state + .view + .pane_infos + .iter() + .find(|info| info.id == first_pane) + .expect("first pane info") + .clone(); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + first_info.inner_rect.x + 1, + first_info.inner_rect.y + 1, + )); + + let menu_state = app.state.context_menu.as_ref().expect("pane context menu"); + let close_idx = menu_state + .items() + .iter() + .position(|item| *item == "Close pane") + .expect("close pane menu item"); + let menu = app + .state + .context_menu_rect() + .expect("pane context menu rect"); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 1 + close_idx as u16, + )); + + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + assert!(app.state.context_menu.is_none()); + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { + matches!(event.event, crate::api::schema::EventKind::PaneClosed) + })); + } + + #[test] + fn clicking_pane_context_menu_close_last_parent_group_pane_keeps_confirmation_mode() { + let mut app = app_for_mouse_test(); + let mut parent = Workspace::test_new("main"); + let pane_id = parent.tabs[0].root_pane; + mark_worktree_space_member(&mut parent, 0, "repo-key"); + let mut child = Workspace::test_new("issue"); + mark_worktree_space_member(&mut child, 1, "repo-key"); + app.state.workspaces = vec![parent, child]; + app.state.active = Some(0); + app.state.selected = 1; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let pane_info = app + .state + .view + .pane_infos + .iter() + .find(|info| info.id == pane_id) + .expect("pane info") + .clone(); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + pane_info.inner_rect.x + 1, + pane_info.inner_rect.y + 1, + )); + + let menu_state = app.state.context_menu.as_ref().expect("pane context menu"); + let close_idx = menu_state + .items() + .iter() + .position(|item| *item == "Close pane") + .expect("close pane menu item"); + let menu = app + .state + .context_menu_rect() + .expect("pane context menu rect"); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + menu.x + 2, + menu.y + 1 + close_idx as u16, + )); + + assert_eq!(app.state.selected, 0); + assert_eq!(app.state.mode, Mode::ConfirmClose); + assert_eq!(app.state.workspaces.len(), 2); + assert!(app.state.context_menu.is_none()); + } + #[test] fn wheel_over_overflowing_tab_bar_switches_tabs() { let mut app = app_for_mouse_test(); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 186947b6..53aa8a5d 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -6,7 +6,9 @@ use std::{ }; use bytes::Bytes; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::KeyCode; +#[cfg(test)] +use crossterm::event::KeyEvent; use ratatui::layout::Direction; use crate::{ @@ -253,8 +255,9 @@ impl App { } } NavigateAction::CloseTab => { - self.close_active_tab_via_api(); - leave_navigate_mode(&mut self.state); + if !self.close_active_tab_via_api_requires_confirmation() { + leave_navigate_mode(&mut self.state); + } } NavigateAction::RenamePane => { if let Some(pane_id) = self @@ -297,8 +300,9 @@ impl App { leave_navigate_mode(&mut self.state); } NavigateAction::ClosePane => { - self.close_focused_pane_via_api(); - leave_navigate_mode(&mut self.state); + if !self.close_focused_pane_via_api_requires_confirmation() { + leave_navigate_mode(&mut self.state); + } } NavigateAction::EditScrollback => {} NavigateAction::CopyMode => self.state.enter_copy_mode(&self.terminal_runtimes), @@ -383,15 +387,28 @@ impl App { self.runtime_tab_focus("tui.tab.focus", tab_id); } - pub(crate) fn close_active_tab_via_api(&mut self) { + pub(crate) fn close_active_tab_via_api_requires_confirmation(&mut self) -> bool { let Some(ws_idx) = self.state.active else { - return; + return false; }; + if self + .state + .workspaces + .get(ws_idx) + .is_some_and(|ws| ws.tabs.len() <= 1) + { + if self.state.confirm_implicit_worktree_group_close(ws_idx) { + return true; + } + self.close_workspace_idx_via_api(ws_idx); + return false; + } let tab_idx = self.state.workspaces[ws_idx].active_tab_index(); let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { - return; + return false; }; self.runtime_tab_close("tui.tab.close", tab_id); + false } pub(crate) fn move_tab_via_api( @@ -428,14 +445,12 @@ impl App { self.focus_pane_internal_via_api(ws_idx, target); return; } - self.dispatch_runtime_mutation( + self.runtime_pane_focus_direction( "tui.pane.focus_direction", - crate::api::schema::Method::PaneFocusDirection( - crate::api::schema::PaneFocusDirectionParams { - pane_id: None, - direction: api_pane_direction(direction), - }, - ), + crate::api::schema::PaneFocusDirectionParams { + pane_id: None, + direction: api_pane_direction(direction), + }, ); } @@ -444,26 +459,26 @@ impl App { let source_pane_id = self.public_pane_id(ws_idx, source); let target_pane_id = self.public_pane_id(ws_idx, target); if let (Some(source_pane_id), Some(target_pane_id)) = (source_pane_id, target_pane_id) { - self.dispatch_runtime_mutation( + self.runtime_pane_swap( "tui.pane.swap_exact", - crate::api::schema::Method::PaneSwap(crate::api::schema::PaneSwapParams { + crate::api::schema::PaneSwapParams { pane_id: None, direction: None, source_pane_id: Some(source_pane_id), target_pane_id: Some(target_pane_id), - }), + }, ); return; } } - self.dispatch_runtime_mutation( + self.runtime_pane_swap( "tui.pane.swap", - crate::api::schema::Method::PaneSwap(crate::api::schema::PaneSwapParams { + crate::api::schema::PaneSwapParams { pane_id: None, direction: Some(api_pane_direction(direction)), source_pane_id: None, target_pane_id: None, - }), + }, ); } @@ -471,9 +486,9 @@ impl App { &mut self, direction: crate::api::schema::SplitDirection, ) { - self.dispatch_runtime_mutation( + self.runtime_pane_split( "tui.pane.split", - crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams { + crate::api::schema::PaneSplitParams { workspace_id: None, target_pane_id: None, direction, @@ -481,41 +496,40 @@ impl App { cwd: None, focus: true, env: Default::default(), - }), + }, ); } - pub(crate) fn close_focused_pane_via_api(&mut self) { + pub(crate) fn close_focused_pane_via_api_requires_confirmation(&mut self) -> bool { let Some((ws_idx, pane_id)) = self.focused_pane_target() else { - return; + return false; }; let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { - return; + return false; }; self.runtime_pane_close("tui.pane.close", pane_id); + self.state.mode == Mode::ConfirmClose } pub(crate) fn zoom_focused_pane_via_api(&mut self) { - self.dispatch_runtime_mutation( + self.runtime_pane_zoom( "tui.pane.zoom", - crate::api::schema::Method::PaneZoom(crate::api::schema::PaneZoomParams { + crate::api::schema::PaneZoomParams { pane_id: None, mode: crate::api::schema::PaneZoomMode::Toggle, - }), + }, ); } pub(crate) fn set_split_ratio_via_api(&mut self, path: Vec, ratio: f32) { - self.dispatch_runtime_mutation( + self.runtime_layout_set_split_ratio( "tui.layout.set_split_ratio", - crate::api::schema::Method::LayoutSetSplitRatio( - crate::api::schema::LayoutSetSplitRatioParams { - tab_id: None, - pane_id: None, - path, - ratio, - }, - ), + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: None, + pane_id: None, + path, + ratio, + }, ); } @@ -1039,6 +1053,7 @@ pub(crate) fn command_for_key( .cloned() } +#[cfg(test)] pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKey) -> bool { let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); if modifiers.is_empty() { @@ -1171,7 +1186,7 @@ pub(super) fn api_pane_direction(direction: NavDirection) -> crate::api::schema: } } -#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key +#[cfg(test)] pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { let mut terminal_runtimes = TerminalRuntimeRegistry::new(); state.update_dismissed = true; @@ -1381,6 +1396,7 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi ); } +#[cfg(test)] pub(super) fn execute_navigate_action_in_context( state: &mut AppState, terminal_runtimes: &mut TerminalRuntimeRegistry, @@ -1716,6 +1732,22 @@ mod tests { }); } + fn app_with_test_workspaces(names: &[&str]) -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); + app.state.ensure_test_terminals(); + app.state.active = (!app.state.workspaces.is_empty()).then_some(0); + app.state.selected = 0; + app + } + #[test] fn default_goto_key_opens_navigator() { let mut state = state_with_workspaces(&["test"]); @@ -2604,6 +2636,38 @@ last_pane = "prefix+tab" assert_eq!(state.workspaces.len(), 2); } + #[test] + fn tui_close_tab_last_parent_group_workspace_opens_confirmation_via_api() { + 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 = 1; + app.state.mode = Mode::Navigate; + + app.execute_tui_navigate_action(NavigateAction::CloseTab, ActionContext::Navigate); + + assert_eq!(app.state.selected, 0); + assert_eq!(app.state.mode, Mode::ConfirmClose); + assert_eq!(app.state.workspaces.len(), 2); + } + + #[test] + fn tui_close_pane_last_parent_group_pane_opens_confirmation_via_api() { + 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 = 1; + app.state.mode = Mode::Navigate; + + app.execute_tui_navigate_action(NavigateAction::ClosePane, ActionContext::Navigate); + + assert_eq!(app.state.selected, 0); + assert_eq!(app.state.mode, Mode::ConfirmClose); + assert_eq!(app.state.workspaces.len(), 2); + } + #[cfg(unix)] #[tokio::test] async fn custom_command_runs_from_prefix_key_in_navigate_mode() { diff --git a/src/app/runtime_mutations.rs b/src/app/runtime_mutations.rs index 4a94593c..99cd3623 100644 --- a/src/app/runtime_mutations.rs +++ b/src/app/runtime_mutations.rs @@ -1,4 +1,8 @@ -use crate::api::schema::{Method, PaneTarget, TabTarget, WorkspaceTarget}; +use crate::api::schema::{ + LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneRenameParams, + PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomParams, TabTarget, + WorkspaceTarget, +}; use super::App; @@ -46,4 +50,52 @@ impl App { pub(crate) fn runtime_pane_close(&mut self, id: &'static str, pane_id: String) -> String { self.dispatch_runtime_mutation(id, Method::PaneClose(PaneTarget { pane_id })) } + + pub(crate) fn runtime_pane_rename( + &mut self, + id: &'static str, + params: PaneRenameParams, + ) -> String { + self.dispatch_runtime_mutation(id, Method::PaneRename(params)) + } + + pub(crate) fn runtime_pane_focus_direction( + &mut self, + id: &'static str, + params: PaneFocusDirectionParams, + ) -> String { + self.dispatch_runtime_mutation(id, Method::PaneFocusDirection(params)) + } + + pub(crate) fn runtime_pane_resize( + &mut self, + id: &'static str, + params: PaneResizeParams, + ) -> String { + self.dispatch_runtime_mutation(id, Method::PaneResize(params)) + } + + pub(crate) fn runtime_pane_swap(&mut self, id: &'static str, params: PaneSwapParams) -> String { + self.dispatch_runtime_mutation(id, Method::PaneSwap(params)) + } + + pub(crate) fn runtime_pane_split( + &mut self, + id: &'static str, + params: PaneSplitParams, + ) -> String { + self.dispatch_runtime_mutation(id, Method::PaneSplit(params)) + } + + pub(crate) fn runtime_pane_zoom(&mut self, id: &'static str, params: PaneZoomParams) -> String { + self.dispatch_runtime_mutation(id, Method::PaneZoom(params)) + } + + pub(crate) fn runtime_layout_set_split_ratio( + &mut self, + id: &'static str, + params: LayoutSetSplitRatioParams, + ) -> String { + self.dispatch_runtime_mutation(id, Method::LayoutSetSplitRatio(params)) + } } diff --git a/src/logging.rs b/src/logging.rs index b3f2f834..ff85799b 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -278,6 +278,7 @@ pub(crate) fn tab_focused(workspace_id: &str, tab_id: &str) { ); } +#[cfg(test)] pub(crate) fn tab_closed(workspace_id: &str, tab_id: &str) { tracing::info!( event = "tab.close", diff --git a/src/workspace.rs b/src/workspace.rs index 8216001b..f6060338 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -593,10 +593,12 @@ impl Workspace { true } + #[cfg(test)] pub fn close_active_tab(&mut self) -> bool { self.close_tab(self.active_tab) } + #[cfg(test)] pub fn split_focused( &mut self, direction: Direction, @@ -873,6 +875,7 @@ impl Workspace { } /// Close the focused pane. Returns true if the workspace should close. + #[cfg(test)] pub fn close_focused(&mut self) -> bool { let pane_count = self .active_tab() @@ -1156,6 +1159,7 @@ impl Workspace { self.public_pane_numbers.remove(&pane_id); } + #[cfg(test)] fn close_active_tab_and_report(&mut self) -> bool { if self.tabs.len() <= 1 { return true; diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 3a339e57..d1b39ff4 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -416,6 +416,7 @@ impl Tab { }) } + #[cfg(test)] pub fn close_focused(&mut self) -> Option { let pane_id = self.layout.focused(); self.detach_pane(pane_id)