aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs2
-rw-r--r--src/app/input/modal.rs148
-rw-r--r--src/app/input/mouse.rs114
-rw-r--r--src/app/input/navigate.rs144
4 files changed, 340 insertions, 68 deletions
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();
@@ -3240,6 +3251,109 @@ mod tests {
}
#[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();
let mut ws = Workspace::test_new("one");
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<bool>, 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() {