diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/app/input/navigate.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/app/input/navigate.rs')
| -rw-r--r-- | src/app/input/navigate.rs | 3898 |
1 files changed, 0 insertions, 3898 deletions
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs deleted file mode 100644 index 517e267d..00000000 --- a/src/app/input/navigate.rs +++ /dev/null @@ -1,3898 +0,0 @@ -use std::{ - fs, io, - io::Write, - process::Stdio, - time::{SystemTime, UNIX_EPOCH}, -}; - -use bytes::Bytes; -use crossterm::event::KeyCode; -#[cfg(test)] -use crossterm::event::KeyEvent; -use ratatui::layout::Direction; - -use crate::{ - app::{ - state::{AppState, Mode}, - App, - }, - input::TerminalKey, - layout::NavDirection, - terminal::TerminalRuntimeRegistry, -}; - -#[cfg(test)] -pub(crate) fn terminal_direct_navigation_action( - state: &AppState, - key: TerminalKey, -) -> Option<NavigateAction> { - action_for_key(state, key, BindingDispatch::Direct) -} - -pub(crate) fn terminal_direct_non_indexed_navigation_action( - state: &AppState, - key: &TerminalKey, -) -> Option<NavigateAction> { - non_indexed_action_for_key(state, key, BindingDispatch::Direct) -} - -pub(crate) fn terminal_direct_indexed_navigation_action( - state: &AppState, - key: &TerminalKey, -) -> Option<NavigateAction> { - indexed_navigation_action(state, key, BindingDispatch::Direct) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ActionContext { - Direct, - Prefix, - Navigate, -} - -impl App { - fn cancel_copy_mode_if_active(&mut self) { - if self.state.copy_mode.is_some() { - self.state.cancel_copy_mode(&self.terminal_runtimes); - } - } - - pub(crate) fn handle_prefix_key(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - self.state.update_dismissed = true; - - if matches!(key.code, KeyCode::Modifier(_)) { - return; - } - - if self.state.is_prefix_key(&raw_key) { - if self.state.copy_mode_pane_is_focused() { - self.state.cancel_copy_mode(&self.terminal_runtimes); - } - if !self.pass_through_key_to_focused_pane(raw_key) { - leave_command_mode(&mut self.state); - } - return; - } - - if key.code == KeyCode::Esc { - leave_command_mode(&mut self.state); - return; - } - - match prefix_binding_for_key(&self.state, &raw_key) { - Some(PrefixBindingMatch::Action(action)) => self.execute_prefix_key_action(action), - Some(PrefixBindingMatch::Command(binding)) => { - self.cancel_copy_mode_if_active(); - self.launch_custom_command(binding, ActionContext::Prefix); - } - None => leave_command_mode(&mut self.state), - } - } - - fn execute_prefix_key_action(&mut self, action: NavigateAction) { - if action == NavigateAction::EditScrollback { - let previous_mode = self.state.mode; - self.cancel_copy_mode_if_active(); - self.launch_focused_scrollback_editor(); - finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); - } else if action == NavigateAction::CopyMode { - self.cancel_copy_mode_if_active(); - self.execute_tui_navigate_action(action, ActionContext::Prefix); - } else if copy_mode_survives_prefix_action(action) { - self.execute_tui_navigate_action(action, ActionContext::Prefix); - if self.state.copy_mode.is_some() { - self.state.sync_copy_mode_with_focus(); - } - } else { - self.cancel_copy_mode_if_active(); - self.execute_tui_navigate_action(action, ActionContext::Prefix); - } - self.selection_autoscroll_deadline = None; - } - - pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - self.state.update_dismissed = true; - - if key.code == KeyCode::Esc || self.state.is_prefix_key(&raw_key) { - leave_navigate_mode(&mut self.state); - return; - } - - if self - .state - .keybinds - .navigate - .workspace_up - .matches_direct_key(&raw_key) - { - self.state.move_selected_workspace_by_visible_delta(-1); - return; - } - if self - .state - .keybinds - .navigate - .workspace_down - .matches_direct_key(&raw_key) - { - self.state.move_selected_workspace_by_visible_delta(1); - return; - } - - if let Some(action) = navigate_reserved_action_for_key(&self.state, &raw_key) { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - return; - } - - if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, &raw_key) { - if action == NavigateAction::EditScrollback { - self.launch_focused_scrollback_editor(); - } else { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - } - self.selection_autoscroll_deadline = None; - return; - } - - if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { - self.launch_custom_command(binding, ActionContext::Navigate); - return; - } - - if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, &raw_key) { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - self.selection_autoscroll_deadline = None; - } - } - - pub(super) fn execute_tui_navigate_action( - &mut self, - action: NavigateAction, - context: ActionContext, - ) { - let previous_mode = self.state.mode; - match action { - NavigateAction::NewWorkspace => { - self.begin_tui_workspace_create("tui.key.workspace.create"); - } - NavigateAction::NewWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { - workspace_can_start_worktree_action(&self.state, &self.terminal_runtimes, *idx) - }) { - self.state.request_new_linked_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::OpenWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { - workspace_can_start_worktree_action(&self.state, &self.terminal_runtimes, *idx) - }) { - self.state.request_open_existing_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RemoveWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - self.state.request_remove_linked_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RenameWorkspace => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - super::modal::open_rename_workspace( - &mut self.state, - &self.terminal_runtimes, - ws_idx, - ); - } - } - NavigateAction::CloseWorkspace => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - self.state.selected = ws_idx; - if self.state.confirm_close { - super::modal::open_confirm_close(&mut self.state); - } else { - self.close_workspace_idx_with_group_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - } - NavigateAction::SwitchWorkspace(idx) => { - if let Some(ws_idx) = self.state.workspace_at_visible_position(idx) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::SwitchTab(idx) => { - if self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx)) - .is_some_and(|ws| idx < ws.tabs.len()) - { - self.focus_tab_idx_via_api(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::FocusAgent(idx) => { - if let Some((ws_idx, pane_id)) = self.agent_entry_target(idx) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::WorkspacePicker => { - self.state.mobile_switcher_scroll = 0; - self.state.mode = Mode::Navigate; - } - NavigateAction::PreviousWorkspace => { - if let Some(ws_idx) = self.relative_visible_workspace(-1) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextWorkspace => { - if let Some(ws_idx) = self.relative_visible_workspace(1) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::PreviousAgent => { - if let Some((idx, ws_idx, pane_id)) = self.relative_agent_entry(false) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextAgent => { - if let Some((idx, ws_idx, pane_id)) = self.relative_agent_entry(true) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NewTab => { - if self.state.active.is_some() { - if self.state.prompt_new_tab_name { - super::modal::open_new_tab_dialog(&mut self.state); - } else { - self.runtime_tab_create( - "tui.key.tab.create", - crate::api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - leave_navigate_mode(&mut self.state); - } - } - } - NavigateAction::RenameTab => { - super::modal::open_rename_active_tab(&mut self.state, false) - } - NavigateAction::PreviousTab => { - if let Some(tab_idx) = self.relative_tab(-1) { - self.focus_tab_idx_via_api(tab_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextTab => { - if let Some(tab_idx) = self.relative_tab(1) { - self.focus_tab_idx_via_api(tab_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::MoveTabPrevious => { - if let Some((ws_idx, source, insert)) = self.active_tab_move(-1) { - self.move_tab_via_api(ws_idx, source, insert); - } - leave_navigate_mode(&mut self.state); - } - NavigateAction::MoveTabNext => { - if let Some((ws_idx, source, insert)) = self.active_tab_move(1) { - self.move_tab_via_api(ws_idx, source, insert); - } - leave_navigate_mode(&mut self.state); - } - NavigateAction::CloseTab => { - if !self.close_active_tab_via_api_requires_confirmation() { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RenamePane => { - if let Some(pane_id) = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx)) - .and_then(|ws| ws.focused_pane_id()) - { - super::modal::open_rename_pane(&mut self.state, pane_id); - } - } - NavigateAction::FocusPaneLeft => { - self.focus_pane_direction_in_context(NavDirection::Left, context) - } - NavigateAction::FocusPaneDown => { - self.focus_pane_direction_in_context(NavDirection::Down, context) - } - NavigateAction::FocusPaneUp => { - self.focus_pane_direction_in_context(NavDirection::Up, context) - } - NavigateAction::FocusPaneRight => { - self.focus_pane_direction_in_context(NavDirection::Right, context) - } - NavigateAction::SwapPaneLeft => { - self.swap_pane_direction_via_api(NavDirection::Left); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneDown => { - self.swap_pane_direction_via_api(NavDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneUp => { - self.swap_pane_direction_via_api(NavDirection::Up); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneRight => { - self.swap_pane_direction_via_api(NavDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SplitVertical => { - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SplitHorizontal => { - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ClosePane => { - 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), - NavigateAction::Zoom => { - self.zoom_focused_pane_via_api(); - leave_navigate_mode(&mut self.state); - } - NavigateAction::EnterResizeMode => self.state.mode = Mode::Resize, - NavigateAction::ResizePaneLeft => { - self.resize_pane_direction_via_api(NavDirection::Left); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneDown => { - self.resize_pane_direction_via_api(NavDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneUp => { - self.resize_pane_direction_via_api(NavDirection::Up); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneRight => { - self.resize_pane_direction_via_api(NavDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ToggleSidebar => { - self.state.sidebar_collapsed = !self.state.sidebar_collapsed; - leave_navigate_mode(&mut self.state); - } - NavigateAction::CyclePaneNext => { - self.cycle_pane_via_api(false); - leave_navigate_mode(&mut self.state); - } - NavigateAction::CyclePanePrevious => { - self.cycle_pane_via_api(true); - leave_navigate_mode(&mut self.state); - } - NavigateAction::LastPane => { - self.last_pane_via_api(); - leave_navigate_mode(&mut self.state); - } - NavigateAction::Help => super::modal::open_keybind_help(&mut self.state), - NavigateAction::Settings => super::settings::open_settings(&mut self.state), - NavigateAction::ReloadConfig => { - self.runtime_server_reload_config("tui.server.reload_config"); - leave_navigate_mode(&mut self.state); - } - NavigateAction::OpenNotificationTarget => { - self.focus_toast_target_via_api(); - if self.state.mode == Mode::Navigate { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::Detach => { - super::modal::request_detach(&mut self.state); - leave_navigate_mode(&mut self.state); - } - NavigateAction::OpenNavigator => { - self.state.open_navigator_from(&self.terminal_runtimes) - } - } - - finish_action_context(&mut self.state, context, previous_mode); - } - - pub(crate) fn focus_workspace_idx_via_api(&mut self, ws_idx: usize) { - let workspace_id = self.public_workspace_id(ws_idx); - self.runtime_workspace_focus("tui.workspace.focus", workspace_id); - } - - pub(crate) fn close_workspace_idx_with_group_via_api(&mut self, ws_idx: usize) { - let workspace_id = self.public_workspace_id(ws_idx); - self.runtime_workspace_close_group("tui.workspace.close", workspace_id); - } - - pub(crate) fn move_workspace_via_api(&mut self, source_ws_idx: usize, insert_idx: usize) { - let workspace_id = self.public_workspace_id(source_ws_idx); - self.runtime_workspace_move( - "tui.workspace.move", - crate::api::schema::WorkspaceMoveParams { - workspace_id, - insert_index: insert_idx, - }, - ); - } - - pub(crate) fn move_workspace_block_via_api( - &mut self, - params: crate::api::schema::WorkspaceMoveBlockParams, - ) { - self.runtime_workspace_move_block("tui.workspace.move_block", params); - } - - pub(crate) fn focus_tab_idx_via_api(&mut self, tab_idx: usize) { - let Some(ws_idx) = self.state.active else { - return; - }; - let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { - return; - }; - self.runtime_tab_focus("tui.tab.focus", tab_id); - } - - pub(crate) fn close_active_tab_via_api_requires_confirmation(&mut self) -> bool { - let Some(ws_idx) = self.state.active else { - 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_with_group_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 false; - }; - self.runtime_tab_close("tui.tab.close", tab_id); - false - } - - pub(crate) fn move_tab_via_api( - &mut self, - ws_idx: usize, - source_tab_idx: usize, - insert_idx: usize, - ) { - let Some(tab_id) = self.public_tab_id(ws_idx, source_tab_idx) else { - return; - }; - self.runtime_tab_move( - "tui.tab.move", - crate::api::schema::TabMoveParams { - tab_id, - insert_index: insert_idx, - }, - ); - } - - pub(crate) fn focus_pane_internal_via_api( - &mut self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - ) { - let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { - return; - }; - self.runtime_pane_focus("tui.pane.focus", pane_id); - } - - pub(crate) fn focus_pane_direction_via_api(&mut self, direction: NavDirection) { - if let Some((ws_idx, target)) = self.directional_pane_target_from_view(direction) { - self.focus_pane_internal_via_api(ws_idx, target); - return; - } - self.runtime_pane_focus_direction( - "tui.pane.focus_direction", - crate::api::schema::PaneFocusDirectionParams { - pane_id: None, - direction: api_pane_direction(direction), - }, - ); - } - - fn focus_pane_direction_in_context(&mut self, direction: NavDirection, context: ActionContext) { - let preserve_navigate_mode = - context == ActionContext::Navigate && self.state.mode == Mode::Navigate; - self.focus_pane_direction_via_api(direction); - if preserve_navigate_mode { - self.state.mode = Mode::Navigate; - } - } - - pub(crate) fn resize_pane_direction_via_api(&mut self, direction: NavDirection) { - self.runtime_pane_resize( - "tui.pane.resize", - crate::api::schema::PaneResizeParams { - pane_id: None, - direction: api_pane_direction(direction), - amount: None, - }, - ); - } - - pub(crate) fn swap_pane_direction_via_api(&mut self, direction: NavDirection) { - if let Some((ws_idx, source, target)) = self.directional_pane_swap_from_view(direction) { - 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.runtime_pane_swap( - "tui.pane.swap_exact", - 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.runtime_pane_swap( - "tui.pane.swap", - crate::api::schema::PaneSwapParams { - pane_id: None, - direction: Some(api_pane_direction(direction)), - source_pane_id: None, - target_pane_id: None, - }, - ); - } - - pub(crate) fn split_focused_pane_via_api( - &mut self, - direction: crate::api::schema::SplitDirection, - ) { - self.runtime_pane_split( - "tui.pane.split", - crate::api::schema::PaneSplitParams { - workspace_id: None, - target_pane_id: None, - direction, - ratio: None, - cwd: None, - focus: true, - right_click: Default::default(), - env: Default::default(), - }, - ); - } - - 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 false; - }; - let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { - 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.runtime_pane_zoom( - "tui.pane.zoom", - 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.runtime_layout_set_split_ratio( - "tui.layout.set_split_ratio", - crate::api::schema::LayoutSetSplitRatioParams { - tab_id: None, - pane_id: None, - path, - ratio, - }, - ); - } - - pub(crate) fn cycle_pane_via_api(&mut self, reverse: bool) { - let Some((ws_idx, pane_id)) = self.focused_pane_target() else { - return; - }; - let Some(tab) = self.state.workspaces[ws_idx].active_tab() else { - return; - }; - let ids = tab.layout.pane_ids(); - let Some(pos) = ids.iter().position(|id| *id == pane_id) else { - return; - }; - let target = if reverse { - ids[(pos + ids.len() - 1) % ids.len()] - } else { - ids[(pos + 1) % ids.len()] - }; - self.focus_pane_internal_via_api(ws_idx, target); - } - - pub(crate) fn last_pane_via_api(&mut self) { - let Some(target) = self.state.previous_pane_focus.clone() else { - return; - }; - let Some((ws_idx, _tab_idx)) = self.state.pane_focus_target_indices(&target) else { - self.state.previous_pane_focus = None; - return; - }; - if self.state.current_pane_focus_target().as_ref() == Some(&target) { - self.state.previous_pane_focus = None; - return; - } - self.focus_pane_internal_via_api(ws_idx, target.pane_id); - } - - pub(crate) fn focus_toast_target_via_api(&mut self) { - let Some(target) = self - .state - .toast - .as_ref() - .and_then(|toast| toast.target.clone()) - else { - return; - }; - let Some(ws_idx) = self - .state - .workspaces - .iter() - .position(|workspace| workspace.id == target.workspace_id) - else { - return; - }; - self.focus_pane_internal_via_api(ws_idx, target.pane_id); - self.state.toast = None; - self.state.mode = Mode::Terminal; - } - - fn focused_pane_target(&self) -> Option<(usize, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; - Some((ws_idx, pane_id)) - } - - fn directional_pane_target_from_view( - &self, - direction: NavDirection, - ) -> Option<(usize, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let focused = self - .state - .view - .pane_infos - .iter() - .find(|pane| pane.is_focused)?; - let target = - crate::layout::find_in_direction(focused, direction, &self.state.view.pane_infos)?; - Some((ws_idx, target)) - } - - fn directional_pane_swap_from_view( - &self, - direction: NavDirection, - ) -> Option<(usize, crate::layout::PaneId, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let focused = self - .state - .view - .pane_infos - .iter() - .find(|pane| pane.is_focused)?; - let target = - crate::layout::find_in_direction(focused, direction, &self.state.view.pane_infos)?; - Some((ws_idx, focused.id, target)) - } - - fn relative_visible_workspace(&self, delta: isize) -> Option<usize> { - let order = self.state.visible_workspace_order(); - if order.is_empty() { - return None; - } - let current = self.state.active.unwrap_or(self.state.selected); - let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); - let next = (current_pos as isize + delta).rem_euclid(order.len() as isize) as usize; - order.get(next).copied() - } - - fn active_tab_move(&self, delta: isize) -> Option<(usize, usize, usize)> { - let ws_idx = self.state.active?; - let ws = self.state.workspaces.get(ws_idx)?; - let source = ws.active_tab; - let insert = tab_move_insert_index(ws.tabs.len(), source, delta)?; - Some((ws_idx, source, insert)) - } - - fn relative_tab(&self, delta: isize) -> Option<usize> { - let ws = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx))?; - if ws.tabs.is_empty() { - return None; - } - Some((ws.active_tab as isize + delta).rem_euclid(ws.tabs.len() as isize) as usize) - } - - fn agent_entry_target(&self, idx: usize) -> Option<(usize, crate::layout::PaneId)> { - let entries = crate::ui::agent_panel_entries(&self.state); - let target = entries.get(idx)?; - Some((target.ws_idx, target.pane_id)) - } - - fn relative_agent_entry(&self, forward: bool) -> Option<(usize, usize, crate::layout::PaneId)> { - let entries = crate::ui::agent_panel_entries(&self.state); - if entries.is_empty() { - return None; - } - let focused = self - .state - .active - .and_then(|idx| self.state.workspaces.get(idx)) - .and_then(crate::workspace::Workspace::focused_pane_id); - let current_idx = entries - .iter() - .position(|entry| Some(entry.pane_id) == focused); - let next_idx = match (current_idx, forward) { - (Some(idx), true) => (idx + 1) % entries.len(), - (Some(0), false) => entries.len() - 1, - (Some(idx), false) => idx - 1, - (None, true) => 0, - (None, false) => entries.len() - 1, - }; - let target = entries.get(next_idx)?; - Some((next_idx, target.ws_idx, target.pane_id)) - } - - fn pass_through_key_to_focused_pane(&mut self, key: TerminalKey) -> bool { - let Some(ws_idx) = self.state.active else { - return false; - }; - let Some(rt) = self - .state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - else { - return false; - }; - - let bytes = rt.encode_terminal_key(key.clone()); - if bytes.is_empty() || rt.try_send_bytes(Bytes::from(bytes)).is_err() { - return false; - } - - self.state.mode = Mode::Terminal; - true - } - - pub(super) fn launch_custom_command( - &mut self, - binding: crate::config::CustomCommandKeybind, - context: ActionContext, - ) { - let previous_mode = self.state.mode; - let previous_toast = self.state.toast.clone(); - let result = match binding.action { - crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding), - crate::config::CustomCommandAction::Pane => { - self.spawn_pane_command(&binding.command, Vec::new()) - } - crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(&binding), - crate::config::CustomCommandAction::PluginAction => self - .invoke_plugin_action_from_keybind(binding.command.clone()) - .map_err(std::io::Error::other), - }; - match result { - Ok(()) => finish_custom_command_context(&mut self.state, context, previous_mode), - Err(err) => { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "custom command failed".to_string(), - context: err.to_string(), - position: None, - target: None, - }); - self.sync_toast_deadline(previous_toast); - finish_custom_command_context(&mut self.state, context, previous_mode); - } - } - } - - fn spawn_custom_popup_command( - &mut self, - binding: &crate::config::CustomCommandKeybind, - ) -> io::Result<()> { - self.spawn_popup_shell_command( - &binding.command, - None, - self.custom_command_env().0, - crate::app::popup::PopupGeometry { - width: binding.width, - height: binding.height, - }, - ) - } - - pub(crate) fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) { - let mut env = vec![( - crate::api::SOCKET_PATH_ENV_VAR.to_string(), - crate::api::socket_path().display().to_string(), - )]; - if let Ok(current_exe) = std::env::current_exe() { - env.push(( - "HERDR_BIN_PATH".to_string(), - current_exe.display().to_string(), - )); - } - - let mut cwd = None; - if let Some(ws_idx) = self.state.active { - env.push(( - "HERDR_ACTIVE_WORKSPACE_ID".to_string(), - self.public_workspace_id(ws_idx), - )); - if let Some(workspace) = self.state.workspaces.get(ws_idx) { - let tab_idx = workspace.active_tab_index(); - if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { - env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id)); - } - if let Some(pane_id) = workspace.focused_pane_id() { - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); - } - if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { - tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) - }) { - env.push(( - "HERDR_ACTIVE_PANE_CWD".to_string(), - pane_cwd.display().to_string(), - )); - if pane_cwd.is_dir() { - cwd = Some(pane_cwd); - } - } - } - } - } - (env, cwd) - } - - fn spawn_custom_command( - &mut self, - binding: &crate::config::CustomCommandKeybind, - ) -> std::io::Result<()> { - let mut command = crate::platform::detached_custom_command_process(&binding.command); - command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - let (env, cwd) = self.custom_command_env(); - command.envs(env); - if let Some(cwd) = cwd { - command.current_dir(cwd); - } - let child = command.spawn()?; - self.detached_process_children.push(child); - Ok(()) - } - - pub(super) fn launch_focused_scrollback_editor(&mut self) { - let previous_toast = self.state.toast.clone(); - match self.open_focused_scrollback_in_editor() { - Ok(()) => self.sync_toast_deadline(previous_toast), - Err(err) => { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "edit scrollback failed".to_string(), - context: err.to_string(), - position: None, - target: None, - }); - self.sync_toast_deadline(previous_toast); - } - } - } - - fn open_focused_scrollback_in_editor(&mut self) -> std::io::Result<()> { - let ws_idx = self - .state - .active - .ok_or_else(|| std::io::Error::other("no active workspace"))?; - let ws = self - .state - .workspaces - .get(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let pane_id = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let scrollback = self - .state - .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) - .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? - .recent_unwrapped_text_snapshot(usize::MAX) - .text; - - let path = write_scrollback_temp_file(&scrollback)?; - - let argv = match crate::platform::scrollback_editor_argv(&path) { - Ok(argv) => argv, - Err(err) => { - let _ = fs::remove_file(&path); - return Err(err); - } - }; - let (env, _) = self.custom_command_env(); - let new_pane = match self.spawn_overlay_argv_command(&argv, None, env, vec![path.clone()]) { - Ok((_, new_pane)) => new_pane, - Err(err) => { - let _ = fs::remove_file(&path); - return Err(err); - } - }; - let terminal_id = new_pane.terminal.id.clone(); - self.terminal_runtimes - .insert(terminal_id.clone(), new_pane.runtime); - self.state - .remove_alias_shadowed_by_new_pane(new_pane.pane_id); - self.state.terminals.insert(terminal_id, new_pane.terminal); - - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "opened scrollback".to_string(), - context: format!("focused pane {public_pane_id}"), - position: None, - target: None, - }); - } - Ok(()) - } - - fn spawn_pane_command( - &mut self, - command: &str, - temp_files: Vec<std::path::PathBuf>, - ) -> std::io::Result<()> { - let Some(ws_idx) = self.state.active else { - return Err(std::io::Error::other("no active workspace")); - }; - let previous_focus_target = self.state.current_pane_focus_target(); - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); - let (env, _) = self.custom_command_env(); - - let ws = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let tab_idx = ws.active_tab_index(); - let previous_focus = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let cwd = ws.active_tab().and_then(|tab| { - tab.cwd_for_pane( - previous_focus, - &self.state.terminals, - &self.terminal_runtimes, - ) - }); - let new_pane = ws.split_focused_command( - Direction::Horizontal, - new_rows, - new_cols, - cwd, - command, - env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - )?; - let new_pane_id = new_pane.pane_id; - self.terminal_runtimes - .insert(new_pane.terminal.id.clone(), new_pane.runtime); - self.state - .terminals - .insert(new_pane.terminal.id.clone(), new_pane.terminal); - let new_focus_target = crate::app::state::PaneFocusTarget { - workspace_id: ws.id.clone(), - pane_id: new_pane_id, - }; - if previous_focus_target.as_ref() != Some(&new_focus_target) { - self.state.previous_pane_focus = previous_focus_target; - } - ws.active_tab_mut() - .expect("workspace must have an active tab") - .layout - .focus_pane(new_pane_id); - ws.active_tab_mut() - .expect("workspace must have an active tab") - .zoomed = true; - self.overlay_panes.insert( - new_pane_id, - super::super::OverlayPaneState { - ws_idx, - tab_idx, - previous_focus, - previous_zoomed, - temp_files, - }, - ); - self.state.remove_alias_shadowed_by_new_pane(new_pane_id); - self.state.mode = Mode::Terminal; - Ok(()) - } - - pub(crate) fn spawn_overlay_argv_command( - &mut self, - argv: &[String], - cwd: Option<std::path::PathBuf>, - extra_env: Vec<(String, String)>, - temp_files: Vec<std::path::PathBuf>, - ) -> std::io::Result<(usize, crate::workspace::NewPane)> { - let Some(ws_idx) = self.state.active else { - return Err(std::io::Error::other("no active workspace")); - }; - let previous_focus_target = self.state.current_pane_focus_target(); - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); - - let ws = self - .state - .workspaces - .get(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let previous_focus = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let cwd = cwd.or_else(|| { - ws.active_tab().and_then(|tab| { - tab.cwd_for_pane( - previous_focus, - &self.state.terminals, - &self.terminal_runtimes, - ) - }) - }); - - let (tab_idx, new_pane, workspace_id) = { - let ws = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let result = ws.split_pane_argv_command( - previous_focus, - Direction::Horizontal, - new_rows, - new_cols, - cwd, - argv, - extra_env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - true, - ); - let (tab_idx, new_pane) = match result { - Some(Ok(result)) => result, - Some(Err(err)) => return Err(err), - None => return Err(std::io::Error::other("focused pane disappeared")), - }; - ws.tabs - .get_mut(tab_idx) - .ok_or_else(|| std::io::Error::other("plugin overlay tab disappeared"))? - .zoomed = true; - self.overlay_panes.insert( - new_pane.pane_id, - super::super::OverlayPaneState { - ws_idx, - tab_idx, - previous_focus, - previous_zoomed, - temp_files, - }, - ); - (tab_idx, new_pane, ws.id.clone()) - }; - - let new_focus_target = crate::app::state::PaneFocusTarget { - workspace_id, - pane_id: new_pane.pane_id, - }; - if previous_focus_target.as_ref() != Some(&new_focus_target) { - self.state.previous_pane_focus = previous_focus_target; - } - self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.mode = Mode::Terminal; - Ok((ws_idx, new_pane)) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum BindingDispatch { - Direct, - Prefix, -} - -enum PrefixBindingMatch { - Action(NavigateAction), - Command(crate::config::CustomCommandKeybind), -} - -fn prefix_binding_for_key(state: &AppState, key: &TerminalKey) -> Option<PrefixBindingMatch> { - exact_prefix_binding_for_key(state, key).or_else(|| { - generated_character_key(key) - .as_ref() - .and_then(|generated_key| exact_prefix_binding_for_key(state, generated_key)) - }) -} - -fn exact_prefix_binding_for_key(state: &AppState, key: &TerminalKey) -> Option<PrefixBindingMatch> { - non_indexed_action_for_key(state, key, BindingDispatch::Prefix) - .map(PrefixBindingMatch::Action) - .or_else(|| { - command_for_key(state, key, BindingDispatch::Prefix).map(PrefixBindingMatch::Command) - }) - .or_else(|| { - indexed_navigation_action(state, key, BindingDispatch::Prefix) - .map(PrefixBindingMatch::Action) - }) -} - -fn generated_character_key(key: &TerminalKey) -> Option<TerminalKey> { - let mut characters = key.generated_text.as_deref()?.chars(); - let character = characters.next()?; - if character.is_control() || characters.next().is_some() { - return None; - } - Some(TerminalKey::new( - KeyCode::Char(character), - crossterm::event::KeyModifiers::empty(), - )) -} - -pub(crate) fn command_for_key( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option<crate::config::CustomCommandKeybind> { - state - .keybinds - .custom_commands - .iter() - .find(|binding| match dispatch { - BindingDispatch::Direct => binding.bindings.matches_direct_key(key), - BindingDispatch::Prefix => binding.bindings.matches_prefix_key(key), - }) - .cloned() -} - -fn unmodified_digit_for_key(key: &TerminalKey) -> Option<char> { - ('1'..='9').find(|digit| { - crate::config::terminal_key_matches_combo( - key, - ( - KeyCode::Char(*digit), - crossterm::event::KeyModifiers::empty(), - ), - ) - }) -} - -#[cfg(test)] -pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKey) -> bool { - if let Some(c) = unmodified_digit_for_key(&key) { - let idx = (c as usize) - ('1' as usize); - if let Some(ws_idx) = state.workspace_at_visible_position(idx) { - state.switch_workspace(ws_idx); - leave_navigate_mode(state); - } - return true; - } - - let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); - if modifiers.is_empty() { - match code { - KeyCode::Enter => { - if !state.workspaces.is_empty() { - state.switch_workspace(state.selected); - leave_navigate_mode(state); - } - return true; - } - KeyCode::Tab => { - state.cycle_pane(false); - return true; - } - KeyCode::BackTab => { - state.cycle_pane(true); - return true; - } - KeyCode::Left => { - state.navigate_pane(NavDirection::Left); - return true; - } - KeyCode::Right => { - state.navigate_pane(NavDirection::Right); - return true; - } - _ => {} - } - } - - if state - .keybinds - .navigate - .workspace_up - .matches_direct_key(&key) - { - state.move_selected_workspace_by_visible_delta(-1); - return true; - } - if state - .keybinds - .navigate - .workspace_down - .matches_direct_key(&key) - { - state.move_selected_workspace_by_visible_delta(1); - return true; - } - if state.keybinds.navigate.pane_left.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Left); - return true; - } - if state.keybinds.navigate.pane_down.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Down); - return true; - } - if state.keybinds.navigate.pane_up.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Up); - return true; - } - if state.keybinds.navigate.pane_right.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Right); - return true; - } - - false -} - -fn navigate_reserved_action_for_key(state: &AppState, key: &TerminalKey) -> Option<NavigateAction> { - if let Some(c) = unmodified_digit_for_key(key) { - return Some(NavigateAction::SwitchWorkspace( - (c as usize) - ('1' as usize), - )); - } - - let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); - if modifiers.is_empty() { - match code { - KeyCode::Enter => { - return (!state.workspaces.is_empty()).then_some(NavigateAction::SwitchWorkspace( - state - .visible_workspace_order() - .iter() - .position(|idx| *idx == state.selected) - .unwrap_or(state.selected), - )); - } - KeyCode::Tab => return Some(NavigateAction::CyclePaneNext), - KeyCode::BackTab => return Some(NavigateAction::CyclePanePrevious), - KeyCode::Left => return Some(NavigateAction::FocusPaneLeft), - KeyCode::Right => return Some(NavigateAction::FocusPaneRight), - _ => {} - } - } - - if state.keybinds.navigate.workspace_up.matches_direct_key(key) - || state - .keybinds - .navigate - .workspace_down - .matches_direct_key(key) - { - return None; - } - if state.keybinds.navigate.pane_left.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneLeft); - } - if state.keybinds.navigate.pane_down.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneDown); - } - if state.keybinds.navigate.pane_up.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneUp); - } - if state.keybinds.navigate.pane_right.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneRight); - } - - None -} - -pub(super) fn api_pane_direction(direction: NavDirection) -> crate::api::schema::PaneDirection { - match direction { - NavDirection::Left => crate::api::schema::PaneDirection::Left, - NavDirection::Right => crate::api::schema::PaneDirection::Right, - NavDirection::Up => crate::api::schema::PaneDirection::Up, - NavDirection::Down => crate::api::schema::PaneDirection::Down, - } -} - -#[cfg(test)] -pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_dismissed = true; - let terminal_key = TerminalKey::from(key); - - if state.is_prefix_key(&terminal_key) || key.code == KeyCode::Esc { - leave_navigate_mode(state); - return; - } - - if handle_navigate_reserved_key(state, terminal_key.clone()) { - return; - } - - if let Some(action) = navigate_mode_action_for_key(state, terminal_key) { - execute_navigate_action_in_context( - state, - &mut terminal_runtimes, - action, - ActionContext::Navigate, - ); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NavigateAction { - NewWorkspace, - NewWorktree, - OpenWorktree, - RemoveWorktree, - RenameWorkspace, - CloseWorkspace, - SwitchWorkspace(usize), - SwitchTab(usize), - FocusAgent(usize), - WorkspacePicker, - PreviousWorkspace, - NextWorkspace, - PreviousAgent, - NextAgent, - NewTab, - RenameTab, - PreviousTab, - NextTab, - MoveTabPrevious, - MoveTabNext, - CloseTab, - RenamePane, - FocusPaneLeft, - FocusPaneDown, - FocusPaneUp, - FocusPaneRight, - SwapPaneLeft, - SwapPaneDown, - SwapPaneUp, - SwapPaneRight, - SplitVertical, - SplitHorizontal, - ClosePane, - EditScrollback, - CopyMode, - Zoom, - EnterResizeMode, - ResizePaneLeft, - ResizePaneDown, - ResizePaneUp, - ResizePaneRight, - ToggleSidebar, - CyclePaneNext, - CyclePanePrevious, - LastPane, - Help, - Settings, - ReloadConfig, - OpenNotificationTarget, - Detach, - OpenNavigator, -} - -fn copy_mode_survives_prefix_action(action: NavigateAction) -> bool { - matches!( - action, - NavigateAction::SwitchWorkspace(_) - | NavigateAction::SwitchTab(_) - | NavigateAction::FocusAgent(_) - | NavigateAction::PreviousWorkspace - | NavigateAction::NextWorkspace - | NavigateAction::PreviousAgent - | NavigateAction::NextAgent - | NavigateAction::PreviousTab - | NavigateAction::NextTab - | NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - | NavigateAction::CyclePaneNext - | NavigateAction::CyclePanePrevious - | NavigateAction::LastPane - | NavigateAction::OpenNotificationTarget - ) -} - -fn indexed_navigation_action( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option<NavigateAction> { - let kb = &state.keybinds; - let actual_modifiers = crate::config::normalize_key_combo((key.code, key.modifiers)).1; - - for exact_modifiers in [true, false] { - let trigger_matches = |binding: &crate::config::IndexedKeybind| { - let dispatch_matches = match dispatch { - BindingDispatch::Direct => binding.trigger.is_direct(), - BindingDispatch::Prefix => binding.trigger.is_prefix(), - }; - let expected_modifiers = crate::config::normalize_key_combo(binding.trigger.combo()).1; - dispatch_matches && (actual_modifiers == expected_modifiers) == exact_modifiers - }; - - for binding in &kb.switch_tab { - if trigger_matches(binding) { - if let Some(idx) = binding.matched_index(key) { - return Some(NavigateAction::SwitchTab(idx)); - } - } - } - for binding in &kb.switch_workspace { - if trigger_matches(binding) { - if let Some(idx) = binding.matched_index(key) { - return Some(NavigateAction::SwitchWorkspace(idx)); - } - } - } - for binding in &kb.focus_agent { - if trigger_matches(binding) { - if let Some(idx) = binding.matched_index(key) { - return Some(NavigateAction::FocusAgent(idx)); - } - } - } - } - - None -} - -fn action_matches( - bindings: &crate::config::ActionKeybinds, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> bool { - match dispatch { - BindingDispatch::Direct => bindings.matches_direct_key(key), - BindingDispatch::Prefix => bindings.matches_prefix_key(key), - } -} - -#[cfg(test)] -fn action_for_key( - state: &AppState, - key: TerminalKey, - dispatch: BindingDispatch, -) -> Option<NavigateAction> { - non_indexed_action_for_key(state, &key, dispatch) - .or_else(|| indexed_navigation_action(state, &key, dispatch)) -} - -fn non_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option<NavigateAction> { - let kb = &state.keybinds; - for (bindings, action) in [ - (&kb.help, NavigateAction::Help), - (&kb.settings, NavigateAction::Settings), - (&kb.workspace_picker, NavigateAction::WorkspacePicker), - (&kb.new_workspace, NavigateAction::NewWorkspace), - (&kb.new_worktree, NavigateAction::NewWorktree), - (&kb.open_worktree, NavigateAction::OpenWorktree), - (&kb.remove_worktree, NavigateAction::RemoveWorktree), - (&kb.rename_workspace, NavigateAction::RenameWorkspace), - (&kb.close_workspace, NavigateAction::CloseWorkspace), - (&kb.previous_workspace, NavigateAction::PreviousWorkspace), - (&kb.next_workspace, NavigateAction::NextWorkspace), - (&kb.previous_agent, NavigateAction::PreviousAgent), - (&kb.next_agent, NavigateAction::NextAgent), - (&kb.new_tab, NavigateAction::NewTab), - (&kb.rename_tab, NavigateAction::RenameTab), - (&kb.previous_tab, NavigateAction::PreviousTab), - (&kb.next_tab, NavigateAction::NextTab), - (&kb.move_tab_previous, NavigateAction::MoveTabPrevious), - (&kb.move_tab_next, NavigateAction::MoveTabNext), - (&kb.close_tab, NavigateAction::CloseTab), - (&kb.rename_pane, NavigateAction::RenamePane), - (&kb.edit_scrollback, NavigateAction::EditScrollback), - (&kb.copy_mode, NavigateAction::CopyMode), - (&kb.focus_pane_left, NavigateAction::FocusPaneLeft), - (&kb.focus_pane_down, NavigateAction::FocusPaneDown), - (&kb.focus_pane_up, NavigateAction::FocusPaneUp), - (&kb.focus_pane_right, NavigateAction::FocusPaneRight), - (&kb.swap_pane_left, NavigateAction::SwapPaneLeft), - (&kb.swap_pane_down, NavigateAction::SwapPaneDown), - (&kb.swap_pane_up, NavigateAction::SwapPaneUp), - (&kb.swap_pane_right, NavigateAction::SwapPaneRight), - (&kb.last_pane, NavigateAction::LastPane), - (&kb.cycle_pane_next, NavigateAction::CyclePaneNext), - (&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious), - (&kb.split_vertical, NavigateAction::SplitVertical), - (&kb.split_horizontal, NavigateAction::SplitHorizontal), - (&kb.close_pane, NavigateAction::ClosePane), - (&kb.zoom, NavigateAction::Zoom), - (&kb.resize_mode, NavigateAction::EnterResizeMode), - (&kb.resize_pane_left, NavigateAction::ResizePaneLeft), - (&kb.resize_pane_down, NavigateAction::ResizePaneDown), - (&kb.resize_pane_up, NavigateAction::ResizePaneUp), - (&kb.resize_pane_right, NavigateAction::ResizePaneRight), - (&kb.toggle_sidebar, NavigateAction::ToggleSidebar), - (&kb.reload_config, NavigateAction::ReloadConfig), - ( - &kb.open_notification_target, - NavigateAction::OpenNotificationTarget, - ), - (&kb.detach, NavigateAction::Detach), - (&kb.goto, NavigateAction::OpenNavigator), - ] { - if action_matches(bindings, key, dispatch) { - return Some(action); - } - } - None -} - -#[cfg(test)] -fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option<NavigateAction> { - let action = action_for_key(state, key, BindingDispatch::Prefix)?; - if matches!( - action, - NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - ) { - return None; - } - Some(action) -} - -fn navigate_mode_non_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, -) -> Option<NavigateAction> { - let action = non_indexed_action_for_key(state, key, BindingDispatch::Prefix)?; - if matches!( - action, - NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - ) { - return None; - } - Some(action) -} - -fn navigate_mode_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, -) -> Option<NavigateAction> { - indexed_navigation_action(state, key, BindingDispatch::Prefix) -} - -#[cfg(test)] -pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - execute_navigate_action_in_context( - state, - &mut terminal_runtimes, - action, - ActionContext::Navigate, - ); -} - -#[cfg(test)] -pub(super) fn execute_navigate_action_in_context( - state: &mut AppState, - terminal_runtimes: &mut TerminalRuntimeRegistry, - action: NavigateAction, - context: ActionContext, -) { - let previous_mode = state.mode; - match action { - NavigateAction::NewWorkspace => { - state.request_new_workspace = true; - leave_navigate_mode(state); - } - NavigateAction::NewWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) - .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) - { - state.request_new_linked_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::OpenWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) - .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) - { - state.request_open_existing_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::RemoveWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) { - state.request_remove_linked_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::RenameWorkspace => { - if let Some(ws_idx) = workspace_action_target(state, context) { - super::modal::open_rename_workspace(state, terminal_runtimes, ws_idx); - } - } - NavigateAction::CloseWorkspace => { - if let Some(ws_idx) = workspace_action_target(state, context) { - state.selected = ws_idx; - if state.confirm_close { - super::modal::open_confirm_close(state); - } else { - state.close_selected_workspace(); - leave_navigate_mode(state); - } - } - } - NavigateAction::SwitchWorkspace(idx) => { - if let Some(ws_idx) = state.workspace_at_visible_position(idx) { - state.switch_workspace(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::SwitchTab(idx) => { - let tab_exists = state - .active - .and_then(|ws_idx| state.workspaces.get(ws_idx)) - .is_some_and(|ws| idx < ws.tabs.len()); - if tab_exists { - state.switch_tab(idx); - leave_navigate_mode(state); - } - } - NavigateAction::FocusAgent(idx) => { - if state.focus_agent_entry(idx) { - leave_navigate_mode(state); - } - } - NavigateAction::WorkspacePicker => { - state.mobile_switcher_scroll = 0; - state.mode = Mode::Navigate; - } - NavigateAction::PreviousWorkspace => { - state.previous_workspace(); - leave_navigate_mode(state); - } - NavigateAction::NextWorkspace => { - state.next_workspace(); - leave_navigate_mode(state); - } - NavigateAction::PreviousAgent => { - state.previous_agent(); - leave_navigate_mode(state); - } - NavigateAction::NextAgent => { - state.next_agent(); - leave_navigate_mode(state); - } - NavigateAction::NewTab => { - if state.active.is_some() { - if state.prompt_new_tab_name { - super::modal::open_new_tab_dialog(state); - } else { - state.request_new_tab = true; - leave_navigate_mode(state); - } - } - } - NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false), - NavigateAction::PreviousTab => { - state.previous_tab(); - leave_navigate_mode(state); - } - NavigateAction::NextTab => { - state.next_tab(); - leave_navigate_mode(state); - } - NavigateAction::MoveTabPrevious => { - move_active_tab_relative(state, -1); - leave_navigate_mode(state); - } - NavigateAction::MoveTabNext => { - move_active_tab_relative(state, 1); - leave_navigate_mode(state); - } - NavigateAction::CloseTab => { - if !state.close_tab() { - leave_navigate_mode(state); - } - } - NavigateAction::RenamePane => { - if let Some(pane_id) = state - .active - .and_then(|ws_idx| state.workspaces.get(ws_idx)) - .and_then(|ws| ws.focused_pane_id()) - { - super::modal::open_rename_pane(state, pane_id); - } - } - NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left), - NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down), - NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), - NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), - NavigateAction::SwapPaneLeft => { - state.swap_pane(NavDirection::Left); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneDown => { - state.swap_pane(NavDirection::Down); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneUp => { - state.swap_pane(NavDirection::Up); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneRight => { - state.swap_pane(NavDirection::Right); - leave_navigate_mode(state); - } - NavigateAction::SplitVertical => { - state.split_pane(terminal_runtimes, Direction::Horizontal); - leave_navigate_mode(state); - } - NavigateAction::SplitHorizontal => { - state.split_pane(terminal_runtimes, Direction::Vertical); - leave_navigate_mode(state); - } - NavigateAction::ClosePane => { - if !state.close_pane() { - leave_navigate_mode(state); - } - } - NavigateAction::EditScrollback => {} - NavigateAction::CopyMode => state.enter_copy_mode(terminal_runtimes), - NavigateAction::Zoom => { - state.toggle_zoom(); - leave_navigate_mode(state); - } - NavigateAction::EnterResizeMode => state.mode = Mode::Resize, - NavigateAction::ResizePaneLeft => { - state.resize_pane(NavDirection::Left); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneDown => { - state.resize_pane(NavDirection::Down); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneUp => { - state.resize_pane(NavDirection::Up); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneRight => { - state.resize_pane(NavDirection::Right); - leave_navigate_mode(state); - } - NavigateAction::ToggleSidebar => { - state.sidebar_collapsed = !state.sidebar_collapsed; - leave_navigate_mode(state); - } - NavigateAction::CyclePaneNext => { - state.cycle_pane(false); - leave_navigate_mode(state); - } - NavigateAction::CyclePanePrevious => { - state.cycle_pane(true); - leave_navigate_mode(state); - } - NavigateAction::LastPane => { - state.last_pane(); - leave_navigate_mode(state); - } - NavigateAction::Help => super::modal::open_keybind_help(state), - NavigateAction::Settings => super::settings::open_settings(state), - NavigateAction::ReloadConfig => { - state.request_reload_config = true; - leave_navigate_mode(state); - } - NavigateAction::OpenNotificationTarget => { - state.focus_toast_target(); - if state.mode == Mode::Navigate { - leave_navigate_mode(state); - } - } - NavigateAction::Detach => { - super::modal::request_detach(state); - leave_navigate_mode(state); - } - NavigateAction::OpenNavigator => state.open_navigator_from(terminal_runtimes), - } - - finish_action_context(state, context, previous_mode); -} - -fn workspace_action_target(state: &AppState, context: ActionContext) -> Option<usize> { - let idx = match context { - ActionContext::Direct | ActionContext::Prefix => state.active.unwrap_or(state.selected), - ActionContext::Navigate => state.selected, - }; - (idx < state.workspaces.len()).then_some(idx) -} - -fn workspace_can_start_worktree_action( - state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> bool { - let Some(ws) = state.workspaces.get(ws_idx) else { - return false; - }; - if ws - .worktree_space() - .is_some_and(|space| space.is_linked_worktree) - { - return false; - } - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&state.terminals, terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - !git_space.is_some_and(|space| space.is_linked_worktree) -} - -// Translate a one-step move into the pre-removal insertion slot that -// Workspace::move_tab expects, wrapping at either end. None when there is -// nothing to move. -fn tab_move_insert_index(len: usize, source: usize, delta: isize) -> Option<usize> { - if len <= 1 { - return None; - } - Some(if delta > 0 { - if source + 1 >= len { - 0 - } else { - source + 2 - } - } else if source == 0 { - len - } else { - source - 1 - }) -} - -#[cfg(test)] -fn move_active_tab_relative(state: &mut AppState, delta: isize) { - let Some(ws) = state - .active - .and_then(|ws_idx| state.workspaces.get_mut(ws_idx)) - else { - return; - }; - let source = ws.active_tab; - if let Some(insert) = tab_move_insert_index(ws.tabs.len(), source, delta) { - ws.move_tab(source, insert); - } -} - -fn leave_navigate_mode(state: &mut AppState) { - if state.active.is_some() { - state.mode = Mode::Terminal; - } -} - -fn finish_action_context(state: &mut AppState, context: ActionContext, previous_mode: Mode) { - if matches!(context, ActionContext::Direct | ActionContext::Prefix) - && state.mode == previous_mode - { - leave_command_mode(state); - } -} - -fn finish_custom_command_context( - state: &mut AppState, - context: ActionContext, - previous_mode: Mode, -) { - if context == ActionContext::Navigate { - leave_navigate_mode(state); - } else { - finish_action_context(state, context, previous_mode); - } -} - -fn leave_command_mode(state: &mut AppState) { - if state.copy_mode_pane_is_focused() { - state.mode = Mode::Copy; - } else if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - }; -} - -fn write_scrollback_temp_file(content: &str) -> io::Result<std::path::PathBuf> { - let mut last_collision = None; - for attempt in 0..16 { - let path = unique_scrollback_path(attempt); - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - - match options.open(&path) { - Ok(mut file) => { - file.write_all(content.as_bytes())?; - return Ok(path); - } - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { - last_collision = Some(err); - } - Err(err) => return Err(err), - } - } - - Err(last_collision.unwrap_or_else(|| { - io::Error::new( - io::ErrorKind::AlreadyExists, - "failed to create unique scrollback temp file", - ) - })) -} - -fn unique_scrollback_path(attempt: u32) -> std::path::PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!( - "herdr-scrollback-{}-{nanos}-{attempt}.txt", - std::process::id() - )) -} - -#[cfg(test)] -mod tests { - #[cfg(unix)] - use std::time::Duration; - - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, ModifierKeyCode}; - use ratatui::layout::Direction; - - use super::super::{state_with_workspaces, unique_temp_path}; - #[cfg(unix)] - use super::super::{wait_for_detached_process_reap, wait_for_file}; - use super::*; - use crate::{ - app::App, - config::Config, - input::TerminalKey, - raw_input::{parse_raw_input_bytes_sync, RawInputEvent}, - terminal::TerminalState, - workspace::Workspace, - }; - - 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, - }); - } - - 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 next_agent_starts_at_first_visible_entry_when_focused_agent_is_filtered_out() { - let mut app = app_with_test_workspaces(&["hidden", "first", "second"]); - for ws_idx in 0..app.state.workspaces.len() { - let pane_id = app.state.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = if ws_idx == 0 { - crate::detect::AgentState::Idle - } else { - crate::detect::AgentState::Working - }; - } - app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::Status, - ), - value: crate::api::schema::AgentViewValue::String("working".to_string()), - }), - sort: Vec::new(), - }); - - app.execute_tui_navigate_action(NavigateAction::NextAgent, ActionContext::Prefix); - - assert_eq!(app.state.active, Some(1)); - } - - #[test] - fn default_goto_key_opens_navigator() { - let mut state = state_with_workspaces(&["test"]); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - } - - #[test] - fn custom_rename_key_enters_rename_mode() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.name_input, "test"); - } - - #[test] - fn rename_workspace_prefills_live_terminal_cwd_label() { - let mut state = state_with_workspaces(&["stale"]); - let root = state.workspaces[0].tabs[0].root_pane; - let terminal_id = state.workspaces[0].panes[&root] - .attached_terminal_id - .clone(); - state.workspaces[0].custom_name = None; - state.workspaces[0].identity_cwd = "/__herdr_original__".into(); - state.terminals.insert( - terminal_id.clone(), - TerminalState::new(terminal_id, "/__herdr_projects__".into()), - ); - state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.name_input, "__herdr_projects__"); - assert_eq!(state.workspaces[0].display_name(), "__herdr_original__"); - } - - #[test] - fn prefix_rename_workspace_targets_active_workspace_not_stale_selection() { - let mut state = state_with_workspaces(&["main", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.active = Some(1); - state.selected = 0; - state.mode = Mode::Prefix; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::RenameWorkspace, - ActionContext::Prefix, - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.selected, 1); - assert_eq!(state.name_input, "issue"); - } - - #[test] - fn prefix_close_workspace_targets_active_linked_worktree_without_removing_checkout() { - let mut state = state_with_workspaces(&["main", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.active = Some(1); - state.selected = 0; - state.mode = Mode::Prefix; - state.confirm_close = false; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::CloseWorkspace, - ActionContext::Prefix, - ); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_new_workspace_key_requests_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.request_new_workspace); - assert_eq!(state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn new_workspace_key_opens_prefilled_prompt_and_preserves_captured_cwd() { - let cwd = unique_temp_path("workspace-name-suggestion"); - std::fs::create_dir_all(&cwd).unwrap(); - let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); - let mut app = app_with_test_workspaces(&["test"]); - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path(cwd.display().to_string()); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - app.state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert_eq!(app.state.name_input, suggested_name); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.pending_workspace_create_cwd.as_ref(), Some(&cwd)); - assert_eq!(app.state.workspaces.len(), 1); - - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path("/tmp/changed-after-prompt".into()); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.workspaces[1].identity_cwd, cwd); - assert!(app.state.workspaces[1].custom_name.is_none()); - assert!(app.state.pending_workspace_create_cwd.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(&cwd); - } - - #[tokio::test] - async fn new_workspace_prompt_saves_custom_name_atomically() { - let cwd = unique_temp_path("workspace-custom-name"); - std::fs::create_dir_all(&cwd).unwrap(); - let mut app = app_with_test_workspaces(&["test"]); - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path(cwd.display().to_string()); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::NewWorkspace, ActionContext::Navigate); - app.state.name_input = " logs ".into(); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.workspaces[1].custom_name.as_deref(), Some("logs")); - assert_eq!(app.state.workspaces[1].identity_cwd, cwd); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(&cwd); - } - - #[test] - fn cancelling_new_workspace_prompt_creates_nothing() { - let mut app = app_with_test_workspaces(&["test"]); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::NewWorkspace, ActionContext::Navigate); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 1); - assert!(app.state.pending_workspace_create_cwd.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn custom_new_worktree_key_requests_selected_workspace() { - let mut state = state_with_workspaces(&["main", "scratch"]); - state.workspaces[1].identity_cwd = unique_temp_path("navigate-new-worktree-selected"); - state.mode = Mode::Navigate; - state.selected = 1; - state.active = Some(0); - state.keybinds.new_worktree = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.request_new_linked_worktree, Some(1)); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn worktree_actions_do_not_start_from_linked_child_workspace() { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - let mut state = state_with_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 1, "repo-key"); - state.mode = Mode::Navigate; - state.selected = 1; - state.active = Some(0); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewWorktree, - ActionContext::Navigate, - ); - assert_eq!(state.request_new_linked_worktree, None); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::OpenWorktree, - ActionContext::Navigate, - ); - assert_eq!(state.request_open_existing_worktree, None); - } - - #[test] - fn direct_new_worktree_action_targets_active_workspace() { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - let mut state = state_with_workspaces(&["main", "scratch"]); - state.workspaces[0].identity_cwd = unique_temp_path("navigate-new-worktree-active"); - state.mode = Mode::Terminal; - state.selected = 1; - state.active = Some(0); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewWorktree, - ActionContext::Direct, - ); - - assert_eq!(state.request_new_linked_worktree, Some(0)); - } - - #[test] - fn navigate_down_follows_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Navigate; - state.active = Some(0); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 2); - } - - #[test] - fn navigate_number_keys_follow_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Navigate; - state.active = Some(0); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('2'), KeyModifiers::empty()), - ); - - assert_eq!(state.active, Some(2)); - assert_eq!(state.selected, 2); - } - - #[test] - fn indexed_switch_workspace_keybind_follows_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Prefix; - state.active = Some(0); - state.selected = 0; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::SwitchWorkspace(1), - ActionContext::Prefix, - ); - - assert_eq!(state.active, Some(2)); - assert_eq!(state.selected, 2); - } - - #[test] - fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("g"); - assert!(!state.sidebar_collapsed); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.sidebar_collapsed); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_resize_key_enters_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Resize); - } - - #[test] - fn custom_reload_config_key_requests_reload_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.reload_config = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.request_reload_config); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_open_notification_key_focuses_current_toast_target() { - let mut state = state_with_workspaces(&["one", "two"]); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - state.keybinds.open_notification_target = crate::config::ActionKeybinds::prefix("g"); - let target_workspace_id = state.workspaces[1].id.clone(); - let target_pane = state.workspaces[1].tabs[0].root_pane; - state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "pi needs attention".into(), - context: "two".into(), - position: None, - target: Some(crate::app::state::ToastTarget { - workspace_id: target_workspace_id, - pane_id: target_pane, - }), - }); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.selected, 1); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(target_pane)); - assert!(state.toast.is_none()); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn movement_action_stays_in_navigate_mode() { - let mut state = state_with_workspaces(&["a", "b"]); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn navigate_workspace_keys_are_configurable() { - let mut state = state_with_workspaces(&["a", "b"]); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn navigate_pane_keys_are_configurable() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn focus_pane_prefix_rhs_does_not_create_navigate_mode_pane_shortcut() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -focus_pane_down = "prefix+f" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn customized_navigate_pane_key_disables_matching_prefix_rhs_fallback() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn left_and_right_arrows_remain_permanent_navigate_pane_aliases() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - state.workspaces[0].layout.focus_pane(right); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_pane_left = "ctrl+h" -navigate_pane_right = "ctrl+l" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Left, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn mobile_workspace_keyboard_navigation_keeps_selected_row_visible() { - let mut state = state_with_workspaces(&["a", "b", "c", "d"]); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 44, 8)); - assert_eq!(state.mobile_switcher_scroll, 0); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mobile_switcher_scroll, 1); - } - - #[test] - fn terminal_direct_agent_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.next_agent = crate::config::ActionKeybinds::direct("alt+a"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('a'), KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::NextAgent)); - } - - #[test] - fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+left"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Left, KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); - } - - #[test] - fn terminal_direct_swap_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.swap_pane_right = crate::config::ActionKeybinds::direct("alt+shift+l"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT | KeyModifiers::SHIFT), - ); - - assert_eq!(action, Some(NavigateAction::SwapPaneRight)); - } - - #[test] - fn terminal_direct_resize_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.resize_pane_right = - crate::config::ActionKeybinds::direct("ctrl+shift+alt+right"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new( - KeyCode::Right, - KeyModifiers::CONTROL | KeyModifiers::SHIFT | KeyModifiers::ALT, - ), - ); - - assert_eq!(action, Some(NavigateAction::ResizePaneRight)); - } - - #[test] - fn prefix_resize_pane_binding_maps_to_navigation_action() { - let config: Config = toml::from_str( - r#" -[keys] -resize_pane_left = "prefix+shift+left" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Left, KeyModifiers::SHIFT), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::ResizePaneLeft)); - } - - #[test] - fn terminal_direct_move_tab_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.move_tab_next = crate::config::ActionKeybinds::direct("alt+shift+right"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Right, KeyModifiers::ALT | KeyModifiers::SHIFT), - ); - - assert_eq!(action, Some(NavigateAction::MoveTabNext)); - } - - fn tab_labels(state: &AppState) -> Vec<String> { - let ws = &state.workspaces[0]; - (0..ws.tabs.len()) - .map(|tab_idx| ws.tab_display_name(tab_idx).unwrap()) - .collect() - } - - #[test] - fn move_tab_actions_reorder_and_wrap_the_active_tab() { - let mut state = state_with_workspaces(&["test"]); - { - let ws = &mut state.workspaces[0]; - ws.tabs[0].set_custom_name("a".into()); - ws.test_add_tab(Some("b")); - ws.test_add_tab(Some("c")); - ws.switch_tab(1); - } - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - assert_eq!(tab_labels(&state), vec!["a", "c", "b"]); - assert_eq!(state.workspaces[0].active_tab, 2); - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - assert_eq!(tab_labels(&state), vec!["b", "a", "c"]); - assert_eq!(state.workspaces[0].active_tab, 0); - - execute_navigate_action(&mut state, NavigateAction::MoveTabPrevious); - assert_eq!(tab_labels(&state), vec!["a", "c", "b"]); - assert_eq!(state.workspaces[0].active_tab, 2); - state.workspaces[0].assert_invariants_for_test(); - } - - #[test] - fn move_tab_is_a_noop_with_a_single_tab() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].tabs[0].set_custom_name("only".into()); - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - - assert_eq!(tab_labels(&state), vec!["only"]); - assert_eq!(state.workspaces[0].active_tab, 0); - } - - #[test] - fn move_tab_with_a_single_tab_still_exits_navigate_mode() { - let event_hub = crate::api::EventHub::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = crate::app::App::new( - &crate::config::Config::default(), - true, - None, - api_rx, - event_hub, - ); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("solo")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::MoveTabNext, ActionContext::Navigate); - - assert_eq!(app.state.workspaces[0].tabs.len(), 1); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.last_pane = crate::config::ActionKeybinds::direct("alt+l"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::LastPane)); - } - - #[test] - fn generated_character_prefix_binding_falls_back_after_exact_chord() { - let generated_key = crate::input::parse_terminal_key_sequence("\x1b[119;3;124u").unwrap(); - assert_eq!(generated_key.code, KeyCode::Char('w')); - assert_eq!(generated_key.modifiers, KeyModifiers::ALT); - assert_eq!(generated_key.generated_text.as_deref(), Some("|")); - - let generated_only: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = generated_only.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Action(NavigateAction::SplitVertical)) - )); - let multi_character_key = - crate::input::parse_terminal_key_sequence("\x1b[119;3;124:120u").unwrap(); - assert!(prefix_binding_for_key(&state, &multi_character_key).is_none()); - - let exact_and_generated: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -split_horizontal = "prefix+alt+w" -"#, - ) - .unwrap(); - state.keybinds = exact_and_generated.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Action(NavigateAction::SplitHorizontal)) - )); - - let exact_command: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" - -[[keys.command]] -key = "prefix+alt+w" -command = "echo exact" -"#, - ) - .unwrap(); - state.keybinds = exact_command.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Command(binding)) if binding.command == "echo exact" - )); - } - - #[test] - fn shifted_backslash_layout_prefers_horizontal_split_binding() { - let config: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -split_horizontal = 'prefix+\' -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - let key = crate::input::parse_terminal_key_sequence("\x1b[124:92;2:1u").unwrap(); - assert_eq!(key.code, KeyCode::Char('|')); - assert_eq!(key.modifiers, KeyModifiers::SHIFT); - assert_eq!(key.shifted_codepoint, Some('\\' as u32)); - assert!(state.keybinds.split_horizontal.matches_prefix_key(&key)); - assert!(!state.keybinds.split_vertical.matches_prefix_key(&key)); - - assert_eq!( - action_for_key(&state, key, BindingDispatch::Prefix), - Some(NavigateAction::SplitHorizontal) - ); - assert_eq!( - action_for_key( - &state, - TerminalKey::new(KeyCode::Char('|'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ), - Some(NavigateAction::SplitVertical) - ); - } - - #[test] - fn prefix_tab_override_can_map_to_last_pane() { - let config: Config = toml::from_str( - r#" -[keys] -last_pane = "prefix+tab" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - - let pane_action = action_for_key( - &state, - TerminalKey::new(KeyCode::Tab, KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(pane_action, Some(NavigateAction::LastPane)); - } - - #[test] - fn terminal_direct_indexed_tab_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - let config: Config = toml::from_str("[keys]\nswitch_tab = \"ctrl+3\"\n").unwrap(); - state.keybinds.switch_tab = config.keybinds().switch_tab; - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('3'), KeyModifiers::CONTROL), - ); - - assert_eq!(action, Some(NavigateAction::SwitchTab(2))); - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_maps_legacy_us_symbol_key() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - state.keybinds.switch_workspace = config.keybinds().switch_workspace; - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('@'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::SwitchWorkspace(1))); - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_maps_non_us_number_rows() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - state.keybinds.switch_workspace = config.keybinds().switch_workspace; - - for key in [ - TerminalKey::new(KeyCode::Char('2'), KeyModifiers::SHIFT) - .with_shifted_codepoint('"' as u32), - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - ] { - assert_eq!( - action_for_key(&state, key, BindingDispatch::Prefix), - Some(NavigateAction::SwitchWorkspace(1)) - ); - } - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_survives_modifier_press() { - let mut app = app_with_test_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - app.state.keybinds.switch_workspace = config.keybinds().switch_workspace; - app.state.mode = Mode::Prefix; - - app.handle_prefix_key(TerminalKey::new( - KeyCode::Modifier(ModifierKeyCode::LeftShift), - KeyModifiers::SHIFT, - )); - - assert_eq!(app.state.mode, Mode::Prefix); - - app.handle_prefix_key( - TerminalKey::new(KeyCode::Char('2'), KeyModifiers::SHIFT) - .with_shifted_codepoint('"' as u32), - ); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn prefix_unshifted_indexed_shortcut_maps_shifted_french_number_row() { - let mut state = state_with_workspaces(&["one"]); - let config: Config = toml::from_str("[keys]\nswitch_tab = \"prefix+1..9\"\n").unwrap(); - state.keybinds.switch_tab = config.keybinds().switch_tab; - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::SwitchTab(1))); - } - - #[test] - fn literal_symbol_binding_takes_precedence_over_shifted_indexed_alias() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -help = "prefix+!" -switch_workspace = "prefix+shift+1..9" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::Help)); - } - - #[test] - fn literal_symbol_custom_command_is_visible_before_shifted_indexed_alias() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -switch_workspace = "prefix+shift+1..9" - -[[keys.command]] -key = "prefix+!" -command = "echo literal" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - let key = TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()); - assert!(command_for_key(&state, &key, BindingDispatch::Prefix).is_some()); - assert_eq!( - indexed_navigation_action(&state, &key, BindingDispatch::Prefix), - Some(NavigateAction::SwitchWorkspace(0)) - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn literal_symbol_custom_command_runs_before_shifted_indexed_alias() { - 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 = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.state.active = Some(1); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("literal-symbol-custom-command"); - let config: Config = toml::from_str(&format!( - r#" -[keys] -switch_workspace = "prefix+shift+1..9" - -[[keys.command]] -key = "prefix+!" -command = "printf literal > '{}'" -"#, - output_path.display() - )) - .unwrap(); - app.state.keybinds = config.keybinds(); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty())) - .await; - - assert_eq!(wait_for_file(&output_path), "literal"); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - } - - #[tokio::test] - async fn navigate_mode_runs_prefix_action_rhs_without_pressing_prefix_again() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::SHIFT)); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigate_mode_matches_legacy_uppercase_shifted_letter() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_workspace_binding_over_unshifted() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('W'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - } - - #[tokio::test] - async fn kitty_shifted_alternate_without_modifier_prefers_reload_over_resize() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Prefix; - - let mut events = parse_raw_input_bytes_sync(b"\x1b[114:82;1u"); - assert_eq!(events.len(), 1); - let RawInputEvent::Key(key) = events.remove(0) else { - panic!("expected key event"); - }; - assert_eq!( - action_for_key(&app.state, key.clone(), BindingDispatch::Prefix), - Some(NavigateAction::ReloadConfig) - ); - app.handle_prefix_key(key); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_reload_binding_over_unshifted() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('R'), KeyModifiers::empty())); - - assert!(!app.state.request_reload_config); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_pane_binding_over_unshifted() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('P'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenamePane); - } - - #[test] - fn app_navigate_mode_workspace_down_moves_selection() { - let mut app = app_with_test_workspaces(&["one", "two"]); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Down, KeyModifiers::empty())); - - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn app_navigate_mode_maps_french_number_row_to_workspace() { - let mut app = app_with_test_workspaces(&["one", "two"]); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key( - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - ); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn app_navigate_mode_workspace_keys_are_configurable() { - let mut app = app_with_test_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - app.state.keybinds = config.keybinds(); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())); - - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[tokio::test] - async fn prefix_focus_pane_is_one_shot_and_returns_to_terminal() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let root = app.state.workspaces[0].tabs[0].root_pane; - let right = app.state.workspaces[0].test_split(Direction::Horizontal); - app.state.workspaces[0].layout.focus_pane(right); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigate_focus_pane_keeps_navigate_mode_active() { - let mut app = app_with_test_workspaces(&["test"]); - let root = app.state.workspaces[0].tabs[0].root_pane; - let below = app.state.workspaces[0].test_split(Direction::Vertical); - app.state.workspaces[0].layout.focus_pane(below); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - app.state.mode = Mode::Navigate; - - app.handle_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[tokio::test] - async fn no_op_prefix_action_exits_prefix_mode() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('o'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn unmatched_prefix_rhs_exits_prefix_mode() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::F(12), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn prefix_help_matches_enhanced_shifted_question_mark() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key( - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ) - .await; - - assert_eq!(app.state.mode, Mode::KeybindHelp); - } - - #[test] - fn navigate_mode_help_is_binding_driven() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.help = crate::config::ActionKeybinds::prefix("f"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), - ); - assert_eq!(state.mode, Mode::Navigate); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::KeybindHelp); - } - - #[test] - fn modified_navigate_local_key_can_be_bound_as_prefix_rhs() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+u"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT), - ); - - assert!(state.sidebar_collapsed); - } - - #[test] - fn empty_state_new_tab_is_no_op() { - let mut state = crate::app::state::AppState::test_new(); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.mode = Mode::Prefix; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewTab, - ActionContext::Prefix, - ); - - assert_eq!(state.mode, Mode::Navigate); - assert!(!state.creating_new_tab); - assert!(!state.request_new_tab); - assert!(state.workspaces.is_empty()); - } - - #[test] - fn closing_linked_worktree_closes_workspace_without_removing_checkout() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.selected = 1; - state.active = Some(1); - state.mode = Mode::Navigate; - state.confirm_close = false; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - execute_navigate_action(&mut state, NavigateAction::CloseWorkspace); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn tui_close_parent_group_closes_immediately_when_confirmation_disabled() { - 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 = 0; - app.state.mode = Mode::Navigate; - app.state.confirm_close = false; - - app.execute_tui_navigate_action(NavigateAction::CloseWorkspace, ActionContext::Navigate); - - assert!(app.state.workspaces.is_empty()); - assert_eq!(app.state.mode, Mode::Navigate); - assert_eq!(app.event_hub.events_after(0).len(), 2); - } - - #[test] - fn prefix_close_pane_last_parent_group_pane_opens_confirmation() { - let mut state = state_with_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 1, "repo-key"); - state.selected = 1; - state.active = Some(0); - state.mode = Mode::Navigate; - - execute_navigate_action(&mut state, NavigateAction::ClosePane); - - assert_eq!(state.selected, 0); - assert_eq!(state.mode, Mode::ConfirmClose); - 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() { - 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 = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("custom-command-keybind"); - let release_path = unique_temp_path("custom-command-release"); - let command = format!( - "printf '%s\\n%s\\n%s\\n%s\\n' \"$$\" \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'; i=0; while [ ! -e '{}' ] && [ \"$i\" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done", - output_path.display(), - release_path.display(), - ); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("m"), - label: "prefix+m".into(), - command, - action: crate::config::CustomCommandAction::Shell, - description: None, - width: None, - height: None, - }]; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - assert_eq!(app.state.mode, Mode::Prefix); - - let launch_started = std::time::Instant::now(); - app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) - .await; - assert!(launch_started.elapsed() < Duration::from_secs(2)); - - let content = wait_for_file(&output_path); - let lines: Vec<&str> = content.lines().collect(); - assert_eq!(lines.len(), 4); - let pid = lines[0] - .parse::<u32>() - .expect("command should report its pid"); - assert!(crate::platform::process_exists(pid)); - assert_eq!(lines[1], app.state.workspaces[0].id); - assert_eq!(lines[2], format!("{}:t1", app.state.workspaces[0].id)); - assert_eq!(lines[3], format!("{}:p1", app.state.workspaces[0].id)); - assert_eq!(app.state.mode, Mode::Terminal); - - std::fs::write(&release_path, b"release").expect("release command"); - let reaped_by_runtime = wait_for_detached_process_reap(&mut app, pid).await; - if !reaped_by_runtime { - if let Some(child) = app - .detached_process_children - .iter_mut() - .find(|child| child.id() == pid) - { - let _ = child.kill(); - let _ = child.wait(); - } - } - assert!( - reaped_by_runtime, - "detached command child {pid} was not reaped" - ); - - let _ = std::fs::remove_file(output_path); - let _ = std::fs::remove_file(release_path); - } - - #[cfg(unix)] - #[tokio::test] - async fn pane_overlay_command_opens_and_closes_after_exit() { - 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(), - ); - let (workspace, terminal, runtime) = Workspace::new( - std::env::current_dir().unwrap_or_else(|_| "/".into()), - 24, - 80, - app.state.pane_scrollback_limit_bytes, - app.state.host_terminal_theme, - app.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), - app.event_tx.clone(), - app.render_notify.clone(), - app.render_dirty.clone(), - ) - .expect("workspace should spawn"); - let root_pane = workspace.tabs[0].root_pane; - app.state.workspaces = vec![workspace]; - app.terminal_runtimes.insert(terminal.id.clone(), runtime); - app.state.terminals.insert(terminal.id.clone(), terminal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("custom-pane-command"); - let command = format!("printf done > '{}'", output_path.display()); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("m"), - label: "prefix+m".into(), - command, - action: crate::config::CustomCommandAction::Pane, - description: None, - width: None, - height: None, - }]; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert_eq!(app.terminal_runtimes.len(), 2); - assert!(app.state.workspaces[0].tabs[0].zoomed); - let overlay_pane = app.state.workspaces[0].focused_pane_id().unwrap(); - assert_ne!(overlay_pane, root_pane); - - app.state.last_pane(); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root_pane)); - - app.state.last_pane(); - - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(overlay_pane) - ); - - let _ = wait_for_file(&output_path); - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if app.drain_internal_events() - && app.state.workspaces[0].tabs[0].layout.pane_count() == 1 - { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(!app.state.workspaces[0].tabs[0].zoomed); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - } - - #[cfg(unix)] - #[tokio::test] - async fn edit_scrollback_key_preserves_logical_lines_in_editor_pane() { - 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(), - ); - let mut workspace = Workspace::test_new("test"); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0].runtimes.insert( - root_pane, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - 5, - 5, - 4096, - b"ABCDEFGHIJ\r\nKLMNO", - ), - ); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("edit-scrollback"); - let previous_editor = std::env::var_os("EDITOR"); - std::env::set_var( - "EDITOR", - format!("sh -c 'cp \"$1\" {}' sh", output_path.display()), - ); - app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::prefix("g"); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) - .await; - - match previous_editor { - Some(value) => std::env::set_var("EDITOR", value), - None => std::env::remove_var("EDITOR"), - } - - let content = wait_for_file(&output_path); - assert_eq!(content, "ABCDEFGHIJ\nKLMNO"); - assert_eq!(app.state.mode, Mode::Terminal); - assert!( - app.state.terminals.values().any(|terminal| terminal - .launch_argv - .as_ref() - .is_some_and(|argv| argv.first().is_some_and(|program| program == "/bin/sh"))), - "scrollback editor should launch through argv overlay path" - ); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn zoom_action_exits_navigate_mode() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_split(Direction::Horizontal); - state.keybinds.zoom = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.workspaces[0].zoomed); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn focus_pane_action_keeps_zoomed_when_changing_focus() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - state.workspaces[0].layout.focus_pane(root); - state.workspaces[0].zoomed = true; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); - - execute_navigate_action(&mut state, NavigateAction::FocusPaneRight); - - assert!(state.workspaces[0].zoomed); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - } - - #[test] - fn question_mark_opens_keybind_help_from_navigate() { - let mut state = state_with_workspaces(&["test"]); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), - ); - - assert_eq!(state.mode, Mode::KeybindHelp); - } - - #[test] - fn new_tab_action_opens_dialog_without_creating_tab() { - let mut state = state_with_workspaces(&["test"]); - - execute_navigate_action(&mut state, NavigateAction::NewTab); - - assert_eq!(state.mode, Mode::RenameTab); - assert!(state.creating_new_tab); - assert_eq!(state.name_input, "2"); - assert!(state.name_input_replace_on_type); - assert!(!state.request_new_tab); - assert_eq!(state.workspaces[0].tabs.len(), 1); - } - - #[test] - fn new_tab_action_can_skip_rename_dialog() { - let mut state = state_with_workspaces(&["test"]); - state.prompt_new_tab_name = false; - - execute_navigate_action(&mut state, NavigateAction::NewTab); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - } - - #[test] - fn navigate_q_detaches_in_persistence_mode() { - let mut state = crate::app::state::AppState::test_new(); - state.detach_exits = false; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()), - ); - - assert!(state.detach_requested); - assert!(!state.should_quit); - } -} |
