refactor: route pane layout tui actions through runtime wrappers
This commit is contained in:
parent
797981c7e9
commit
edd08e895f
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -416,6 +416,7 @@ impl Tab {
|
|||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn close_focused(&mut self) -> Option<DetachedPane> {
|
||||
let pane_id = self.layout.focused();
|
||||
self.detach_pane(pane_id)
|
||||
|
|
|
|||
Loading…
Reference in New Issue