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 { action_for_key(state, key, BindingDispatch::Direct) } pub(crate) fn terminal_direct_non_indexed_navigation_action( state: &AppState, key: &TerminalKey, ) -> Option { non_indexed_action_for_key(state, key, BindingDispatch::Direct) } pub(crate) fn terminal_direct_indexed_navigation_action( state: &AppState, key: &TerminalKey, ) -> Option { 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; } if let Some(action) = non_indexed_action_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { self.execute_prefix_key_action(action); return; } if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { self.cancel_copy_mode_if_active(); self.launch_custom_command(binding, ActionContext::Prefix); return; } if let Some(action) = indexed_navigation_action(&self.state, &raw_key, BindingDispatch::Prefix) { self.execute_prefix_key_action(action); return; } 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_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_via_api(&mut self, ws_idx: usize) { let workspace_id = self.public_workspace_id(ws_idx); self.runtime_workspace_close("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_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, 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 { 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 { 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) { 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_custom_command_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_text(usize::MAX); 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::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, extra_env: Vec<(String, String)>, temp_files: Vec, ) -> 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, } pub(crate) fn command_for_key( state: &AppState, key: &TerminalKey, dispatch: BindingDispatch, ) -> Option { 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 { ('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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; #[cfg(unix)] use super::super::wait_for_file; use super::super::{state_with_workspaces, unique_temp_path}; 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 { 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 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 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::() .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 deadline = tokio::time::Instant::now() + Duration::from_secs(2); while crate::platform::process_exists(pid) && tokio::time::Instant::now() < deadline { app.reap_finished_custom_commands(); tokio::time::sleep(Duration::from_millis(20)).await; } app.reap_finished_custom_commands(); let reaped_by_runtime = !crate::platform::process_exists(pid); if !reaped_by_runtime { if let Some(child) = app .detached_custom_command_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_opens_focused_runtime_scrollback_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( 20, 5, 4096, b"alpha\nbeta\n", ), ); 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!(content.contains("alpha")); assert!(content.contains("beta")); 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); } }