diff options
Diffstat (limited to 'src/app/input/terminal.rs')
| -rw-r--r-- | src/app/input/terminal.rs | 2181 |
1 files changed, 0 insertions, 2181 deletions
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs deleted file mode 100644 index 0b910621..00000000 --- a/src/app/input/terminal.rs +++ /dev/null @@ -1,2181 +0,0 @@ -use bytes::Bytes; -use crossterm::event::KeyCode; -use tracing::{debug, warn}; - -use crate::{ - app::{App, InputSourceId, Mode, TerminalInputTarget}, - input::TerminalKey, -}; - -struct PreparedPaneInput { - ws_idx: usize, - pane_id: crate::layout::PaneId, - target: TerminalInputTarget, - bytes: Bytes, -} - -enum PreparedPopupInput { - NotOpen, - Consumed, - Bytes { - target: TerminalInputTarget, - bytes: Bytes, - }, -} - -fn is_modifier_only_key(code: &KeyCode) -> bool { - matches!(code, KeyCode::Modifier(_)) -} - -impl App { - #[cfg(test)] - pub(crate) fn handle_terminal_key_headless( - &mut self, - key: TerminalKey, - ) -> Option<TerminalInputTarget> { - self.handle_terminal_key_headless_from(crate::app::LOCAL_INPUT_SOURCE, key) - } - - pub(crate) fn handle_terminal_key_headless_from( - &mut self, - source_id: InputSourceId, - key: TerminalKey, - ) -> Option<TerminalInputTarget> { - match self.prepare_popup_key_forward(key.clone()) { - PreparedPopupInput::NotOpen => {} - PreparedPopupInput::Consumed => return None, - PreparedPopupInput::Bytes { target, bytes } => { - let Some(runtime) = self.popup_runtime() else { - self.close_popup_pane(); - return None; - }; - return runtime.try_send_bytes(bytes).is_ok().then_some(target); - } - } - - let input = self.prepare_terminal_key_forward(source_id, key)?; - let sent = self - .lookup_runtime_sender(input.ws_idx, input.pane_id) - .is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok()); - sent.then_some(input.target) - } - - fn prepare_terminal_key_forward( - &mut self, - source_id: InputSourceId, - key: TerminalKey, - ) -> Option<PreparedPaneInput> { - let key_event = key.as_key_event(); - if self.try_copy_retained_selection(source_id, key.clone()) { - return None; - } - - self.state.clear_selection(); - self.selection_autoscroll_deadline = None; - self.state.update_dismissed = true; - - if let Some(action) = - super::terminal_direct_non_indexed_navigation_action(&self.state, &key) - { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - action = ?action, - "intercepted terminal direct keybinding before forwarding to pane" - ); - if action == super::navigate::NavigateAction::EditScrollback { - self.launch_focused_scrollback_editor(); - } else { - self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct); - } - return None; - } - - if let Some(binding) = super::navigate::command_for_key( - &self.state, - &key, - super::navigate::BindingDispatch::Direct, - ) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - command = %binding.label, - "intercepted terminal direct custom command before forwarding to pane" - ); - self.launch_custom_command(binding, super::navigate::ActionContext::Direct); - return None; - } - - if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, &key) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - action = ?action, - "intercepted terminal direct indexed keybinding before forwarding to pane" - ); - self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct); - return None; - } - - if self.state.is_prefix_key(&key) { - self.state.mode = Mode::Prefix; - return None; - } - - if is_modifier_only_key(&key_event.code) { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - "dropping modifier-only terminal key event instead of forwarding it to pane" - ); - return None; - } - - let ws_idx = self.state.active?; - let ws = self.state.workspaces.get(ws_idx)?; - let pane_id = ws.focused_pane_id()?; - let terminal_id = ws.terminal_id(pane_id)?.clone(); - let rt = - self.state - .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?; - - // Intercept plain PageUp/PageDown presses for pane scrollback only - // when the focused pane looks like a shell transcript. Normal-screen - // pagers such as `less -X` keep the primary screen but enter - // application cursor mode while they own special keys. - // Modified page keys are pane shortcuts, and release events should not - // produce a second host-scroll action. - // Only intercept when we know the pane state; if input_state is unknown, - // fail-open and forward the key to the pane. - if matches!(key_event.code, KeyCode::PageUp | KeyCode::PageDown) - && key_event.modifiers.is_empty() - { - if let Some(host_scroll) = rt.plain_page_keys_use_host_scrollback() { - if host_scroll { - if key_event.kind == crossterm::event::KeyEventKind::Release { - return None; - } - if matches!( - key_event.kind, - crossterm::event::KeyEventKind::Press - | crossterm::event::KeyEventKind::Repeat - ) { - let lines = self - .state - .pane_info_by_id(pane_id) - .map(|info| info.inner_rect.height as usize) - .unwrap_or(10) - .max(1); - if key_event.code == KeyCode::PageUp { - self.state - .scroll_pane_up(&self.terminal_runtimes, pane_id, lines); - } else { - self.state - .scroll_pane_down(&self.terminal_runtimes, pane_id, lines); - } - debug!( - code = ?key_event.code, - lines, - "intercepted page key for pane scrollback" - ); - return None; - } - } - } - } - - rt.scroll_reset(); - let protocol = rt.keyboard_protocol(); - let bytes = rt.encode_terminal_key(key.clone()); - - if matches!(key_event.code, KeyCode::Esc) - || key_event - .modifiers - .contains(crossterm::event::KeyModifiers::ALT) - { - debug!( - code = ?key_event.code, - modifiers = ?key_event.modifiers, - kind = ?key_event.kind, - protocol = ?protocol, - encoded = ?bytes, - "forwarding potentially-ambiguous terminal key to pane" - ); - } - - if bytes.is_empty() { - if key.kind != crossterm::event::KeyEventKind::Release - && !matches!( - key.code, - KeyCode::CapsLock - | KeyCode::ScrollLock - | KeyCode::NumLock - | KeyCode::PrintScreen - | KeyCode::Pause - | KeyCode::Menu - | KeyCode::KeypadBegin - | KeyCode::Media(_) - | KeyCode::Modifier(_) - ) - { - warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding"); - } - return None; - } - - Some(PreparedPaneInput { - ws_idx, - pane_id, - target: TerminalInputTarget { terminal_id }, - bytes: Bytes::from(bytes), - }) - } - - fn prepare_popup_key_forward(&mut self, key: TerminalKey) -> PreparedPopupInput { - if self.state.popup_pane.is_none() { - return PreparedPopupInput::NotOpen; - } - let Some(terminal_id) = self - .state - .popup_pane - .as_ref() - .map(|popup| popup.terminal_id.clone()) - else { - return PreparedPopupInput::NotOpen; - }; - let Some(rt) = self.terminal_runtimes.get(&terminal_id) else { - self.close_popup_pane(); - return PreparedPopupInput::Consumed; - }; - rt.scroll_reset(); - let bytes = rt.encode_terminal_key(key.clone()); - self.state.mode = Mode::Terminal; - if bytes.is_empty() { - PreparedPopupInput::Consumed - } else { - PreparedPopupInput::Bytes { - target: TerminalInputTarget { terminal_id }, - bytes: Bytes::from(bytes), - } - } - } - - pub(crate) fn host_keyboard_report_all_requested(&self) -> bool { - if self.state.popup_pane.is_none() - && matches!(self.state.mode, Mode::Prefix | Mode::Navigate) - { - return true; - } - - let runtime = if self.state.popup_pane.is_some() { - self.popup_runtime() - } else if self.state.mode == Mode::Terminal { - self.state.active.and_then(|ws_idx| { - self.state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - }) - } else { - None - }; - - runtime.is_some_and(crate::terminal::TerminalRuntime::keyboard_report_all_requested) - } - - fn terminal_input_runtime( - &self, - target: &TerminalInputTarget, - ) -> Option<&crate::terminal::TerminalRuntime> { - if let Some(runtime) = self.terminal_runtimes.get(&target.terminal_id) { - return Some(runtime); - } - #[cfg(test)] - for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() { - for tab in &workspace.tabs { - for (&pane_id, pane) in &tab.panes { - if pane.attached_terminal_id == target.terminal_id { - return self.state.runtime_for_pane_in_workspace( - &self.terminal_runtimes, - ws_idx, - pane_id, - ); - } - } - } - } - None - } - - pub(crate) fn forward_terminal_key_to_target_headless( - &self, - target: &TerminalInputTarget, - key: TerminalKey, - ) -> bool { - let Some(runtime) = self.terminal_input_runtime(target) else { - return false; - }; - let bytes = runtime.encode_terminal_key(key.clone()); - bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok() - } - - pub(crate) async fn forward_terminal_key_to_target( - &self, - target: &TerminalInputTarget, - key: TerminalKey, - ) -> bool { - let Some(runtime) = self.terminal_input_runtime(target) else { - return false; - }; - let bytes = runtime.encode_terminal_key(key.clone()); - bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok() - } - - fn take_pressed_keys_for_source( - &mut self, - source_id: crate::app::InputSourceId, - ) -> Vec<super::ForwardedInputLease> { - self.input_leases.remove_source(source_id) - } - - pub(crate) fn release_input_target_headless(&mut self, target: &TerminalInputTarget) { - for pressed in self.input_leases.remove_target(target) { - let release = pressed - .key - .with_kind(crossterm::event::KeyEventKind::Release); - let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release); - } - } - - pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) { - // Pending URL clicks survive this call; see clear_input_source. - self.state.clear_chrome_gesture(source_id); - for pressed in self.take_pressed_keys_for_source(source_id) { - let release = pressed - .key - .with_kind(crossterm::event::KeyEventKind::Release); - let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release); - } - } - - pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) { - // Pending URL clicks survive this call; see clear_input_source. - self.state.clear_chrome_gesture(source_id); - for pressed in self.take_pressed_keys_for_source(source_id) { - let release = pressed - .key - .with_kind(crossterm::event::KeyEventKind::Release); - let _ = self - .forward_terminal_key_to_target(&pressed.target, release) - .await; - } - } - - pub(super) async fn handle_terminal_key( - &mut self, - key: TerminalKey, - ) -> Option<TerminalInputTarget> { - match self.prepare_popup_key_forward(key.clone()) { - PreparedPopupInput::NotOpen => {} - PreparedPopupInput::Consumed => return None, - PreparedPopupInput::Bytes { target, bytes } => { - let Some(runtime) = self.popup_runtime() else { - self.close_popup_pane(); - return None; - }; - return runtime.send_bytes(bytes).await.is_ok().then_some(target); - } - } - - let input = self.prepare_terminal_key_forward(crate::app::LOCAL_INPUT_SOURCE, key)?; - let sent = if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) { - runtime.send_bytes(input.bytes).await.is_ok() - } else { - false - }; - sent.then_some(input.target) - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - #[cfg(target_os = "linux")] - use super::super::wait_for_detached_process_reap; - use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes}; - #[cfg(unix)] - use super::super::{unique_temp_path, wait_for_file}; - use super::*; - use crate::{config::Config, events::AppEvent, workspace::Workspace}; - - #[cfg(unix)] - fn app_with_spawned_workspace() -> 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.default_shell = "/bin/sh".into(); - 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"); - 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; - app - } - - #[cfg(unix)] - fn shutdown_test_runtimes(app: &mut App) { - for (_, runtime) in app.terminal_runtimes.drain() { - runtime.shutdown(); - } - } - - fn app_with_screen_bytes(bytes: &[u8]) -> (App, crate::layout::PaneInfo) { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width, - info.inner_rect.height, - bytes, - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, info) - } - - fn double_click(app: &mut App, col: u16, row: u16) { - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - } - - fn modified_mouse( - kind: MouseEventKind, - col: u16, - row: u16, - modifiers: KeyModifiers, - ) -> crossterm::event::MouseEvent { - crossterm::event::MouseEvent { - kind, - column: col, - row, - modifiers, - } - } - - fn clipboard_write_content(app: &mut App) -> Vec<u8> { - match app.event_rx.try_recv().expect("clipboard write event") { - AppEvent::ClipboardWrite { content } => content, - event => panic!("unexpected event: {event:?}"), - } - } - - fn assert_visible_selection(app: &App) { - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_visible)); - } - - #[cfg(unix)] - fn install_test_link_handler(app: &mut App) { - let plugin_root = std::env::temp_dir(); - app.state.installed_plugins = std::collections::HashMap::from([( - "example.links".to_string(), - crate::api::schema::InstalledPluginInfo { - plugin_id: "example.links".into(), - name: "Links".into(), - version: "0.1.0".into(), - min_herdr_version: "0.6.10".into(), - description: None, - manifest_path: plugin_root.join("herdr-plugin.toml").display().to_string(), - plugin_root: plugin_root.display().to_string(), - enabled: true, - platforms: None, - build: Vec::new(), - startup: Vec::new(), - actions: vec![crate::api::schema::PluginManifestAction { - id: "open".into(), - title: "Open link".into(), - description: None, - contexts: Vec::new(), - platforms: None, - command: vec!["sh".into(), "-c".into(), ":".into()], - }], - events: Vec::new(), - panes: Vec::new(), - link_handlers: vec![crate::api::schema::PluginManifestLinkHandler { - id: "github-issue".into(), - title: "Open GitHub issue".into(), - pattern: "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(), - action: "open".into(), - platforms: None, - }], - source: crate::api::schema::PluginSourceInfo::default(), - warnings: Vec::new(), - }, - )]); - } - - #[tokio::test] - async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - let start_row = info.inner_rect.y; - let start_col = info.inner_rect.x + 2; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - start_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - start_col, - info.inner_rect.y.saturating_sub(1), - )); - - let end_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after drag"); - assert_eq!( - end_metrics.offset_from_bottom, - start_metrics.offset_from_bottom + 3 - ); - - let selection = app.state.selection.as_ref().expect("selection after drag"); - assert!(selection.is_visible()); - assert_eq!( - selection.ordered_cells(), - ( - ( - (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, - 2, - ), - (start_metrics.max_offset_from_bottom as u32, 2), - ) - ); - } - - #[tokio::test] - async fn releasing_dragged_selection_clears_highlight_after_copy() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let row = info.inner_rect.y; - let start_col = info.inner_rect.x + 1; - let end_col = info.inner_rect.x + 4; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - assert!(app.state.selection.is_some()); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn drag_copy_then_click_does_not_reuse_double_click_candidate() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let row = info.inner_rect.y; - let start_col = info.inner_rect.x; - let end_col = info.inner_rect.x + 4; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - assert!(app.last_pane_click.is_some()); - - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - assert!(app.last_pane_click.is_none()); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - assert!(app.last_pane_click.is_none()); - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - - assert!(app.last_pane_click.is_some()); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn double_click_selects_and_copies_word() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta-gamma_delta@omega"); - let col = info.inner_rect.x + 13; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!(clipboard_write_content(&mut app), b"beta-gamma_delta@omega"); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn copy_on_select_disabled_keeps_drag_selection_without_copying() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - app.state.copy_on_select = false; - let row = info.inner_rect.y; - let start_col = info.inner_rect.x; - let end_col = info.inner_rect.x + 4; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - assert_visible_selection(&app); - assert!(!app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_finalized)); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - - assert_visible_selection(&app); - assert_eq!( - app.state - .selection - .as_ref() - .map(crate::selection::Selection::ordered_cells), - Some(((0, 0), (0, 4))) - ); - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_finalized)); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn copy_on_select_disabled_retains_double_clicked_word_until_shortcut() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - app.state.copy_on_select = false; - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - - double_click(&mut app, col, row); - - assert_visible_selection(&app); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert!(app.event_rx.try_recv().is_err()); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - )); - - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn new_drag_cancels_stale_double_click_highlight_deadline() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let row = info.inner_rect.y; - let word_col = info.inner_rect.x + 2; - - double_click(&mut app, word_col, row); - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - let stale_deadline = app - .selection_highlight_clear_deadline - .expect("double-click highlight deadline"); - app.state.copy_on_select = false; - - let start_col = info.inner_rect.x + 6; - let end_col = info.inner_rect.x + 9; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - assert!(app.selection_highlight_clear_deadline.is_none()); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - - assert_visible_selection(&app); - assert!(!app - .clear_due_selection_highlight(stale_deadline + std::time::Duration::from_millis(1))); - assert_visible_selection(&app); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn ignored_left_down_keeps_double_click_highlight_deadline() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - - double_click(&mut app, col, row); - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - let deadline = app - .selection_highlight_clear_deadline - .expect("double-click highlight deadline"); - app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "finished".into(), - context: "missing".into(), - position: None, - target: Some(crate::app::state::ToastTarget { - workspace_id: "missing".into(), - pane_id: info.id, - }), - }); - app.state.view.toast_hit_area = Rect::new(0, 0, 1, 1); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0)); - - assert_visible_selection(&app); - assert_eq!(app.selection_highlight_clear_deadline, Some(deadline)); - assert!(app.clear_due_selection_highlight(deadline + std::time::Duration::from_millis(1))); - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn double_click_uses_display_columns_for_wide_chars() { - let (mut app, info) = app_with_screen_bytes("echo 你好-world done".as_bytes()); - let col = info.inner_rect.x + 8; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!(clipboard_write_content(&mut app), "你好-world".as_bytes()); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn double_click_copies_quoted_path_without_quotes() { - let line = r#"cat "/tmp/build output/log.txt""#; - let (mut app, info) = app_with_screen_bytes(line.as_bytes()); - let col = info.inner_rect.x + line.find("output").expect("path segment") as u16; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!( - clipboard_write_content(&mut app), - b"/tmp/build output/log.txt" - ); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn double_click_excludes_trailing_punctuation() { - let (mut app, info) = app_with_screen_bytes(b"done."); - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - - assert_eq!(clipboard_write_content(&mut app), b"done"); - assert_visible_selection(&app); - } - - #[tokio::test] - async fn modified_pane_click_does_not_seed_double_click_copy() { - let (mut app, info) = app_with_screen_bytes(b"alpha beta"); - let col = info.inner_rect.x + 7; - let row = info.inner_rect.y; - - app.handle_mouse(modified_mouse( - MouseEventKind::Down(MouseButton::Left), - col, - row, - KeyModifiers::CONTROL, - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - - assert!(app.event_rx.try_recv().is_err()); - assert!(app.selection_highlight_clear_deadline.is_none()); - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn ctrl_click_url_reaps_failed_opener() { - let opener_dir = unique_temp_path("url-opener"); - let record_path = opener_dir.join("record"); - let opener_path = opener_dir.join("xdg-open"); - std::fs::create_dir_all(&opener_dir).expect("fake opener directory"); - std::fs::write( - &opener_path, - "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$1\" > \"$2\"\nexit 3\n", - ) - .expect("fake opener script"); - - let url = "https://example.com/akbash-2903"; - let line = format!("see {url}"); - let (mut app, info) = app_with_screen_bytes(line.as_bytes()); - let col = info.inner_rect.x + line.find("example").expect("url host") as u16; - let handled = app.handle_modified_url_click_with( - 41, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - col, - info.inner_rect.y, - KeyModifiers::CONTROL, - ), - |clicked_url| { - std::process::Command::new("/bin/sh") - .arg(&opener_path) - .arg(clicked_url) - .arg(&record_path) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .map(Some) - }, - ); - assert!(handled); - - let record = wait_for_file(&record_path); - let mut lines = record.lines(); - let pid = lines - .next() - .expect("opener pid") - .parse::<u32>() - .expect("numeric opener pid"); - assert_eq!(lines.next(), Some(url)); - - let reaped = wait_for_detached_process_reap(&mut app, pid).await; - if !reaped { - unsafe { - libc::waitpid(pid as libc::pid_t, std::ptr::null_mut(), 0); - } - } - - let _ = std::fs::remove_dir_all(&opener_dir); - assert!(reaped, "failed URL opener child {pid} was not reaped"); - } - - #[cfg(unix)] - #[tokio::test] - async fn ctrl_click_url_does_not_forward_release_to_mouse_reporting_pane() { - let line = "see https://github.com/herdrdev/herdr/issues/1761"; - let col = line.find("github").expect("url host") as u16; - let (mut app, info) = app_with_screen_bytes(b""); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}"); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - screen.as_bytes(), - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - install_test_link_handler(&mut app); - let mut send_mouse = |source_id, kind, column, modifiers| { - app.handle_mouse_from_input_source( - source_id, - modified_mouse(kind, column, info.inner_rect.y, modifiers), - ); - }; - let down = MouseEventKind::Down(MouseButton::Left); - let up = MouseEventKind::Up(MouseButton::Left); - let url_x = info.inner_rect.x + col; - - send_mouse(41, down, url_x, KeyModifiers::CONTROL); - send_mouse(42, down, info.inner_rect.x, KeyModifiers::empty()); - send_mouse(42, up, info.inner_rect.x, KeyModifiers::empty()); - send_mouse(41, up, url_x, KeyModifiers::empty()); - - assert_eq!(app.state.plugin_command_logs.len(), 1); - assert_eq!( - input_rx.try_recv().expect("other source mouse down"), - Bytes::from_static(b"\x1b[<0;1;1M") - ); - assert_eq!( - input_rx.try_recv().expect("other source mouse up"), - Bytes::from_static(b"\x1b[<0;1;1m") - ); - assert!( - input_rx.try_recv().is_err(), - "handled URL click must not leave an unmatched release for the pane" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn outer_focus_loss_does_not_forward_pending_url_click_release_to_pane() { - let line = "see https://github.com/herdrdev/herdr/issues/1761"; - let col = line.find("github").expect("url host") as u16; - let (mut app, info) = app_with_screen_bytes(b""); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}"); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - screen.as_bytes(), - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - install_test_link_handler(&mut app); - let url_x = info.inner_rect.x + col; - - app.handle_mouse_from_input_source( - 41, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - url_x, - info.inner_rect.y, - KeyModifiers::CONTROL, - ), - ); - assert_eq!(app.state.plugin_command_logs.len(), 1); - - // Opening the URL raises the browser, so the host terminal loses focus - // while the button is still down. - app.route_client_events_from( - 41, - vec![crate::raw_input::RawInputEvent::OuterFocusLost], - false, - ); - - app.handle_mouse_from_input_source( - 41, - modified_mouse( - MouseEventKind::Up(MouseButton::Left), - url_x, - info.inner_rect.y, - KeyModifiers::empty(), - ), - ); - - assert!( - input_rx.try_recv().is_err(), - "focus loss must not clear a pending URL click, so its release must stay out of the pane" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn ctrl_click_osc8_file_url_invokes_plugin_link_handler() { - let uri = "file:///tmp/herdr-file-repro.txt"; - let screen = format!("\x1b]8;;{uri}\x1b\\FILE\x1b]8;;\x1b\\"); - let (mut app, info) = app_with_screen_bytes(screen.as_bytes()); - install_test_link_handler(&mut app); - app.state - .installed_plugins - .get_mut("example.links") - .expect("test plugin") - .link_handlers[0] - .pattern = r"^file:///tmp/herdr-file-repro\.txt$".into(); - - let handled = app.handle_modified_url_click_with( - 41, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - info.inner_rect.x + 1, - info.inner_rect.y, - KeyModifiers::CONTROL, - ), - |_| panic!("matched file link should not use the system URL opener"), - ); - - assert!(handled); - let log = app - .state - .plugin_command_logs - .last() - .expect("ctrl-click should start plugin link handler"); - assert_eq!(log.plugin_id, "example.links"); - assert_eq!(log.action_id.as_deref(), Some("open")); - - let (mut unmatched_app, unmatched_info) = app_with_screen_bytes(screen.as_bytes()); - install_test_link_handler(&mut unmatched_app); - let unmatched_handled = unmatched_app.handle_modified_url_click_with( - 42, - modified_mouse( - MouseEventKind::Down(MouseButton::Left), - unmatched_info.inner_rect.x + 1, - unmatched_info.inner_rect.y, - KeyModifiers::CONTROL, - ), - |_| panic!("unmatched file link should not use the system URL opener"), - ); - - assert!(!unmatched_handled); - assert!(unmatched_app.state.plugin_command_logs.is_empty()); - } - - #[cfg(unix)] - #[tokio::test] - async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() { - let line = "see https://github.com/herdrdev/herdr/issues/398"; - let col = line.find("github").expect("url host") as u16; - - let (mut ctrl_app, ctrl_info) = app_with_screen_bytes(line.as_bytes()); - install_test_link_handler(&mut ctrl_app); - ctrl_app.handle_mouse(modified_mouse( - MouseEventKind::Down(MouseButton::Left), - ctrl_info.inner_rect.x + col, - ctrl_info.inner_rect.y, - KeyModifiers::CONTROL, - )); - - let ctrl_log = ctrl_app - .state - .plugin_command_logs - .last() - .expect("ctrl-click should start plugin link handler"); - assert_eq!(ctrl_log.plugin_id, "example.links"); - assert_eq!(ctrl_log.action_id.as_deref(), Some("open")); - - let (mut super_app, super_info) = app_with_screen_bytes(line.as_bytes()); - install_test_link_handler(&mut super_app); - super_app.handle_mouse(modified_mouse( - MouseEventKind::Down(MouseButton::Left), - super_info.inner_rect.x + col, - super_info.inner_rect.y, - KeyModifiers::SUPER, - )); - - assert!(super_app.state.plugin_command_logs.is_empty()); - } - - #[tokio::test] - async fn pane_cell_url_resolver_finds_visible_url() { - let line = "see https://example.com/pr/307."; - let (app, info) = app_with_screen_bytes(line.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let col = line.find("example").expect("url host") as u16; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, col) - .as_deref(), - Some("https://example.com/pr/307") - ); - assert_eq!( - app.state.url_at_pane_cell( - &app.terminal_runtimes, - pane_id, - 0, - info.inner_rect.width - 1 - ), - None - ); - } - - #[tokio::test] - async fn pane_cell_url_resolver_finds_soft_wrapped_url() { - let (_app, info) = app_with_screen_bytes(b""); - let prefix = "https://example.com/"; - let padding = "a".repeat(info.inner_rect.width as usize - prefix.len()); - let url = format!("{prefix}{padding}tail"); - let (app, _info) = app_with_screen_bytes(url.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1) - .as_deref(), - Some(url.as_str()) - ); - } - - #[tokio::test] - async fn pane_cell_url_resolver_does_not_shift_after_zero_width_mark() { - let url = "https://example.com/mark"; - let screen = format!("e\u{301} {url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 2) - .as_deref(), - Some(url) - ); - } - - #[tokio::test] - async fn pane_cell_url_resolver_handles_hard_newline_after_full_row() { - let (_app, info) = app_with_screen_bytes(b""); - let full_row = "x".repeat(info.inner_rect.width as usize); - let url = "https://example.com/next"; - let screen = format!("{full_row}\n{url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1) - .as_deref(), - None - ); - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 2, 1) - .as_deref(), - Some(url) - ); - } - - #[tokio::test] - async fn render_stream_does_not_synthesize_soft_wrapped_url_hyperlinks() { - let (_app, info) = app_with_screen_bytes(b""); - let prefix = "https://example.com/"; - let padding = "b".repeat(info.inner_rect.width as usize - prefix.len()); - let url = format!("{prefix}{padding}tail"); - let (app, _info) = app_with_screen_bytes(url.as_bytes()); - - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - - assert!(links.is_empty()); - } - - #[tokio::test] - async fn render_stream_does_not_synthesize_url_hyperlinks_after_zero_width_mark() { - let url = "https://example.com/mark"; - let screen = format!("e\u{301} {url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - - assert!(links.is_empty()); - } - - #[tokio::test] - async fn render_stream_does_not_synthesize_hard_newline_plain_url_hyperlinks() { - let (_app, info) = app_with_screen_bytes(b""); - let full_row = "x".repeat(info.inner_rect.width as usize); - let url = "https://example.com/next"; - let screen = format!("{full_row}\n{url}"); - let (app, _info) = app_with_screen_bytes(screen.as_bytes()); - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - - assert!(links.is_empty()); - } - - #[tokio::test] - async fn render_stream_exports_osc8_hyperlink_metadata() { - let uri = "https://example.com/target"; - let (mut app, _info) = - app_with_screen_bytes(format!("\x1b]8;;{uri}\x1b\\label\x1b]8;;\x1b\\").as_bytes()); - let (buffer, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry( - &mut app.state, - &app.terminal_runtimes, - ratatui::layout::Rect::new(0, 0, 106, 20), - false, - crate::kitty_graphics::HostCellSize::default(), - ); - let links = - crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - let frame = crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks( - &buffer, cursor, &links, - ); - let ((x, y), symbol, _) = links - .iter() - .find(|(_, symbol, link_uri)| symbol == "l" && link_uri == uri) - .expect("OSC 8 link cell"); - let linked_cell_index = usize::from(*y) * usize::from(frame.width) + usize::from(*x); - - assert_eq!(frame.hyperlinks, vec![uri.to_owned()]); - assert_eq!(symbol, "l"); - assert_eq!(frame.cells[linked_cell_index].hyperlink, Some(0)); - } - - #[tokio::test] - async fn pane_cell_url_resolver_prefers_osc8_hyperlink() { - let (app, _info) = app_with_screen_bytes( - b"\x1b]8;;https://example.com/hidden-target\x1b\\label\x1b]8;;\x1b\\", - ); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - - assert_eq!( - app.state - .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 1) - .as_deref(), - Some("https://example.com/hidden-target") - ); - } - - #[tokio::test] - async fn double_click_highlight_clears_after_short_delay() { - let (mut app, info) = app_with_screen_bytes(b"copied"); - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y; - double_click(&mut app, col, row); - assert_eq!(clipboard_write_content(&mut app), b"copied"); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); - - assert!(app.event_rx.try_recv().is_err()); - assert!(app.state.selection.is_some()); - let deadline = app - .selection_highlight_clear_deadline - .expect("highlight clear deadline"); - assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1), false)); - assert!(app.state.selection.is_none()); - } - - #[tokio::test] - async fn copy_on_select_disabled_still_forwards_mouse_reporting_gestures() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - ws.insert_test_runtime(pane_id, runtime); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.state.copy_on_select = false; - - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y + 3; - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - col + 1, - row + 1, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - col + 1, - row + 1, - )); - - assert!(app.event_rx.try_recv().is_err()); - assert!(app.state.selection.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert_eq!( - input_rx.try_recv().expect("forwarded left mouse down"), - Bytes::from_static(b"\x1b[<0;3;4M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded left mouse drag"), - Bytes::from_static(b"\x1b[<32;4;5M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded left mouse up"), - Bytes::from_static(b"\x1b[<0;4;5m") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - let top_row = info.inner_rect.y; - let col = info.inner_rect.x + 2; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row)); - app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row)); - - let end_metrics = app - .state - .runtime_for_pane(&app.terminal_runtimes, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after wheel"); - assert_eq!( - end_metrics.offset_from_bottom, - start_metrics.offset_from_bottom + 3 - ); - - let selection = app.state.selection.as_ref().expect("selection after wheel"); - assert!(selection.is_visible()); - assert_eq!( - selection.ordered_cells(), - ( - ( - (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32, - 2, - ), - (start_metrics.max_offset_from_bottom as u32, 2), - ) - ); - } - - #[tokio::test] - async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_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.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(Rect::new(0, 0, 80, 24)); - let focused_before = app.state.workspaces[0].layout.focused(); - app.state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+h"); - - app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT)) - .await; - - assert_ne!(app.state.workspaces[0].layout.focused(), focused_before); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[cfg(unix)] - #[tokio::test] - async fn terminal_direct_edit_scrollback_opens_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("direct-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::direct("ctrl+alt+e"); - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('e'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .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); - - let _ = std::fs::remove_file(output_path); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_command_runs_before_forwarding_to_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(), - ); - 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("direct-custom-command"); - let command = format!("printf direct > '{}'", output_path.display()); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command, - action: crate::config::CustomCommandAction::Shell, - description: None, - width: None, - height: None, - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert_eq!(wait_for_file(&output_path), "direct"); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_pane_command_opens_overlay_pane() { - let mut app = app_with_spawned_workspace(); - - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command: "printf direct-pane".into(), - action: crate::config::CustomCommandAction::Pane, - description: None, - width: None, - height: None, - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert!(app.state.workspaces[0].tabs[0].zoomed); - assert_eq!(app.state.mode, Mode::Terminal); - - shutdown_test_runtimes(&mut app); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_popup_command_opens_layout_neutral_popup() { - let mut app = app_with_spawned_workspace(); - - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command: "sleep 1".into(), - action: crate::config::CustomCommandAction::Popup, - description: None, - width: Some(crate::popup_size::PopupSize::Cells(60)), - height: Some(crate::popup_size::PopupSize::Cells(12)), - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert!(app.state.popup_pane.is_some()); - assert!(!app - .popup_runtime() - .unwrap() - .agent_detection_enabled_for_test()); - 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 snapshot = crate::persist::capture( - &app.state.workspaces, - &app.state.terminals, - &app.terminal_runtimes, - app.state.active, - app.state.selected, - app.state.sidebar_width, - app.state.sidebar_section_split, - app.state.collapsed_space_keys.clone(), - ); - assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 1); - assert!(matches!( - snapshot.workspaces[0].tabs[0].layout, - crate::persist::LayoutSnapshot::Pane(_) - )); - - shutdown_test_runtimes(&mut app); - } - - #[cfg(unix)] - #[tokio::test] - async fn direct_custom_popup_command_closes_after_exit() { - let mut app = app_with_spawned_workspace(); - let focused_pane = app.state.workspaces[0].focused_pane_id().unwrap(); - let focused_pane_id = app.public_pane_id(0, focused_pane).unwrap(); - - let output_path = unique_temp_path("custom-popup-command"); - let command = format!( - "printf '%s|%s' \"${{HERDR_PANE_ID-unset}}\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'", - output_path.display() - ); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), - label: "ctrl+alt+g".into(), - command, - action: crate::config::CustomCommandAction::Popup, - description: None, - width: None, - height: None, - }]; - - app.handle_terminal_key(TerminalKey::new( - KeyCode::Char('g'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )) - .await; - - assert_eq!( - wait_for_file(&output_path), - format!("unset|{focused_pane_id}") - ); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while std::time::Instant::now() < deadline { - app.drain_internal_events(); - if app.state.popup_pane.is_none() { - break; - } - } - - assert!(app.state.popup_pane.is_none()); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_file(output_path); - } - - #[tokio::test] - async fn popup_forwards_escape_instead_of_closing() { - let mut app = app_for_mouse_test(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 40, - 2, - 1024, - b"one\r\ntwo\r\nthree\r\n", - 4, - ); - runtime.scroll_up(1); - assert!(runtime - .scroll_metrics() - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - app.install_test_popup_runtime(runtime); - app.state.mode = Mode::Settings; - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - - assert_eq!(rx.try_recv().unwrap().as_ref(), b"\x1b"); - assert!(app.state.popup_pane.is_some()); - assert_eq!( - app.popup_runtime() - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .map(|metrics| metrics.offset_from_bottom), - Some(0) - ); - } - - #[tokio::test] - async fn local_popup_input_waits_for_channel_capacity() { - let mut app = app_for_mouse_test(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_capacity(40, 2, 1); - runtime - .try_send_bytes(Bytes::from_static(b"queued")) - .unwrap(); - app.install_test_popup_runtime(runtime); - app.state.mode = Mode::Settings; - - let mut send = Box::pin( - app.handle_terminal_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())), - ); - assert!( - tokio::time::timeout(std::time::Duration::from_millis(20), &mut send) - .await - .is_err() - ); - - assert_eq!(rx.recv().await.unwrap().as_ref(), b"queued"); - send.await; - assert_eq!(rx.recv().await.unwrap().as_ref(), b"x"); - } - - #[tokio::test] - async fn alt_backspace_is_forwarded_to_focused_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 80, 24)); - let info = pane_infos[0].clone(); - let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel( - info.inner_rect.width, - info.inner_rect.height, - ); - ws.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap(); - app.handle_terminal_key_headless(key); - - let bytes = rx.try_recv().unwrap(); - assert_eq!(bytes.as_ref(), b"\x1b\x7f"); - assert!(rx.try_recv().is_err()); - } - - fn app_with_plain_scrollback( - line_count: usize, - ) -> (App, crate::layout::PaneId, crate::layout::PaneInfo) { - let mut app = app_for_mouse_test(); - let mut workspace = Workspace::test_new("test"); - let pane_id = workspace.tabs[0].root_pane; - let pane_infos = workspace.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let pane_info = pane_infos[0].clone(); - workspace.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - pane_info.inner_rect.width, - pane_info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(line_count), - ), - ); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - (app, pane_id, pane_info) - } - - fn pane_scroll_offset(app: &App, pane_id: crate::layout::PaneId) -> usize { - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("pane scroll metrics") - .offset_from_bottom - } - - fn physical_page_up(repeat_count: u16) -> TerminalKey { - TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()).with_windows_record( - crate::input::WindowsKeyRecord { - key_down: true, - repeat_count, - virtual_key_code: 0x21, - virtual_scan_code: 0x49, - unicode: 0, - control_key_state: 0x0100, - }, - ) - } - - #[tokio::test] - async fn page_up_scrolls_plain_shell_pane() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64); - assert_eq!(pane_scroll_offset(&app, pane_id), 0); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize - ); - } - - #[tokio::test] - async fn consumed_page_up_preserves_separate_and_grouped_repeats() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256); - let page_up = physical_page_up(1); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key(page_up.clone()), - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - ), - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Release), - ), - ], - false, - ); - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize * 2 - ); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(physical_page_up(3))], - false, - ); - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize * 5 - ); - } - - #[tokio::test] - async fn runtime_consumed_page_up_preserves_separate_and_grouped_repeats() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256); - let page_up = physical_page_up(1); - for key in [ - page_up.clone(), - page_up.clone().with_kind(KeyEventKind::Repeat), - page_up.clone().with_kind(KeyEventKind::Release), - ] { - app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(key)) - .await; - } - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize * 2 - ); - - app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(physical_page_up(3))) - .await; - - assert_eq!( - pane_scroll_offset(&app, pane_id), - pane_info.inner_rect.height as usize * 5 - ); - } - - #[tokio::test] - async fn consumed_repeat_that_becomes_forwarded_acquires_pane_ownership() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info"); - ws.tabs[0].runtimes.insert( - first_pane, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - first_info.inner_rect.width, - first_info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(128), - ), - ); - let (second_runtime, mut second_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[?1h\x1b[>15u", - 3, - ); - ws.tabs[0].runtimes.insert(second_pane, second_runtime); - ws.tabs[0].layout.focus_pane(first_pane); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let page_up = physical_page_up(1); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(page_up.clone())], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, second_pane)); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - )], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, first_pane)); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - ), - crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)), - ], - false, - ); - - let first_repeat = second_rx.try_recv().expect("first forwarded repeat"); - let second_repeat = second_rx.try_recv().expect("owned repeat"); - let release = second_rx.try_recv().expect("owned release"); - assert_eq!(first_repeat, second_repeat); - assert_ne!(second_repeat, release); - assert!(second_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn missing_popup_runtime_suppresses_grouped_and_later_repeats() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); - ws.tabs[0].runtimes.insert(pane_id, runtime); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let (popup_runtime, _popup_rx) = - crate::terminal::TerminalRuntime::test_with_channel(40, 12); - app.install_test_popup_runtime(popup_runtime); - let popup_terminal_id = app - .state - .popup_pane - .as_ref() - .expect("popup installed") - .terminal_id - .clone(); - app.terminal_runtimes.remove(&popup_terminal_id); - - let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()).with_repeat_count(3); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::Key( - key.clone() - .with_kind(KeyEventKind::Repeat) - .with_repeat_count(1), - ), - crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Release)), - ], - false, - ); - - assert!(app.state.popup_pane.is_none()); - assert!(input_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn consumed_repeat_stays_suppressed_after_context_returns() { - let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(128); - let page_up = physical_page_up(1); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(page_up.clone())], - false, - ); - let after_press = pane_scroll_offset(&app, pane_id); - - let (popup_runtime, mut popup_rx) = - crate::terminal::TerminalRuntime::test_with_channel(40, 12); - app.install_test_popup_runtime(popup_runtime); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - )], - false, - ); - app.close_popup_pane(); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key( - page_up.clone().with_kind(KeyEventKind::Repeat), - ), - crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)), - ], - false, - ); - - assert_eq!(pane_scroll_offset(&app, pane_id), after_press); - assert!(popup_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn page_down_returns_to_bottom_after_page_up() { - let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(pane_scroll_offset(&app, pane_id) > 0); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::PageDown, - KeyModifiers::empty(), - )); - assert_eq!(pane_scroll_offset(&app, pane_id), 0); - } - - #[tokio::test] - async fn page_up_release_does_not_scroll_plain_shell_pane_again() { - let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let after_press = pane_scroll_offset(&app, pane_id); - assert_eq!(after_press, pane_info.inner_rect.height as usize); - - app.handle_terminal_key_headless( - TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()) - .with_kind(KeyEventKind::Release), - ); - - assert_eq!(pane_scroll_offset(&app, pane_id), after_press); - } - - #[tokio::test] - async fn modified_page_up_does_not_host_scroll_plain_shell_pane() { - let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL)); - - assert_eq!(pane_scroll_offset(&app, pane_id), 0); - } - - #[tokio::test] - async fn page_up_forwarded_to_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let mut bytes = b"\x1b[?1002h".to_vec(); - bytes.extend_from_slice(&numbered_lines_bytes(64)); - ws.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &bytes, - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - assert_eq!(start_metrics.offset_from_bottom, 0); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - let end_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp"); - // Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom. - assert_eq!(end_metrics.offset_from_bottom, 0); - } - - #[tokio::test] - async fn page_up_forwarded_to_primary_screen_application_cursor_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - let mut bytes = b"\x1b[?1h".to_vec(); - bytes.extend_from_slice(&numbered_lines_bytes(64)); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &bytes, - 4, - ); - ws.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - let start_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - assert_eq!(start_metrics.offset_from_bottom, 0); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - let forwarded = input_rx.try_recv().expect("forwarded PageUp"); - assert_eq!(forwarded.as_ref(), b"\x1b[5~"); - let end_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp"); - assert_eq!(end_metrics.offset_from_bottom, 0); - } - - #[tokio::test] - async fn page_up_scrolls_shell_like_decckm_with_bracketed_paste() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - // zsh enables DECCKM via smkx and bracketed paste together; bash/fish do not. - let mut bytes = b"\x1b[?1h\x1b[?2004h".to_vec(); - bytes.extend_from_slice(&numbered_lines_bytes(64)); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &bytes, - 4, - ); - ws.tabs[0].runtimes.insert(pane_id, runtime); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - assert!( - input_rx.try_recv().is_err(), - "PageUp should not reach the shell" - ); - assert_eq!( - pane_scroll_offset(&app, pane_id), - info.inner_rect.height as usize - ); - } -} |
