aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input/mod.rs')
-rw-r--r--src/app/input/mod.rs1051
1 files changed, 0 insertions, 1051 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
deleted file mode 100644
index 10edc0c0..00000000
--- a/src/app/input/mod.rs
+++ /dev/null
@@ -1,1051 +0,0 @@
-//! Input handling — translates crossterm key/mouse events into state mutations.
-
-use bytes::Bytes;
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
-use tracing::warn;
-
-use crate::app::PaneClickState;
-use crate::input::TerminalKey;
-#[cfg(test)]
-use ratatui::layout::Direction;
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ScrollbarClickTarget {
- Thumb { grab_row_offset: u16 },
- Track { offset_from_bottom: usize },
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-#[cfg(test)]
-enum WheelRouting {
- HostScroll,
- MouseReport,
- AlternateScroll,
-}
-
-const WORKSPACE_DRAG_THRESHOLD: u16 = 1;
-const TAB_DRAG_THRESHOLD: u16 = 1;
-
-fn modified_url_click_modifier() -> KeyModifiers {
- KeyModifiers::CONTROL
-}
-
-#[cfg(test)]
-#[test]
-fn modified_url_click_modifier_matches_terminal_mouse_reporting() {
- assert_eq!(modified_url_click_modifier(), KeyModifiers::CONTROL);
-}
-
-mod clipboard;
-mod copy_mode;
-mod lease;
-mod modal;
-mod mouse;
-mod navigate;
-mod overlays;
-mod selection;
-mod settings;
-mod sidebar;
-mod terminal;
-
-pub(crate) use self::{
- lease::{ConsumedInputLease, ForwardedInputLease, InputLeaseKey, InputLeaseTable, RepeatPlan},
- modal::{
- handle_global_menu_key, handle_keybind_help_key, handle_navigator_key,
- insert_keybind_help_query_text, insert_navigator_search_text, insert_rename_input_text,
- open_new_workspace_dialog,
- },
- navigate::{
- terminal_direct_indexed_navigation_action, terminal_direct_non_indexed_navigation_action,
- },
- settings::open_settings_at,
-};
-use self::{
- modal::{
- modal_action_from_key, ModalAction, ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS,
- },
- mouse::MouseAction,
- settings::SettingsAction,
-};
-use super::state::{AppState, Mode};
-use super::App;
-
-// ---------------------------------------------------------------------------
-// Key handling
-// ---------------------------------------------------------------------------
-
-impl App {
- pub(super) async fn handle_key(
- &mut self,
- key: TerminalKey,
- ) -> Option<super::TerminalInputTarget> {
- if self.state.popup_pane.is_some() {
- return self.handle_terminal_key(key).await;
- }
- let key_event = key.as_key_event();
- if modal_paste_target_active(&self.state) && is_modal_paste_shortcut(&key_event) {
- if let Some(text) = crate::platform::read_clipboard_text() {
- self.paste_into_active_text_input(&text);
- }
- return None;
- }
-
- match self.state.mode {
- Mode::Terminal => return self.handle_terminal_key(key).await,
- Mode::Prefix => self.handle_prefix_key(key),
- Mode::Navigate => self.handle_navigate_key(key),
- Mode::Copy => self.handle_copy_mode_key(key),
- _ => match self.state.mode {
- Mode::Onboarding => self.handle_onboarding_key(key_event),
- Mode::ReleaseNotes => self.handle_release_notes_key(key_event),
- Mode::ProductAnnouncement => self.handle_product_announcement_key(key_event),
- Mode::Prefix | Mode::Navigate | Mode::Copy => unreachable!(),
- Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
- self.handle_rename_key_via_api(key_event)
- }
- Mode::NewLinkedWorktree => self.handle_worktree_create_key(key_event),
- Mode::OpenExistingWorktree => self.handle_worktree_open_key(key_event),
- Mode::ConfirmRemoveWorktree => self.handle_worktree_remove_key(key_event),
- Mode::Resize => self.handle_resize_key_via_api(key),
- Mode::ConfirmClose => self.handle_confirm_close_key_via_api(key_event),
- Mode::ContextMenu => {
- self.handle_context_menu_key_via_api(key_event);
- }
- Mode::Settings => self.handle_settings_key(key_event),
- Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event),
- Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key),
- Mode::Navigator => {
- handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event)
- }
- Mode::Terminal => unreachable!(),
- },
- }
- None
- }
-
- pub(crate) fn handle_text_commit_headless(&mut self, text: &str) {
- if text.is_empty() {
- return;
- }
- if self.state.popup_pane.is_some() {
- if let Some(runtime) = self.popup_runtime() {
- let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes()));
- } else {
- self.close_popup_pane();
- }
- return;
- }
- if self.state.mode != Mode::Terminal {
- self.paste_into_active_text_input(text);
- return;
- }
-
- self.state.clear_selection();
- self.selection_autoscroll_deadline = None;
- self.state.update_dismissed = true;
- if let Some(ws_idx) = self.state.active {
- if let Some(runtime) = self
- .state
- .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
- {
- let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes()));
- }
- }
- }
-
- pub(super) async fn handle_text_commit(&mut self, text: String) {
- if text.is_empty() {
- return;
- }
- if self.state.popup_pane.is_some() {
- if let Some(runtime) = self.popup_runtime() {
- let _ = runtime.send_bytes(Bytes::from(text)).await;
- } else {
- self.close_popup_pane();
- }
- return;
- }
- if self.state.mode != Mode::Terminal {
- self.paste_into_active_text_input(&text);
- return;
- }
-
- self.state.clear_selection();
- self.selection_autoscroll_deadline = None;
- self.state.update_dismissed = true;
- if let Some(ws_idx) = self.state.active {
- if let Some(runtime) = self
- .state
- .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
- {
- let _ = runtime.send_bytes(Bytes::from(text)).await;
- }
- }
- }
-
- pub(super) async fn handle_paste(&mut self, text: String) {
- if self.state.popup_pane.is_some() {
- if let Some(runtime) = self.popup_runtime() {
- let _ = runtime.send_paste(text).await;
- } else {
- self.close_popup_pane();
- }
- return;
- }
- if self.state.mode != Mode::Terminal {
- self.paste_into_active_text_input(&text);
- return;
- }
-
- if let Some(ws_idx) = self.state.active {
- if let Some(rt) = self
- .state
- .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
- {
- let _ = rt.send_paste(text).await;
- }
- }
- }
-
- pub(crate) fn paste_into_active_text_input(&mut self, text: &str) -> bool {
- match self.state.mode {
- Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
- insert_rename_input_text(&mut self.state, text);
- true
- }
- Mode::NewLinkedWorktree => {
- self.insert_worktree_create_text(text);
- true
- }
- Mode::OpenExistingWorktree => {
- if !self
- .state
- .worktree_open
- .as_ref()
- .is_some_and(|open| open.search_focused)
- {
- return false;
- }
- self.insert_worktree_open_search_text(text);
- true
- }
- Mode::Navigator => {
- if !self.state.navigator.search_focused {
- return false;
- }
- insert_navigator_search_text(&mut self.state, &self.terminal_runtimes, text);
- true
- }
- Mode::KeybindHelp => {
- if !self.state.keybind_help.search_focused {
- return false;
- }
- insert_keybind_help_query_text(&mut self.state, text);
- true
- }
- Mode::Copy => {
- let Some(prompt) = self
- .state
- .copy_mode
- .as_mut()
- .and_then(|copy_mode| copy_mode.search.prompt.as_mut())
- else {
- return false;
- };
- prompt
- .query
- .extend(text.chars().filter(|ch| !ch.is_control()));
- true
- }
- _ => false,
- }
- }
-
- pub(crate) fn handle_onboarding_key(&mut self, key: KeyEvent) {
- match key.code {
- KeyCode::Right | KeyCode::Char('l') => self.open_settings_from_onboarding(),
- _ => {
- if let Some(ModalAction::Continue) =
- modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS)
- {
- self.open_settings_from_onboarding();
- }
- }
- }
- }
-
- pub(crate) fn handle_release_notes_key(&mut self, key: KeyEvent) {
- match key.code {
- KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1),
- KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1),
- KeyCode::PageUp => self.scroll_release_notes(-8),
- KeyCode::PageDown => self.scroll_release_notes(8),
- KeyCode::Home => {
- if let Some(notes) = &mut self.state.release_notes {
- notes.scroll = 0;
- }
- }
- KeyCode::End => {
- let max_scroll = self.state.release_notes_max_scroll();
- if let Some(notes) = &mut self.state.release_notes {
- notes.scroll = max_scroll;
- }
- }
- _ => {
- if let Some(ModalAction::Close) = modal_action_from_key(&key, RELEASE_NOTES_ACTIONS)
- {
- self.dismiss_release_notes();
- }
- }
- }
- }
-
- pub(crate) fn handle_product_announcement_key(&mut self, key: KeyEvent) {
- match key.code {
- KeyCode::Up | KeyCode::Char('k') => self.scroll_product_announcement(-1),
- KeyCode::Down | KeyCode::Char('j') => self.scroll_product_announcement(1),
- KeyCode::PageUp => self.scroll_product_announcement(-8),
- KeyCode::PageDown => self.scroll_product_announcement(8),
- KeyCode::Home => {
- if let Some(announcement) = &mut self.state.product_announcement {
- announcement.scroll = 0;
- }
- }
- KeyCode::End => {
- let max_scroll = self.state.product_announcement_max_scroll();
- if let Some(announcement) = &mut self.state.product_announcement {
- announcement.scroll = max_scroll;
- }
- }
- _ => {
- if let Some(ModalAction::Close) = modal_action_from_key(&key, RELEASE_NOTES_ACTIONS)
- {
- self.dismiss_product_announcement();
- }
- }
- }
- }
-
- pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
- self.handle_mouse_from_input_source(super::LOCAL_INPUT_SOURCE, mouse);
- }
-
- pub(super) fn handle_mouse_from_input_source(
- &mut self,
- source_id: super::InputSourceId,
- mouse: MouseEvent,
- ) {
- match mouse.kind {
- MouseEventKind::Down(MouseButton::Left) => {
- self.pending_url_click_sources.remove(&source_id);
- }
- MouseEventKind::Drag(MouseButton::Left)
- if self.pending_url_click_sources.contains(&source_id) =>
- {
- return;
- }
- MouseEventKind::Up(MouseButton::Left)
- if self.pending_url_click_sources.remove(&source_id) =>
- {
- return;
- }
- _ => {}
- }
-
- if self.state.popup_pane.is_some() {
- self.handle_popup_mouse(mouse);
- return;
- }
- if self.handle_overlay_mouse(mouse) {
- return;
- }
-
- if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
- && self.state.on_sidebar_divider(mouse.column, mouse.row)
- {
- let now = std::time::Instant::now();
- let is_double_click = self
- .last_sidebar_divider_click
- .is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW);
- self.last_sidebar_divider_click = Some(now);
-
- if is_double_click {
- self.state.sidebar_width = self.state.default_sidebar_width;
- self.state.sidebar_width_source =
- crate::app::state::SidebarWidthSource::ConfigDefault;
- self.state.sidebar_width_auto = false;
- self.state.mark_session_dirty();
- self.state.drag = None;
- return;
- }
- }
-
- if self.handle_modified_url_click(source_id, mouse) {
- return;
- }
-
- let handled_pane_double_click = self.handle_pane_double_click(mouse);
- if !handled_pane_double_click {
- self.focus_pane_before_mouse_press(mouse);
- }
-
- let previous_agent_panel_sort = self.state.agent_panel_sort;
- let previous_settings_section = self.state.settings.section;
- if !handled_pane_double_click {
- if let Some(action) =
- self.state
- .handle_mouse(&mut self.terminal_runtimes, source_id, mouse)
- {
- match action {
- MouseAction::NewWorkspace => {
- self.begin_tui_workspace_create("tui.mouse.workspace.create")
- }
- MouseAction::Settings(action) => match action {
- SettingsAction::SaveTheme(name) => self.save_theme(&name),
- SettingsAction::SaveStatusIndicators(style) => {
- self.save_status_indicators(style)
- }
- SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
- SettingsAction::SaveToastDelivery(delivery) => {
- self.save_toast_delivery(delivery)
- }
- SettingsAction::SaveAgentBorderLabels(enabled) => {
- self.save_agent_border_labels(enabled)
- }
- SettingsAction::InstallRecommendedIntegrations => {
- self.install_recommended_integrations()
- }
- },
- MouseAction::FocusWorkspace { ws_idx } => {
- self.focus_workspace_idx_via_api(ws_idx)
- }
- MouseAction::FocusTab { tab_idx } => self.focus_tab_idx_via_api(tab_idx),
- MouseAction::FocusPane { ws_idx, pane_id } => {
- self.focus_pane_internal_via_api(ws_idx, pane_id)
- }
- MouseAction::FocusToastTarget => self.focus_toast_target_via_api(),
- MouseAction::MoveWorkspace {
- source_ws_idx,
- insert_idx,
- } => self.move_workspace_via_api(source_ws_idx, insert_idx),
- MouseAction::MoveWorkspaceBlock { params } => {
- self.move_workspace_block_via_api(params)
- }
- MouseAction::MoveTab {
- ws_idx,
- source_tab_idx,
- insert_idx,
- } => self.move_tab_via_api(ws_idx, source_tab_idx, insert_idx),
- MouseAction::SetSplitRatio { path, ratio } => {
- self.set_split_ratio_via_api(path, ratio)
- }
- MouseAction::RenameModal(action) => {
- self.apply_rename_mouse_action_via_api(action)
- }
- MouseAction::ConfirmCloseAccept => self.confirm_close_accept_via_api(),
- MouseAction::ContextMenu { menu, idx } => {
- self.apply_context_menu_action_via_api(menu, idx)
- }
- }
- }
- if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
- && self
- .state
- .selection
- .as_ref()
- .is_none_or(crate::selection::Selection::is_in_progress)
- {
- self.selection_highlight_clear_deadline = None;
- }
- }
- if previous_settings_section != crate::app::state::SettingsSection::Integrations
- && self.state.settings.section == crate::app::state::SettingsSection::Integrations
- {
- self.refresh_integration_recommendations();
- }
- if self.state.agent_panel_sort != previous_agent_panel_sort {
- self.save_agent_panel_sort(self.state.agent_panel_sort);
- }
-
- self.dispatch_pending_clipboard_write();
-
- // Sync autoscroll deadline with state (mouse handler may have
- // set or cleared selection_autoscroll during handle_mouse).
- if self.state.selection_autoscroll.is_none() {
- self.selection_autoscroll_deadline = None;
- } else if self.selection_autoscroll_deadline.is_none() {
- self.selection_autoscroll_deadline =
- Some(std::time::Instant::now() + super::SELECTION_AUTOSCROLL_INTERVAL);
- }
- }
-
- fn handle_popup_mouse(&mut self, mouse: MouseEvent) {
- let Some((_outer, inner)) =
- crate::ui::popup_pane_rects(&self.state, self.state.view.terminal_area)
- else {
- return;
- };
- if mouse.column < inner.x
- || mouse.column >= inner.x.saturating_add(inner.width)
- || mouse.row < inner.y
- || mouse.row >= inner.y.saturating_add(inner.height)
- {
- return;
- }
- let Some(rt) = self.popup_runtime() else {
- self.close_popup_pane();
- return;
- };
- let position = crate::input::mouse::Position::Cell {
- column: mouse.column.saturating_sub(inner.x),
- row: mouse.row.saturating_sub(inner.y),
- };
- let bytes = match mouse.kind {
- MouseEventKind::ScrollUp
- | MouseEventKind::ScrollDown
- | MouseEventKind::ScrollLeft
- | MouseEventKind::ScrollRight => match rt.wheel_routing() {
- Some(crate::pane::WheelRouting::MouseReport) => {
- rt.encode_mouse_wheel(mouse.kind, position, mouse.modifiers)
- }
- Some(crate::pane::WheelRouting::AlternateScroll) => {
- rt.encode_alternate_scroll(mouse.kind)
- }
- Some(crate::pane::WheelRouting::HostScroll) | None => {
- let lines_per_notch = self.state.mouse_scroll_lines;
- match mouse.kind {
- MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch),
- MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch),
- _ => {}
- }
- return;
- }
- },
- MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => {
- rt.encode_mouse_button(mouse.kind, position, mouse.modifiers)
- }
- MouseEventKind::Moved => rt.encode_mouse_motion(mouse.kind, position, mouse.modifiers),
- };
- let Some(bytes) = bytes else {
- return;
- };
- if !matches!(mouse.kind, MouseEventKind::Moved) {
- rt.scroll_reset();
- }
- if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
- warn!(err = %err, kind = ?mouse.kind, "failed to forward popup mouse event");
- }
- }
-
- fn focus_pane_before_mouse_press(&mut self, mouse: MouseEvent) {
- if !matches!(self.state.mode, Mode::Terminal | Mode::Resize)
- || !matches!(
- mouse.kind,
- MouseEventKind::Down(MouseButton::Left | MouseButton::Middle)
- )
- {
- return;
- }
-
- let Some(pane_id) = self
- .state
- .pane_at(mouse.column, mouse.row)
- .map(|info| info.id)
- else {
- return;
- };
- let Some(ws_idx) = self.state.active else {
- return;
- };
-
- // Focus through the runtime API before an application can consume its press.
- self.focus_pane_internal_via_api(ws_idx, pane_id);
- }
-
- fn handle_modified_url_click(
- &mut self,
- source_id: super::InputSourceId,
- mouse: MouseEvent,
- ) -> bool {
- self.handle_modified_url_click_with(source_id, mouse, crate::platform::open_url)
- }
-
- fn handle_modified_url_click_with(
- &mut self,
- source_id: super::InputSourceId,
- mouse: MouseEvent,
- open_url: impl FnOnce(&str) -> std::io::Result<Option<std::process::Child>>,
- ) -> bool {
- if self.state.mode != Mode::Terminal
- || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
- || !mouse.modifiers.contains(modified_url_click_modifier())
- {
- return false;
- }
-
- let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else {
- return false;
- };
- let viewport_row = mouse.row.saturating_sub(info.inner_rect.y);
- let col = mouse.column.saturating_sub(info.inner_rect.x);
- let Some(url) =
- self.state
- .url_at_pane_cell(&self.terminal_runtimes, info.id, viewport_row, col)
- else {
- return false;
- };
-
- let plugin_handled = match self.invoke_plugin_link_handler_for_url(&url, info.id) {
- Ok(handled) => handled,
- Err(err) => {
- tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler");
- false
- }
- };
- if !plugin_handled && crate::app::actions::safe_web_url(&url).is_none() {
- return false;
- }
-
- self.last_pane_click = None;
- self.pending_url_click_sources.insert(source_id);
- if plugin_handled {
- return true;
- }
- match open_url(&url) {
- Ok(Some(child)) => self.detached_process_children.push(child),
- Ok(None) => {}
- Err(err) => {
- tracing::warn!(err = %err, url = %url, "failed to open pane URL");
- }
- }
- true
- }
-
- fn handle_pane_double_click(&mut self, mouse: MouseEvent) -> bool {
- // A pane press stops being a double-click candidate once it becomes
- // a drag or completes as a real text selection.
- match mouse.kind {
- MouseEventKind::Drag(MouseButton::Left) => {
- self.last_pane_click = None;
- return false;
- }
- MouseEventKind::Up(MouseButton::Left)
- if self
- .state
- .selection
- .as_ref()
- .is_some_and(|selection| selection.is_visible()) =>
- {
- self.last_pane_click = None;
- return false;
- }
- _ => {}
- }
-
- // Only terminal-pane left-clicks can start this gesture; other clicks
- // should keep their existing mouse behavior and clear stale candidates.
- let Some(click) = self.pane_click_candidate(mouse) else {
- return false;
- };
-
- // Require the second click to land near the first click in the same pane
- // and within the double-click window so adjacent interactions do not select a word.
- if !self.take_pane_double_click(click) {
- return false;
- }
-
- self.select_double_clicked_word(click)
- }
-
- fn pane_click_candidate(&mut self, mouse: MouseEvent) -> Option<PaneClickState> {
- if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
- return None;
- }
-
- if !mouse.modifiers.is_empty() {
- self.last_pane_click = None;
- return None;
- }
-
- if self.state.mode != Mode::Terminal {
- self.last_pane_click = None;
- return None;
- }
-
- let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else {
- self.last_pane_click = None;
- return None;
- };
-
- Some(PaneClickState {
- pane_id: info.id,
- viewport_row: mouse.row - info.inner_rect.y,
- col: mouse.column - info.inner_rect.x,
- at: std::time::Instant::now(),
- })
- }
-
- fn take_pane_double_click(&mut self, click: PaneClickState) -> bool {
- if !self
- .last_pane_click
- .is_some_and(|last| last.is_double_click_for(click))
- {
- self.last_pane_click = Some(click);
- return false;
- }
-
- self.last_pane_click = None;
- true
- }
-
- fn select_double_clicked_word(&mut self, click: PaneClickState) -> bool {
- let selected = self.state.select_word_at_pane_cell(
- &self.terminal_runtimes,
- click.pane_id,
- click.viewport_row,
- click.col,
- );
- if selected {
- self.selection_highlight_clear_deadline = self
- .state
- .copy_on_select
- .then(|| std::time::Instant::now() + super::PANE_COPY_HIGHLIGHT_DURATION);
- }
- selected
- }
-}
-
-pub(crate) fn is_modal_paste_shortcut(key: &KeyEvent) -> bool {
- if !matches!(key.code, KeyCode::Char('v' | 'V')) {
- return false;
- }
-
- #[cfg(target_os = "macos")]
- {
- key.modifiers.contains(KeyModifiers::SUPER) || key.modifiers.contains(KeyModifiers::CONTROL)
- }
-
- #[cfg(not(target_os = "macos"))]
- {
- key.modifiers.contains(KeyModifiers::CONTROL)
- }
-}
-
-pub(crate) fn modal_paste_target_active(state: &AppState) -> bool {
- match state.mode {
- Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane | Mode::NewLinkedWorktree => {
- true
- }
- Mode::OpenExistingWorktree => state
- .worktree_open
- .as_ref()
- .is_some_and(|open| open.search_focused),
- Mode::Navigator => state.navigator.search_focused,
- Mode::KeybindHelp => state.keybind_help.search_focused,
- Mode::Copy => state
- .copy_mode
- .as_ref()
- .is_some_and(|copy_mode| copy_mode.search.prompt.is_some()),
- _ => false,
- }
-}
-
-// ---------------------------------------------------------------------------
-// Mouse handling
-// ---------------------------------------------------------------------------
-
-// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App
-impl AppState {
- #[cfg(test)]
- pub(crate) fn split_pane(
- &mut self,
- terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry,
- direction: Direction,
- ) {
- // Actual PTY spawning happens in Workspace::split_focused
- // which needs events channel — this is called from navigate_key
- // where we don't have async context, so the workspace handles it
- let (rows, cols) = self.estimate_pane_size();
- let new_rows = (rows / 2).max(4);
- let new_cols = (cols / 2).max(10);
-
- let follow_cwd = self
- .active
- .and_then(|i| self.workspaces.get(i))
- .and_then(|ws| {
- let tab = ws.active_tab()?;
- let terminal_id = tab.terminal_id(tab.layout.focused())?;
- super::creation::launch_cwd_for_terminal(
- terminal_id,
- &self.terminals,
- terminal_runtimes,
- )
- });
- let cwd = Some(super::creation::resolve_new_terminal_cwd(
- &self.new_terminal_cwd,
- follow_cwd,
- ));
-
- let previous_focus = self.current_pane_focus_target();
- if let Some(ws_idx) = self.active {
- let Some(ws) = self.workspaces.get_mut(ws_idx) else {
- return;
- };
- if let Ok(new_pane) = ws.split_focused(
- direction,
- new_rows,
- new_cols,
- cwd,
- self.pane_scrollback_limit_bytes,
- self.host_terminal_theme,
- self.host_terminal_appearance,
- crate::pane::PaneShellConfig::new(&self.default_shell, self.shell_mode),
- Vec::new(),
- ) {
- let new_id = new_pane.pane_id;
- terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime);
- self.remove_alias_shadowed_by_new_pane(new_id);
- self.terminals
- .insert(new_pane.terminal.id.clone(), new_pane.terminal);
- self.record_pane_focus_change(previous_focus, ws_idx, new_id);
- self.mark_session_dirty();
- self.mode = Mode::Terminal;
- }
- }
- }
-}
-
-#[cfg(test)]
-fn state_with_workspaces(names: &[&str]) -> AppState {
- let mut state = AppState::test_new();
- state.workspaces = names
- .iter()
- .map(|name| crate::workspace::Workspace::test_new(name))
- .collect();
- if !state.workspaces.is_empty() {
- state.active = Some(0);
- state.selected = 0;
- state.mode = Mode::Navigate;
- }
- state
-}
-
-#[cfg(test)]
-fn app_for_mouse_test() -> App {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &crate::config::Config::default(),
- true,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- app.state.mode = Mode::Terminal;
- app.state.update_available = None;
- app.state.latest_release_notes_available = false;
- app.state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 26, 20);
- app.state.view.terminal_area = ratatui::layout::Rect::new(26, 0, 80, 20);
- app
-}
-
-#[cfg(test)]
-fn mouse(
- kind: crossterm::event::MouseEventKind,
- col: u16,
- row: u16,
-) -> crossterm::event::MouseEvent {
- crossterm::event::MouseEvent {
- kind,
- column: col,
- row,
- modifiers: crossterm::event::KeyModifiers::empty(),
- }
-}
-
-#[cfg(test)]
-fn numbered_lines_bytes(count: usize) -> Vec<u8> {
- (0..count)
- .map(|i| format!("{i:06}\r\n"))
- .collect::<String>()
- .into_bytes()
-}
-
-#[cfg(test)]
-fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot {
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- crate::persist::capture(
- &state.workspaces,
- &state.terminals,
- &terminal_runtimes,
- state.active,
- state.selected,
- state.sidebar_width,
- state.sidebar_section_split,
- state.collapsed_space_keys.clone(),
- )
-}
-
-#[cfg(test)]
-fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option<f32> {
- match &snapshot.workspaces.first()?.tabs.first()?.layout {
- crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio),
- crate::persist::LayoutSnapshot::Pane(_) => None,
- }
-}
-
-#[cfg(test)]
-fn unique_temp_path(name: &str) -> std::path::PathBuf {
- let nanos = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_nanos())
- .unwrap_or(0);
- std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
-}
-
-#[cfg(test)]
-#[cfg(unix)]
-fn wait_for_file(path: &std::path::Path) -> String {
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
- while std::time::Instant::now() < deadline {
- if let Ok(content) = std::fs::read_to_string(path) {
- if !content.is_empty() {
- return content;
- }
- }
- std::thread::sleep(std::time::Duration::from_millis(20));
- }
- panic!("timed out waiting for {}", path.display());
-}
-
-#[cfg(test)]
-#[cfg(unix)]
-async fn wait_for_detached_process_reap(app: &mut App, pid: u32) -> bool {
- let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
- while crate::platform::process_exists(pid) && tokio::time::Instant::now() < deadline {
- app.reap_finished_detached_processes();
- tokio::time::sleep(std::time::Duration::from_millis(20)).await;
- }
- app.reap_finished_detached_processes();
- !crate::platform::process_exists(pid)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn test_app() -> App {
- App::new(
- &crate::config::Config::default(),
- true,
- None,
- tokio::sync::mpsc::unbounded_channel().1,
- crate::api::EventHub::default(),
- )
- }
-
- #[tokio::test]
- async fn paste_routes_to_rename_modal_input() {
- let mut app = test_app();
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::RenameTab;
- app.state.name_input = "2".into();
- app.state.name_input_replace_on_type = true;
-
- app.handle_paste("feature/logs".into()).await;
-
- assert_eq!(app.state.name_input, "feature/logs");
- assert!(!app.state.name_input_replace_on_type);
- }
-
- #[tokio::test]
- async fn paste_routes_to_keybind_help_query_only_when_searching() {
- let mut app = test_app();
- app.state.mode = Mode::KeybindHelp;
- app.handle_paste("ignored".into()).await;
- assert!(app.state.keybind_help.query.is_empty());
-
- app.state.keybind_help.search_focused = true;
- app.state.keybind_help.scroll = 3;
- app.handle_paste("work\nspace".into()).await;
-
- assert_eq!(app.state.keybind_help.query, "workspace");
- assert_eq!(app.state.keybind_help.scroll, 0);
- }
-
- #[tokio::test]
- async fn paste_routes_to_new_linked_worktree_input() {
- let mut app = test_app();
- app.state.mode = Mode::NewLinkedWorktree;
- app.state.name_input = "generated-branch".into();
- app.state.name_input_replace_on_type = true;
- app.state.worktree_create = Some(crate::app::state::WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: "/repo/herdr".into(),
- source_existing_membership: None,
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: "generated-branch".into(),
- checkout_path: "/repo/herdr-generated-branch".into(),
- error: None,
- creating: false,
- });
-
- app.handle_paste("feature/linear-302".into()).await;
-
- assert_eq!(app.state.name_input, "feature/linear-302");
- assert_eq!(
- app.state
- .worktree_create
- .as_ref()
- .map(|create| create.branch.as_str()),
- Some("feature/linear-302")
- );
- }
-
- #[test]
- fn modal_paste_shortcut_matches_platform_primary_v() {
- #[cfg(target_os = "macos")]
- let modifiers = KeyModifiers::SUPER;
- #[cfg(not(target_os = "macos"))]
- let modifiers = KeyModifiers::CONTROL;
-
- assert!(is_modal_paste_shortcut(&KeyEvent::new(
- KeyCode::Char('v'),
- modifiers
- )));
- assert!(is_modal_paste_shortcut(&KeyEvent::new(
- KeyCode::Char('V'),
- modifiers | KeyModifiers::SHIFT
- )));
- assert!(!is_modal_paste_shortcut(&KeyEvent::new(
- KeyCode::Char('v'),
- KeyModifiers::ALT
- )));
- }
-
- #[test]
- fn modal_paste_target_is_active_only_for_text_inputs() {
- let mut state = AppState::test_new();
-
- state.mode = Mode::RenameTab;
- assert!(modal_paste_target_active(&state));
-
- state.mode = Mode::Navigator;
- state.navigator.search_focused = false;
- assert!(!modal_paste_target_active(&state));
- state.navigator.search_focused = true;
- assert!(modal_paste_target_active(&state));
-
- state.mode = Mode::KeybindHelp;
- state.keybind_help.search_focused = false;
- assert!(!modal_paste_target_active(&state));
- state.keybind_help.search_focused = true;
- assert!(modal_paste_target_active(&state));
-
- state.mode = Mode::ConfirmClose;
- assert!(!modal_paste_target_active(&state));
- }
-}