diff options
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/clipboard.rs | 370 | ||||
| -rw-r--r-- | src/app/input/copy_mode.rs | 2153 | ||||
| -rw-r--r-- | src/app/input/lease.rs | 465 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 1051 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 2421 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 4778 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 3898 | ||||
| -rw-r--r-- | src/app/input/overlays.rs | 831 | ||||
| -rw-r--r-- | src/app/input/selection.rs | 306 | ||||
| -rw-r--r-- | src/app/input/settings.rs | 693 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 1936 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 2181 |
12 files changed, 0 insertions, 21083 deletions
diff --git a/src/app/input/clipboard.rs b/src/app/input/clipboard.rs deleted file mode 100644 index 095b944d..00000000 --- a/src/app/input/clipboard.rs +++ /dev/null @@ -1,370 +0,0 @@ -use crossterm::event::{KeyCode, KeyModifiers}; - -use crate::{ - app::{App, InputSourceId}, - input::TerminalKey, -}; - -use super::{ConsumedInputLease, InputLeaseKey}; - -fn is_retained_selection_copy_key(key: &TerminalKey) -> bool { - matches!(key.code, KeyCode::Char('c' | 'C')) - && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) -} - -impl App { - pub(super) fn dispatch_pending_clipboard_write(&mut self) -> bool { - let Some(content) = self.state.request_clipboard_write.take() else { - return false; - }; - if self - .event_tx - .try_send(crate::events::AppEvent::ClipboardWrite { content }) - .is_err() - { - tracing::warn!("failed to queue clipboard write event"); - } - true - } - - pub(super) fn try_copy_retained_selection( - &mut self, - source_id: InputSourceId, - key: TerminalKey, - ) -> bool { - if self.state.copy_on_select - || !is_retained_selection_copy_key(&key) - || !self - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_visible) - { - return false; - } - - self.state.copy_selection(&self.terminal_runtimes); - self.selection_autoscroll_deadline = None; - if !self.dispatch_pending_clipboard_write() { - return false; - } - - self.input_leases.insert_consumed( - InputLeaseKey::new(source_id, &key), - ConsumedInputLease::SuppressRepeats, - ); - true - } -} - -#[cfg(test)] -mod tests { - use bytes::Bytes; - use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - use super::super::{app_for_mouse_test, mouse}; - use super::*; - use crate::{app::Mode, events::AppEvent, workspace::Workspace}; - - fn app_with_screen_bytes_and_input( - bytes: &[u8], - ) -> ( - App, - crate::layout::PaneInfo, - tokio::sync::mpsc::Receiver<Bytes>, - ) { - 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, input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - bytes, - 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, info, input_rx) - } - - fn drag_select_range( - app: &mut App, - info: &crate::layout::PaneInfo, - start_col: u16, - end_col: u16, - ) { - let row = info.inner_rect.y; - let start_col = info.inner_rect.x + start_col; - let end_col = info.inner_rect.x + end_col; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); - } - - 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)); - } - - #[tokio::test] - async fn copy_on_select_disabled_ctrl_c_copies_and_clears_retained_selection() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - assert_visible_selection(&app); - assert!(app.event_rx.try_recv().is_err()); - - let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL) - .with_windows_record(crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 0x43, - virtual_scan_code: 0x2e, - unicode: 'c' as u16, - control_key_state: 0x0008, - }); - let source_id = 41; - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], - false, - ); - - let content = clipboard_write_content(&mut app); - assert_eq!(content, b"alpha"); - assert!(app.state.selection.is_none()); - assert!(input_rx.try_recv().is_err()); - - app.handle_internal_event(AppEvent::ClipboardWrite { content }); - assert_eq!( - app.state - .copy_feedback - .as_ref() - .map(|feedback| feedback.message.as_str()), - Some("copied to clipboard") - ); - - app.route_client_events_from( - source_id, - vec![ - crate::raw_input::RawInputEvent::Key(ctrl_c.clone()), - crate::raw_input::RawInputEvent::Key( - ctrl_c.clone().with_kind(KeyEventKind::Repeat), - ), - ], - false, - ); - assert_eq!(app.input_leases.len(), 1); - assert!(app.event_rx.try_recv().is_err()); - assert!(input_rx.try_recv().is_err()); - - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key( - ctrl_c.clone().with_kind(KeyEventKind::Release), - )], - false, - ); - assert!(app.input_leases.is_empty()); - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], - false, - ); - let expected = if cfg!(windows) { - b"\x1b[67;46;99;1;8;1_".as_slice() - } else { - b"\x03".as_slice() - }; - assert_eq!( - input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - expected - ); - assert!(app.event_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn copy_on_select_disabled_cmd_c_copies_retained_selection() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Char('c'), KeyModifiers::SUPER)); - - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - assert!(app.state.selection.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn copy_shortcut_before_delayed_mouse_up_copies_in_progress_selection() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - let source_id = 41; - let row = info.inner_rect.y; - let start_col = info.inner_rect.x; - let end_col = info.inner_rect.x + 4; - app.route_client_events_from( - source_id, - vec![ - crate::raw_input::RawInputEvent::Mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - start_col, - row, - )), - crate::raw_input::RawInputEvent::Mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - end_col, - row, - )), - ], - false, - ); - assert_visible_selection(&app); - assert!(app - .state - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_in_progress)); - assert!(app.state.selection_autoscroll.is_some()); - assert!(app.selection_autoscroll_deadline.is_some()); - - let cmd_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::SUPER); - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key(cmd_c.clone())], - false, - ); - - assert_eq!(clipboard_write_content(&mut app), b"alpha"); - assert!(app.event_rx.try_recv().is_err()); - assert!(app.state.selection.is_none()); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert_eq!(app.input_leases.len(), 1); - assert!(input_rx.try_recv().is_err()); - - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Key( - cmd_c.with_kind(KeyEventKind::Release), - )], - false, - ); - assert!(app.input_leases.is_empty()); - - app.route_client_events_from( - source_id, - vec![crate::raw_input::RawInputEvent::Mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - end_col, - row, - ))], - false, - ); - - assert!(app.state.selection.is_none()); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - assert!(app.selection_highlight_clear_deadline.is_none()); - assert!(app.input_leases.is_empty()); - assert!(app.event_rx.try_recv().is_err()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn retained_selection_copy_shortcut_is_disabled_with_copy_on_select() { - let (mut app, _info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let mut selection = crate::selection::Selection::range( - pane_id, - 0, - 0, - 4, - app.state - .pane_scroll_metrics(&app.terminal_runtimes, pane_id), - ); - assert!(selection.finish()); - app.state.selection = Some(selection); - app.state.copy_on_select = true; - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - )); - - assert!(app.state.selection.is_none()); - assert!(app.event_rx.try_recv().is_err()); - assert_eq!( - input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - b"\x03" - ); - } - - #[tokio::test] - async fn retained_selection_copy_shortcut_forwards_when_selection_text_is_empty() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b""); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - assert_visible_selection(&app); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - )); - - assert!(app.state.selection.is_none()); - assert!(app.event_rx.try_recv().is_err()); - assert_eq!( - input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - b"\x03" - ); - } - - #[tokio::test] - async fn retained_selection_copy_shortcut_requires_exact_modifiers() { - let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta"); - app.state.copy_on_select = false; - drag_select_range(&mut app, &info, 0, 4); - - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::Char('C'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - )); - - assert!(app.state.selection.is_none()); - assert!(app.event_rx.try_recv().is_err()); - assert_eq!( - input_rx - .try_recv() - .expect("forwarded Ctrl-Shift-C") - .as_ref(), - b"\x03" - ); - } -} diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs deleted file mode 100644 index 8bdbfd38..00000000 --- a/src/app/input/copy_mode.rs +++ /dev/null @@ -1,2153 +0,0 @@ -use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; - -use crate::{ - app::{ - state::{CopyModeSearchDirection, CopyModeSearchPrompt, CopyModeSelection, CopyModeState}, - App, AppState, Mode, - }, - input::TerminalKey, - selection::Selection, - terminal::TerminalRuntimeRegistry, -}; - -impl App { - pub(crate) fn handle_copy_mode_key(&mut self, key: TerminalKey) { - if key.kind == KeyEventKind::Release { - return; - } - self.state.update_dismissed = true; - if self.state.is_prefix_key(&key) { - self.state.mode = Mode::Prefix; - return; - } - self.state - .handle_copy_mode_key(&self.terminal_runtimes, key); - self.dispatch_pending_clipboard_write(); - } -} - -impl AppState { - pub(crate) fn enter_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(ws_idx) = self.active else { - return; - }; - let Some(pane_id) = self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.focused_pane_id()) - else { - return; - }; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - if info.inner_rect.width == 0 || info.inner_rect.height == 0 { - return; - } - - let cursor = self - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - .and_then(|rt| rt.cursor_state(info.inner_rect, true)) - .filter(|cursor| cursor.visible) - .map(|cursor| { - ( - cursor.y.saturating_sub(info.inner_rect.y), - cursor.x.saturating_sub(info.inner_rect.x), - ) - }) - .unwrap_or_else(|| (info.inner_rect.height.saturating_sub(1), 0)); - let entry_offset_from_bottom = self - .pane_scroll_metrics(terminal_runtimes, pane_id) - .map_or(0, |metrics| metrics.offset_from_bottom); - - self.clear_selection(); - self.copy_mode = Some(CopyModeState { - pane_id, - cursor_row: cursor.0.min(info.inner_rect.height.saturating_sub(1)), - cursor_col: cursor.1.min(info.inner_rect.width.saturating_sub(1)), - entry_offset_from_bottom, - selection: None, - search: crate::app::state::CopyModeSearchState { - geometry: Some((info.inner_rect.width, info.inner_rect.height)), - ..Default::default() - }, - }); - self.mode = Mode::Copy; - } - - pub(crate) fn handle_copy_mode_key( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - key: TerminalKey, - ) { - if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key.clone()) { - return; - } - match key.code { - KeyCode::Esc => { - let should_clear = self.copy_mode.as_ref().is_some_and(|copy_mode| { - copy_mode.selection.is_some() - || !copy_mode.search.query.is_empty() - || !copy_mode.search.matches.is_empty() - || copy_mode.search.direction.is_some() - }); - if should_clear { - self.clear_copy_mode_selection(); - if let Some(search) = self - .copy_mode - .as_mut() - .map(|copy_mode| &mut copy_mode.search) - { - let geometry = search.geometry; - *search = crate::app::state::CopyModeSearchState { - geometry, - ..Default::default() - }; - } - return; - } - self.exit_copy_mode(terminal_runtimes, false); - return; - } - KeyCode::Enter => { - self.exit_copy_mode(terminal_runtimes, true); - return; - } - KeyCode::Left => { - self.move_copy_cursor(terminal_runtimes, 0, -1); - return; - } - KeyCode::Down => { - self.move_copy_cursor(terminal_runtimes, 1, 0); - return; - } - KeyCode::Up => { - self.move_copy_cursor(terminal_runtimes, -1, 0); - return; - } - KeyCode::Right => { - self.move_copy_cursor(terminal_runtimes, 0, 1); - return; - } - KeyCode::PageUp => { - self.scroll_copy_mode_page(terminal_runtimes, -1, false); - return; - } - KeyCode::PageDown => { - self.scroll_copy_mode_page(terminal_runtimes, 1, false); - return; - } - KeyCode::Home => { - self.copy_mode_line_edge(terminal_runtimes, false); - return; - } - KeyCode::End => { - self.copy_mode_line_edge(terminal_runtimes, true); - return; - } - _ => {} - } - - match (key.code, key.modifiers) { - (KeyCode::Char('b'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, -1, false) - } - (KeyCode::Char('f'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, 1, false) - } - (KeyCode::Char('u'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, -1, true) - } - (KeyCode::Char('d'), mods) if mods.contains(KeyModifiers::CONTROL) => { - self.scroll_copy_mode_page(terminal_runtimes, 1, true) - } - _ => {} - } - - let Some(ch) = copy_mode_command_char(key) else { - return; - }; - match ch { - 'q' => self.exit_copy_mode(terminal_runtimes, false), - 'y' => self.exit_copy_mode(terminal_runtimes, true), - 'v' | ' ' => self.begin_copy_mode_selection(terminal_runtimes), - 'V' => self.select_copy_mode_line(terminal_runtimes), - 'h' => self.move_copy_cursor(terminal_runtimes, 0, -1), - 'j' => self.move_copy_cursor(terminal_runtimes, 1, 0), - 'k' => self.move_copy_cursor(terminal_runtimes, -1, 0), - 'l' => self.move_copy_cursor(terminal_runtimes, 0, 1), - 'g' => self.copy_mode_history_top(terminal_runtimes), - 'G' => self.copy_mode_history_bottom(terminal_runtimes), - '0' => self.copy_mode_line_edge(terminal_runtimes, false), - '$' => self.copy_mode_line_edge(terminal_runtimes, true), - '^' => self.copy_mode_first_non_blank(terminal_runtimes), - '/' => self.open_copy_mode_search(CopyModeSearchDirection::Forward), - '?' => self.open_copy_mode_search(CopyModeSearchDirection::Backward), - 'n' => self.repeat_copy_mode_search(terminal_runtimes, false), - 'N' => self.repeat_copy_mode_search(terminal_runtimes, true), - 'w' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextStart), - 'b' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::PreviousStart), - 'e' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextEnd), - 'W' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextBigStart), - 'B' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::PreviousBigStart), - 'E' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextBigEnd), - '{' => self.copy_mode_paragraph(terminal_runtimes, -1), - '}' => self.copy_mode_paragraph(terminal_runtimes, 1), - _ => {} - } - } - - fn handle_copy_mode_search_prompt_key( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - key: TerminalKey, - ) -> bool { - let Some(copy_mode) = self.copy_mode.as_mut() else { - return false; - }; - let Some(prompt) = copy_mode.search.prompt.as_mut() else { - return false; - }; - match key.code { - KeyCode::Esc => { - copy_mode.search.prompt = None; - } - KeyCode::Enter => { - let direction = prompt.direction; - let query = std::mem::take(&mut prompt.query); - copy_mode.search.prompt = None; - self.submit_copy_mode_search(terminal_runtimes, query, direction, false); - } - KeyCode::Backspace => { - prompt.query.pop(); - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - prompt.query.clear(); - } - _ => { - if let Some(ch) = copy_mode_command_char(key) { - prompt.query.push(ch); - } - } - } - true - } - - fn open_copy_mode_search(&mut self, direction: CopyModeSearchDirection) { - let Some(copy_mode) = self.copy_mode.as_mut() else { - return; - }; - copy_mode.search.prompt = Some(CopyModeSearchPrompt { - direction, - query: String::new(), - }); - } - - fn repeat_copy_mode_search( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - reverse: bool, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - if copy_mode.search.query.is_empty() { - return; - } - let Some(mut direction) = copy_mode.search.direction else { - return; - }; - if reverse { - direction = direction.reversed(); - } - self.submit_copy_mode_search( - terminal_runtimes, - copy_mode.search.query.clone(), - direction, - true, - ); - } - - fn submit_copy_mode_search( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - query: String, - direction: CopyModeSearchDirection, - repeat: bool, - ) { - if query.is_empty() { - return; - } - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(ws_idx) = self.active else { - return; - }; - let Some(runtime) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - else { - return; - }; - let Some(metrics) = runtime.scroll_metrics() else { - return; - }; - let cursor = crate::pane::TerminalTextPoint { - row: viewport_top_row(metrics).saturating_add(u32::from(copy_mode.cursor_row)), - col: copy_mode.cursor_col, - }; - let previous_match = repeat - .then(|| { - copy_mode - .search - .current - .and_then(|index| copy_mode.search.matches.get(index).copied()) - }) - .flatten() - .filter(|text_match| { - text_match.start == cursor && runtime.text_match_is_current(*text_match) - }); - let matches = runtime.search_text_matches(&query, query.chars().any(char::is_uppercase)); - let current = search_match_index(&matches, direction, cursor, previous_match); - - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.search.query = query; - if !repeat { - copy_mode.search.direction = Some(direction); - } - copy_mode.search.matches = matches; - copy_mode.search.current = current; - } - - let Some(target) = current.and_then(|index| { - self.copy_mode - .as_ref() - .and_then(|copy_mode| copy_mode.search.matches.get(index).copied()) - }) else { - return; - }; - self.move_copy_cursor_to_absolute(terminal_runtimes, target.start, true); - } - - fn move_copy_cursor_to_absolute( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - target: crate::pane::TerminalTextPoint, - reserve_overlay_row: bool, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { - return; - }; - let current_top = viewport_top_row(metrics); - let max_cursor_row = info - .inner_rect - .height - .saturating_sub(if reserve_overlay_row { 2 } else { 1 }); - let desired_top = if target.row < current_top { - target.row - } else if target.row > current_top.saturating_add(u32::from(max_cursor_row)) { - target.row.saturating_sub(u32::from(max_cursor_row)) - } else { - current_top - }; - let desired_offset = metrics - .max_offset_from_bottom - .saturating_sub(desired_top as usize); - self.set_pane_scroll_offset(terminal_runtimes, pane_id, desired_offset); - let Some(updated_metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { - return; - }; - let updated_top = viewport_top_row(updated_metrics); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = target - .row - .saturating_sub(updated_top) - .min(u32::from(info.inner_rect.height.saturating_sub(1))) - as u16; - copy_mode.cursor_col = target.col.min(info.inner_rect.width.saturating_sub(1)); - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - pub(crate) fn cancel_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - self.exit_copy_mode(terminal_runtimes, false); - } - - fn exit_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, copy: bool) { - let restore_scroll = self - .copy_mode - .as_ref() - .map(|copy_mode| (copy_mode.pane_id, copy_mode.entry_offset_from_bottom)); - if copy { - self.copy_selection(terminal_runtimes); - } else { - self.clear_selection(); - } - if let Some((pane_id, offset_from_bottom)) = restore_scroll { - self.set_pane_scroll_offset(terminal_runtimes, pane_id, offset_from_bottom); - } - self.copy_mode = None; - self.mode = if self.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - fn begin_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else { - return; - }; - if copy_mode.cursor_row >= info.inner_rect.height - || copy_mode.cursor_col >= info.inner_rect.width - { - return; - } - - let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id); - self.selection = Some(Selection::anchor( - copy_mode.pane_id, - copy_mode.cursor_row, - copy_mode.cursor_col, - metrics, - )); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.selection = Some(CopyModeSelection::Character); - } - } - - fn select_copy_mode_line(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let cursor_row = copy_mode.cursor_row; - let Some(info) = self.pane_info_by_id(pane_id) else { - return; - }; - let end_col = info.inner_rect.width.saturating_sub(1); - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - let anchor_row = Selection::absolute_row_for_viewport(cursor_row, metrics); - self.selection = Some(Selection::line_range( - pane_id, anchor_row, anchor_row, end_col, - )); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.selection = Some(CopyModeSelection::Linewise { anchor_row }); - } - } - - fn move_copy_cursor( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - row_delta: i16, - col_delta: i16, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let mut cursor_row = copy_mode.cursor_row; - let mut cursor_col = copy_mode.cursor_col; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - - if col_delta < 0 { - cursor_col = cursor_col.saturating_sub(col_delta.unsigned_abs()); - } else if col_delta > 0 { - cursor_col = cursor_col - .saturating_add(col_delta as u16) - .min(info.inner_rect.width.saturating_sub(1)); - } - - if row_delta < 0 { - let delta = row_delta.unsigned_abs(); - if cursor_row >= delta { - cursor_row -= delta; - } else { - self.scroll_pane_up(terminal_runtimes, pane_id, usize::from(delta)); - cursor_row = 0; - } - } else if row_delta > 0 { - let delta = row_delta as u16; - let bottom = info.inner_rect.height.saturating_sub(1); - if cursor_row.saturating_add(delta) <= bottom { - cursor_row += delta; - } else { - self.scroll_pane_down(terminal_runtimes, pane_id, usize::from(delta)); - cursor_row = bottom; - } - } - - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = cursor_row; - copy_mode.cursor_col = cursor_col; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn scroll_copy_mode_page( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - direction: i16, - half_page: bool, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let mut cursor_row = copy_mode.cursor_row; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let lines = copy_mode_page_lines(info.inner_rect.height, half_page); - if let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) { - if direction < 0 { - let next_offset = metrics.offset_from_bottom.saturating_add(lines); - if next_offset > metrics.max_offset_from_bottom { - let scrolled_lines = metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom); - let cursor_lines = lines.saturating_sub(scrolled_lines); - self.set_pane_scroll_offset( - terminal_runtimes, - pane_id, - metrics.max_offset_from_bottom, - ); - cursor_row = - cursor_row.saturating_sub(cursor_lines.min(u16::MAX as usize) as u16); - } else { - self.set_pane_scroll_offset(terminal_runtimes, pane_id, next_offset); - } - } else if metrics.offset_from_bottom < lines { - let cursor_lines = lines.saturating_sub(metrics.offset_from_bottom); - self.set_pane_scroll_offset(terminal_runtimes, pane_id, 0); - cursor_row = cursor_row - .saturating_add(cursor_lines.min(u16::MAX as usize) as u16) - .min(info.inner_rect.height.saturating_sub(1)); - } else { - self.set_pane_scroll_offset( - terminal_runtimes, - pane_id, - metrics.offset_from_bottom - lines, - ); - } - } else if direction < 0 { - self.scroll_pane_up(terminal_runtimes, pane_id, lines); - } else { - self.scroll_pane_down(terminal_runtimes, pane_id, lines); - } - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = cursor_row; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_history_top(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { - return; - }; - self.set_pane_scroll_offset(terminal_runtimes, pane_id, metrics.max_offset_from_bottom); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_history_bottom(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(info) = self.pane_info_by_id(pane_id) else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let cursor_row = info.inner_rect.height.saturating_sub(1); - self.set_pane_scroll_offset(terminal_runtimes, pane_id, 0); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_row = cursor_row; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_line_edge(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, end: bool) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let cursor_row = copy_mode.cursor_row; - let Some(info) = self.pane_info_by_id(pane_id) else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let cursor_col = if end { - let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, cursor_row) else { - return; - }; - last_character_col(&text) - .unwrap_or(0) - .min(info.inner_rect.width.saturating_sub(1)) - } else { - 0 - }; - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_col = cursor_col; - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_first_non_blank(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let cursor_row = copy_mode.cursor_row; - let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, cursor_row) else { - return; - }; - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.cursor_col = first_non_blank_col(&text).unwrap_or(0); - } - self.sync_copy_mode_selection(terminal_runtimes); - } - - fn copy_mode_word_motion( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - motion: WordMotion, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id) else { - return; - }; - let Some(ws_idx) = self.active else { - return; - }; - let Some(runtime) = - self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, copy_mode.pane_id) - else { - return; - }; - let absolute_row = - viewport_top_row(metrics).saturating_add(u32::from(copy_mode.cursor_row)); - let motion = match motion { - WordMotion::NextStart => crate::pane::TerminalWordMotion::NextStart, - WordMotion::PreviousStart => crate::pane::TerminalWordMotion::PreviousStart, - WordMotion::NextEnd => crate::pane::TerminalWordMotion::NextEnd, - WordMotion::NextBigStart => crate::pane::TerminalWordMotion::NextBigStart, - WordMotion::PreviousBigStart => crate::pane::TerminalWordMotion::PreviousBigStart, - WordMotion::NextBigEnd => crate::pane::TerminalWordMotion::NextBigEnd, - }; - let Some(target) = runtime.word_motion_target(absolute_row, copy_mode.cursor_col, motion) - else { - return; - }; - self.move_copy_cursor_to_absolute(terminal_runtimes, target, false); - } - - fn copy_mode_paragraph(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, direction: i16) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let pane_id = copy_mode.pane_id; - let Some(pane_height) = self - .pane_info_by_id(pane_id) - .map(|info| info.inner_rect.height) - else { - self.exit_copy_mode(terminal_runtimes, false); - return; - }; - let limit = self - .pane_scroll_metrics(terminal_runtimes, pane_id) - .map(|metrics| metrics.max_offset_from_bottom + metrics.viewport_rows) - .unwrap_or(pane_height as usize) - .clamp(1, 1000); - - for _ in 0..limit { - let before = self.copy_mode.as_ref().map(|copy_mode| { - ( - copy_mode.cursor_row, - copy_mode.cursor_col, - copy_mode.selection, - ) - }); - let before_offset = self - .pane_scroll_metrics(terminal_runtimes, pane_id) - .map(|metrics| metrics.offset_from_bottom); - - self.move_copy_cursor(terminal_runtimes, direction, 0); - - let Some(after) = self.copy_mode.as_ref() else { - return; - }; - if self - .copy_mode_visible_row_text(terminal_runtimes, after.cursor_row) - .is_some_and(|text| text.trim().is_empty()) - { - return; - } - - let Some(after_metrics) = self.pane_scroll_metrics(terminal_runtimes, after.pane_id) - else { - continue; - }; - let did_not_move = before - == self.copy_mode.as_ref().map(|copy_mode| { - ( - copy_mode.cursor_row, - copy_mode.cursor_col, - copy_mode.selection, - ) - }) - && before_offset == Some(after_metrics.offset_from_bottom); - let at_top = direction < 0 - && after.cursor_row == 0 - && after_metrics.offset_from_bottom == after_metrics.max_offset_from_bottom; - let at_bottom = direction > 0 - && after.cursor_row + 1 >= pane_height - && after_metrics.offset_from_bottom == 0; - if did_not_move || at_top || at_bottom { - return; - } - } - } - - fn copy_mode_visible_row_text( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - viewport_row: u16, - ) -> Option<String> { - let copy_mode = self.copy_mode.as_ref()?; - let ws_idx = self.active?; - let info = self.pane_info_by_id(copy_mode.pane_id)?; - if viewport_row >= info.inner_rect.height || info.inner_rect.width == 0 { - return None; - } - let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id); - let row_selection = Selection::range( - copy_mode.pane_id, - viewport_row, - 0, - info.inner_rect.width.saturating_sub(1), - metrics, - ); - self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, copy_mode.pane_id)? - .extract_selection(&row_selection) - } - - pub(crate) fn copy_mode_pane_is_focused(&self) -> bool { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return false; - }; - self.current_pane_focus_target() - .is_some_and(|target| target.pane_id == copy_mode.pane_id) - } - - pub(crate) fn sync_copy_mode_with_focus(&mut self) { - if self.copy_mode.is_none() { - return; - } - if !matches!( - self.mode, - Mode::Copy | Mode::Terminal | Mode::Navigate | Mode::Prefix - ) { - return; - } - if self.copy_mode_pane_is_focused() { - self.mode = Mode::Copy; - } else if self.active.is_some() { - self.clear_copy_mode_selection(); - self.mode = Mode::Terminal; - } else { - self.clear_copy_mode_selection(); - self.mode = Mode::Navigate; - } - } - - pub(crate) fn settle_terminal_mode_after_focus(&mut self) { - self.mode = Mode::Terminal; - self.sync_copy_mode_with_focus(); - } - - pub(crate) fn sync_copy_mode_search_geometry(&mut self) { - let geometry = self.copy_mode.as_ref().and_then(|copy_mode| { - self.view - .pane_infos - .iter() - .find(|info| info.id == copy_mode.pane_id) - .map(|info| (info.inner_rect.width, info.inner_rect.height)) - }); - let Some(copy_mode) = self.copy_mode.as_mut() else { - return; - }; - if let Some(geometry) = geometry { - if copy_mode.search.geometry.is_some() && copy_mode.search.geometry != Some(geometry) { - copy_mode.search.matches.clear(); - copy_mode.search.current = None; - } - copy_mode.search.geometry = Some(geometry); - } - } - - pub(crate) fn clear_copy_mode_selection(&mut self) { - self.clear_selection(); - if let Some(copy_mode) = self.copy_mode.as_mut() { - copy_mode.selection = None; - } - } - - pub(crate) fn clear_copy_mode_for_removed_panes( - &mut self, - pane_ids: impl IntoIterator<Item = crate::layout::PaneId>, - ) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - if pane_ids - .into_iter() - .any(|pane_id| pane_id == copy_mode.pane_id) - { - self.clear_selection(); - self.copy_mode = None; - if self.mode == Mode::Copy { - self.mode = if self.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - } - - fn sync_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode.as_ref() else { - return; - }; - let Some(selection) = copy_mode.selection else { - return; - }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else { - return; - }; - match selection { - CopyModeSelection::Character => { - let screen_col = info.inner_rect.x.saturating_add(copy_mode.cursor_col); - let screen_row = info.inner_rect.y.saturating_add(copy_mode.cursor_row); - self.update_selection_cursor( - terminal_runtimes, - copy_mode.pane_id, - screen_col, - screen_row, - ); - } - CopyModeSelection::Linewise { anchor_row } => { - let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id); - let cursor_row = - Selection::absolute_row_for_viewport(copy_mode.cursor_row, metrics); - self.selection = Some(Selection::line_range( - copy_mode.pane_id, - anchor_row, - cursor_row, - info.inner_rect.width.saturating_sub(1), - )); - } - } - } -} - -impl CopyModeSearchDirection { - fn reversed(self) -> Self { - match self { - Self::Forward => Self::Backward, - Self::Backward => Self::Forward, - } - } -} - -fn viewport_top_row(metrics: crate::pane::ScrollMetrics) -> u32 { - metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom) - .min(u32::MAX as usize) as u32 -} - -fn search_match_index( - matches: &[crate::pane::TerminalTextMatch], - direction: CopyModeSearchDirection, - cursor: crate::pane::TerminalTextPoint, - previous: Option<crate::pane::TerminalTextMatch>, -) -> Option<usize> { - if matches.is_empty() { - return None; - } - match direction { - CopyModeSearchDirection::Forward => { - let origin = previous.map_or(cursor, |text_match| text_match.end); - matches - .iter() - .position(|text_match| text_match.start > origin) - .or(Some(0)) - } - CopyModeSearchDirection::Backward => { - let origin = previous.map_or(cursor, |text_match| text_match.start); - matches - .iter() - .rposition(|text_match| text_match.end < origin) - .or_else(|| matches.len().checked_sub(1)) - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WordMotion { - NextStart, - PreviousStart, - NextEnd, - NextBigStart, - PreviousBigStart, - NextBigEnd, -} - -fn first_non_blank_col(text: &str) -> Option<u16> { - let mut col = 0u16; - for ch in text.chars() { - if !ch.is_whitespace() { - return Some(col); - } - col = col.saturating_add(char_cell_width(ch)); - } - None -} - -fn last_character_col(text: &str) -> Option<u16> { - let mut col = 0u16; - let mut last_col = None; - for ch in text.chars() { - let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); - if width > 0 { - last_col = Some(col); - col = col.saturating_add(width); - } - } - last_col -} - -fn char_cell_width(ch: char) -> u16 { - u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)).max(1) -} - -fn copy_mode_page_lines(height: u16, half_page: bool) -> usize { - if height <= 2 { - 1 - } else if half_page { - usize::from(height / 2) - } else { - usize::from(height - 2) - } -} - -fn copy_mode_command_char(key: TerminalKey) -> Option<char> { - if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { - return None; - } - - if let Some(ch) = key.shifted_codepoint.and_then(char::from_u32) { - return Some(ch); - } - - let KeyCode::Char(ch) = key.code else { - return None; - }; - if key.modifiers.contains(KeyModifiers::SHIFT) { - Some(shifted_ascii_char(ch).unwrap_or(ch)) - } else { - Some(ch) - } -} - -fn shifted_ascii_char(ch: char) -> Option<char> { - match ch { - 'a'..='z' => Some(ch.to_ascii_uppercase()), - '1' => Some('!'), - '2' => Some('@'), - '3' => Some('#'), - '4' => Some('$'), - '5' => Some('%'), - '6' => Some('^'), - '7' => Some('&'), - '8' => Some('*'), - '9' => Some('('), - '0' => Some(')'), - '-' => Some('_'), - '=' => Some('+'), - '[' => Some('{'), - ']' => Some('}'), - '\\' => Some('|'), - ';' => Some(':'), - '\'' => Some('"'), - ',' => Some('<'), - '.' => Some('>'), - '/' => Some('?'), - '`' => Some('~'), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::super::{app_for_mouse_test, numbered_lines_bytes}; - use super::*; - use crate::{events::AppEvent, workspace::Workspace}; - use ratatui::layout::Rect; - - fn app_with_copy_runtime( - runtime: impl FnOnce(u16, u16) -> crate::terminal::TerminalRuntime, - ) -> (App, crate::layout::PaneId) { - 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, 20, 5)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - runtime(info.inner_rect.width, info.inner_rect.height), - ); - 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, pane_id) - } - - fn app_with_copy_screen(bytes: &[u8]) -> (App, crate::layout::PaneId) { - app_with_copy_runtime(|cols, rows| { - crate::terminal::TerminalRuntime::test_with_screen_bytes(cols, rows, bytes) - }) - } - - fn app_with_copy_scrollback(bytes: &[u8]) -> (App, crate::layout::PaneId) { - app_with_copy_runtime(|cols, rows| { - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - cols, - rows, - 16 * 1024, - bytes, - ) - }) - } - - fn app_with_split_copy_runtime( - bytes: &[u8], - first_runtime: impl FnOnce(u16, u16, &[u8]) -> crate::terminal::TerminalRuntime, - ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { - 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(0, 0, 40, 5)); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info"); - let second_info = pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info"); - ws.tabs[0].runtimes.insert( - first_pane, - first_runtime( - first_info.inner_rect.width, - first_info.inner_rect.height, - bytes, - ), - ); - ws.tabs[0].runtimes.insert( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width, - second_info.inner_rect.height, - b"", - ), - ); - 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; - (app, first_pane, second_pane) - } - - fn app_with_split_copy_screen( - bytes: &[u8], - ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { - app_with_split_copy_runtime(bytes, |cols, rows, bytes| { - crate::terminal::TerminalRuntime::test_with_screen_bytes(cols, rows, bytes) - }) - } - - fn app_with_split_copy_scrollback( - bytes: &[u8], - ) -> (App, crate::layout::PaneId, crate::layout::PaneId) { - app_with_split_copy_runtime(bytes, |cols, rows, bytes| { - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - cols, - rows, - 16 * 1024, - bytes, - ) - }) - } - - fn copy_mode_clipboard_text(app: &mut App) -> String { - match app.event_rx.try_recv().expect("clipboard event") { - AppEvent::ClipboardWrite { content } => { - String::from_utf8(content).expect("utf8 clipboard") - } - other => panic!("unexpected event: {other:?}"), - } - } - - fn copy_mode_viewport_top_row(app: &App, pane_id: crate::layout::PaneId) -> usize { - let metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("copy mode scroll metrics"); - metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom) - } - - fn copy_mode_offset_from_bottom(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("copy mode scroll metrics") - .offset_from_bottom - } - - fn copy_mode_scroll_metrics( - app: &App, - pane_id: crate::layout::PaneId, - ) -> crate::pane::ScrollMetrics { - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("copy mode scroll metrics") - } - - fn refresh_split_pane_infos(app: &mut App) { - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .expect("active tab") - .layout - .panes(Rect::new(0, 0, 40, 5)); - } - - #[tokio::test] - async fn enter_copy_mode_tracks_focused_pane() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").pane_id, - pane_id - ); - } - - #[tokio::test] - async fn copy_mode_ctrl_b_uses_page_up() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.prefix_code = KeyCode::Char('a'); - app.state.prefix_mods = KeyModifiers::CONTROL; - app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; - let expected_lines = copy_mode_page_lines(height, false); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), expected_lines); - } - - #[tokio::test] - async fn copy_mode_prefix_takes_priority_over_ctrl_b_page_up() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - - assert_eq!(app.state.mode, Mode::Prefix); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - assert!(app.state.copy_mode.is_some()); - } - - #[tokio::test] - async fn copy_mode_prefix_escape_returns_to_copy_mode() { - let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode").clone(); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode, Some(copy_mode)); - } - - #[tokio::test] - async fn copy_mode_prefix_focus_keeps_copy_mode_on_source_pane() { - let (mut app, first_pane, second_pane) = app_with_split_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode").clone(); - assert_eq!(copy_mode.pane_id, first_pane); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.copy_mode, Some(copy_mode.clone())); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - second_pane - ); - - refresh_split_pane_infos(&mut app); - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode, Some(copy_mode)); - assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), first_pane); - } - - #[tokio::test] - async fn copy_mode_focus_away_preserves_scrollback_position() { - let bytes = numbered_lines_bytes(64); - let (mut app, first_pane, second_pane) = app_with_split_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let scrolled_offset = copy_mode_offset_from_bottom(&app, first_pane); - assert!(scrolled_offset > 0); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - second_pane - ); - assert_eq!( - copy_mode_offset_from_bottom(&app, first_pane), - scrolled_offset - ); - } - - #[tokio::test] - async fn copy_mode_cancel_restores_scroll_after_workspace_switch() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state - .workspaces - .push(crate::workspace::Workspace::test_new("other")); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); - - app.state.switch_workspace(1); - app.state.cancel_copy_mode(&app.terminal_runtimes); - - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - assert!(app.state.copy_mode.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn copy_mode_clears_when_source_tab_closes_after_focus_away() { - let (mut app, first_pane, _) = app_with_split_copy_screen(b"alpha\nbeta\n"); - let survivor_tab = app.state.workspaces[0].test_add_tab(Some("survivor")); - let survivor_pane = app.state.workspaces[0].tabs[survivor_tab].root_pane; - let survivor_terminal = app.state.workspaces[0].tabs[survivor_tab].panes[&survivor_pane] - .attached_terminal_id - .clone(); - app.state.terminals.insert( - survivor_terminal.clone(), - crate::terminal::TerminalState::new(survivor_terminal, "/tmp".into()), - ); - app.state.enter_copy_mode(&app.terminal_runtimes); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").pane_id, - first_pane - ); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())) - .await; - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_some()); - - assert!(!app.state.close_tab()); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - app.state.assert_invariants_for_test(); - } - - #[tokio::test] - async fn copy_mode_ctrl_f_uses_page_down() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; - let page_lines = copy_mode_page_lines(height, false); - app.state - .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, page_lines); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); - - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - } - - #[tokio::test] - async fn copy_mode_line_end_stops_at_last_character() { - let (mut app, _) = app_with_copy_screen(b"hello\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty())); - - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::End, KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - - let (mut empty_app, _) = app_with_copy_screen(b"\r\n"); - empty_app - .state - .enter_copy_mode(&empty_app.terminal_runtimes); - if let Some(copy_mode) = empty_app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 7; - } - empty_app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty())); - assert_eq!( - empty_app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .cursor_col, - 0 - ); - - let (mut wide_app, _) = app_with_copy_screen("a界\r\n".as_bytes()); - wide_app.state.enter_copy_mode(&wide_app.terminal_runtimes); - if let Some(copy_mode) = wide_app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - wide_app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty())); - assert_eq!( - wide_app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .cursor_col, - 1 - ); - } - - #[tokio::test] - async fn copy_mode_word_motions_use_visible_row_words() { - let (mut app, _) = app_with_copy_screen(b"foo bar baz\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('e'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 6 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - } - - fn submit_copy_search(app: &mut App, marker: char, query: &str) { - app.handle_copy_mode_key(TerminalKey::new( - KeyCode::Char(marker), - KeyModifiers::empty(), - )); - for ch in query.chars() { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char(ch), KeyModifiers::empty())); - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Enter, KeyModifiers::empty())); - } - - #[tokio::test] - async fn copy_mode_search_wraps_and_repeats_in_both_directions() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha needle\r\nbeta needle\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - - submit_copy_search(&mut app, '/', "needle"); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.matches.len(), 2); - assert_eq!(copy_mode.search.current, Some(0)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 6); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.current, Some(1)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - assert_eq!(copy_mode.cursor_col, 5); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::SHIFT)); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.current, Some(0)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())); - assert_eq!( - app.state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .current, - Some(1) - ); - } - - #[tokio::test] - async fn copy_mode_backward_search_uses_last_match_before_cursor() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha needle\r\nbeta needle\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - - submit_copy_search(&mut app, '?', "needle"); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.current, Some(1)); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - } - - #[tokio::test] - async fn copy_mode_shifted_slash_opens_backward_search() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!( - app.state - .copy_mode - .as_ref() - .and_then(|copy_mode| copy_mode.search.prompt.as_ref()) - .map(|prompt| prompt.direction), - Some(CopyModeSearchDirection::Backward) - ); - } - - #[tokio::test] - async fn copy_mode_search_extends_an_active_selection() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - - submit_copy_search(&mut app, '/', "needle"); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta n"); - } - - #[tokio::test] - async fn copy_mode_search_prompt_escape_and_failure_preserve_copy_mode() { - let (mut app, pane_id) = app_with_copy_screen(b"alpha\r\nbeta\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - let before = ( - copy_mode_viewport_top_row(&app, pane_id), - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - assert_eq!(app.state.mode, Mode::Copy); - assert!(app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .prompt - .is_none()); - - submit_copy_search(&mut app, '/', "missing"); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode.search.query, "missing"); - assert!(copy_mode.search.matches.is_empty()); - assert_eq!( - ( - copy_mode_viewport_top_row(&app, pane_id), - copy_mode.cursor_row, - copy_mode.cursor_col, - ), - before - ); - } - - #[tokio::test] - async fn copy_mode_escape_clears_search_before_exiting() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "needle"); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - - let search = &app.state.copy_mode.as_ref().expect("copy mode").search; - assert!(search.query.is_empty()); - assert!(search.matches.is_empty()); - assert!(search.current.is_none()); - assert_eq!(app.state.mode, Mode::Copy); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - } - - #[tokio::test] - async fn copy_mode_escape_clears_selection_before_exiting() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - assert!(app.state.selection.is_some()); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::Copy); - assert!(app.state.selection.is_none()); - assert!(app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .selection - .is_none()); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn copy_mode_search_prompt_accepts_paste_without_exiting() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty())); - - assert!(app.paste_into_active_text_input("needle")); - assert_eq!( - app.state - .copy_mode - .as_ref() - .and_then(|copy_mode| copy_mode.search.prompt.as_ref()) - .map(|prompt| prompt.query.as_str()), - Some("needle") - ); - assert_eq!(app.state.mode, Mode::Copy); - } - - #[tokio::test] - async fn copy_mode_word_motions_cross_rows_and_respect_separators() { - let (mut app, pane_id) = app_with_copy_screen(b"foo.bar_baz\r\nqux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - for expected_col in [3, 4] { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - expected_col - ); - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - assert_eq!(copy_mode.cursor_col, 0); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::empty())); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 4); - } - - #[tokio::test] - async fn copy_mode_big_word_motions_skip_punctuation_runs() { - let (mut app, _) = app_with_copy_screen(b"foo.bar baz qux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - for expected_col in [8, 12] { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - expected_col - ); - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('e'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 14 - ); - for expected_col in [12, 8, 0] { - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - expected_col - ); - } - - // Lowercase motions keep their punctuation-aware behavior. - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 3 - ); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 4 - ); - } - - #[tokio::test] - async fn copy_mode_big_word_motions_accept_shifted_codepoints_and_cross_rows() { - let (mut app, pane_id) = app_with_copy_screen(b"foo.bar baz\r\nqux/quux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char('W'), KeyModifiers::SHIFT) - .with_shifted_codepoint('W' as u32), - ); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 8); - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char('W'), KeyModifiers::SHIFT) - .with_shifted_codepoint('W' as u32), - ); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 1 - ); - assert_eq!(copy_mode.cursor_col, 0); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::SHIFT)); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - assert_eq!(copy_mode.cursor_col, 8); - } - - #[tokio::test] - async fn copy_mode_big_word_motions_extend_an_active_selection() { - let (mut app, _) = app_with_copy_screen(b"foo.bar baz qux\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "foo.bar b"); - } - - #[tokio::test] - async fn copy_mode_search_does_not_change_live_follow_behavior() { - let bytes = numbered_lines_bytes(32); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "missing"); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .expect("runtime") - .test_process_pty_bytes(b"live output\r\n"); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - - submit_copy_search(&mut app, '?', "000000"); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!( - copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), - 0 - ); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .expect("runtime"); - let visible_before = runtime.visible_text(); - runtime.test_process_pty_bytes(b"more output\r\n"); - assert_eq!(runtime.visible_text(), visible_before); - } - - #[tokio::test] - async fn copy_mode_resize_clears_matches_but_keeps_query() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "needle"); - assert!(!app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .matches - .is_empty()); - - app.state.view.pane_infos[0].inner_rect.width = app.state.view.pane_infos[0] - .inner_rect - .width - .saturating_sub(1); - app.state.sync_copy_mode_search_geometry(); - - let search = &app.state.copy_mode.as_ref().expect("copy mode").search; - assert_eq!(search.query, "needle"); - assert!(search.matches.is_empty()); - assert!(search.current.is_none()); - } - - #[tokio::test] - async fn copy_mode_hidden_source_does_not_look_like_a_resize() { - let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - submit_copy_search(&mut app, '/', "needle"); - let before = app - .state - .copy_mode - .as_ref() - .expect("copy mode") - .search - .clone(); - - app.state.view.pane_infos.clear(); - app.state.sync_copy_mode_search_geometry(); - - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").search, - before - ); - } - - #[tokio::test] - async fn copy_mode_shift_v_y_copies_visible_line() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "beta"); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn copy_mode_shift_v_extends_linewise_down() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_extends_linewise_up() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_reverses_without_character_tail() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_horizontal_motion_keeps_linewise_selection() { - let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 1; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert_eq!(copy_mode_clipboard_text(&mut app), "beta"); - } - - #[tokio::test] - async fn copy_mode_shift_v_page_up_keeps_linewise_scrollback_selection() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 2; - } - - let anchor_row = copy_mode_viewport_top_row(&app, pane_id); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT)); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let cursor_row = copy_mode_viewport_top_row(&app, pane_id); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - assert!(cursor_row < anchor_row); - let expected = (cursor_row..=anchor_row) - .map(|row| format!("{row:06}")) - .collect::<Vec<_>>() - .join("\n"); - assert_eq!(copy_mode_clipboard_text(&mut app), expected); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - } - - #[tokio::test] - async fn copy_mode_page_up_uses_tmux_page_size() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; - let expected_lines = copy_mode_page_lines(height, false); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), expected_lines); - } - - #[tokio::test] - async fn copy_mode_ctrl_u_moves_cursor_when_history_top_clamps() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let bottom = app.state.copy_mode.as_ref().expect("copy mode").cursor_row; - let lines = copy_mode_page_lines(bottom + 1, true); - let metrics = copy_mode_scroll_metrics(&app, pane_id); - assert!(metrics.max_offset_from_bottom >= lines); - app.state.set_pane_scroll_offset( - &app.terminal_runtimes, - pane_id, - metrics.max_offset_from_bottom - lines + 1, - ); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = bottom; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - let expected_cursor_delta = 1; - assert_eq!( - copy_mode_offset_from_bottom(&app, pane_id), - metrics.max_offset_from_bottom - ); - assert_eq!( - copy_mode.cursor_row, - bottom.saturating_sub(expected_cursor_delta as u16) - ); - } - - #[tokio::test] - async fn copy_mode_ctrl_d_moves_cursor_when_live_bottom_clamps() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - let bottom = app.state.copy_mode.as_ref().expect("copy mode").cursor_row; - let lines = copy_mode_page_lines(bottom + 1, true); - assert!(lines > 1); - app.state - .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, lines - 1); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); - - let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - assert_eq!(copy_mode.cursor_row, 1); - } - - #[tokio::test] - async fn copy_mode_q_exits_and_returns_to_bottom_after_scrollback() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - app.state.enter_copy_mode(&app.terminal_runtimes); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('q'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); - } - - #[tokio::test] - async fn copy_mode_q_restores_entry_scrollback_offset() { - let bytes = numbered_lines_bytes(64); - let (mut app, pane_id) = app_with_copy_scrollback(&bytes); - let entry_offset = 3; - app.state - .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, entry_offset); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), entry_offset); - - app.state.enter_copy_mode(&app.terminal_runtimes); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - assert!(copy_mode_offset_from_bottom(&app, pane_id) > entry_offset); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('q'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), entry_offset); - } - - #[tokio::test] - async fn shifted_punctuation_keys_work_with_enhanced_key_reporting() { - let (mut app, _) = app_with_copy_screen(b"foo\r\n\r\nbar\r\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 2; - copy_mode.cursor_col = 2; - } - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('6'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_col, - 0 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char(']'), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - 3 - ); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('['), KeyModifiers::SHIFT)); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - 1 - ); - - app.handle_copy_mode_key( - TerminalKey::new(KeyCode::Char(']'), KeyModifiers::SHIFT) - .with_shifted_codepoint('}' as u32), - ); - assert_eq!( - app.state.copy_mode.as_ref().expect("copy mode").cursor_row, - 3 - ); - } - - #[tokio::test] - async fn copy_mode_v_y_copies_selection_and_exits() { - let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - - match app.event_rx.try_recv().expect("clipboard event") { - AppEvent::ClipboardWrite { content } => assert_eq!(content, b"alp"), - other => panic!("unexpected event: {other:?}"), - } - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - } - - #[tokio::test] - async fn copy_mode_same_tab_switch_preserves_selection() { - let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); - app.state.enter_copy_mode(&app.terminal_runtimes); - if let Some(copy_mode) = app.state.copy_mode.as_mut() { - copy_mode.cursor_row = 0; - copy_mode.cursor_col = 0; - } - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty())); - - assert!(app.state.switch_workspace_tab(0, 0)); - - app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); - assert_eq!(copy_mode_clipboard_text(&mut app), "alp"); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.copy_mode.is_none()); - } -} diff --git a/src/app/input/lease.rs b/src/app/input/lease.rs deleted file mode 100644 index 6724b804..00000000 --- a/src/app/input/lease.rs +++ /dev/null @@ -1,465 +0,0 @@ -use std::collections::HashMap; - -use crate::app::{InputSourceId, TerminalInputContext, TerminalInputTarget}; -use crate::input::{KeyIdentity, TerminalKey}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub(crate) struct InputLeaseKey { - source_id: InputSourceId, - identity: KeyIdentity, -} - -impl InputLeaseKey { - pub(crate) fn new(source_id: InputSourceId, key: &TerminalKey) -> Self { - Self { - source_id, - identity: key.identity(), - } - } -} - -#[derive(Debug, PartialEq, Eq)] -pub(crate) struct ForwardedInputLease { - pub(crate) target: TerminalInputTarget, - pub(crate) key: TerminalKey, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum ConsumedInputLease { - ReprocessRepeats(TerminalInputContext), - SuppressRepeats, -} - -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum InputLease { - Forwarded(ForwardedInputLease), - Consumed(ConsumedInputLease), -} - -pub(crate) enum RepeatPlan { - Forwarded(TerminalInputTarget), - Reprocess { - context: TerminalInputContext, - repetitions: u16, - tracked: bool, - }, - Ignore, -} - -#[derive(Default)] -pub(crate) struct InputLeaseTable { - leases: HashMap<InputLeaseKey, InputLease>, -} - -impl InputLeaseTable { - pub(crate) fn normalize_press( - &mut self, - lease_key: &InputLeaseKey, - key: TerminalKey, - ) -> TerminalKey { - // Generated text is normally stateless, but a native physical key still owns - // press/repeat/release lifecycle even when it carries a layout result. - if key.kind != crossterm::event::KeyEventKind::Press - || (key.generated_text.is_some() && !key.has_physical_identity()) - { - return key; - } - if key.has_physical_identity() && self.leases.contains_key(lease_key) { - key.with_kind(crossterm::event::KeyEventKind::Repeat) - } else { - self.leases.remove(lease_key); - key - } - } - - pub(crate) fn complete_press( - &mut self, - lease_key: InputLeaseKey, - key: &TerminalKey, - initial_context: Option<&TerminalInputContext>, - resulting_context: Option<&TerminalInputContext>, - target: Option<TerminalInputTarget>, - ) -> RepeatPlan { - if key.generated_text.is_some() && !key.has_physical_identity() { - return RepeatPlan::Ignore; - } - if let Some(target) = target { - self.insert_forwarded(lease_key, target, key.clone()); - return RepeatPlan::Ignore; - } - if !self.leases.contains_key(&lease_key) { - let disposition = match (initial_context, resulting_context) { - (Some(initial), Some(resulting)) if initial == resulting => { - ConsumedInputLease::ReprocessRepeats(initial.clone()) - } - _ => ConsumedInputLease::SuppressRepeats, - }; - self.insert_consumed(lease_key, disposition); - } - match self.leases.get(&lease_key) { - Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(context))) - if key.repeat_count > 1 => - { - RepeatPlan::Reprocess { - context: context.clone(), - repetitions: key.repeat_count - 1, - tracked: true, - } - } - _ => RepeatPlan::Ignore, - } - } - - pub(crate) fn plan_repeat( - &mut self, - lease_key: InputLeaseKey, - key: &TerminalKey, - current_context: Option<&TerminalInputContext>, - ) -> RepeatPlan { - match self.leases.get(&lease_key) { - Some(InputLease::Forwarded(lease)) => { - return RepeatPlan::Forwarded(lease.target.clone()); - } - Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(context))) - if current_context == Some(context) => - { - return RepeatPlan::Reprocess { - context: context.clone(), - repetitions: key.repeat_count, - tracked: true, - }; - } - Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(_))) => { - self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); - return RepeatPlan::Ignore; - } - Some(InputLease::Consumed(ConsumedInputLease::SuppressRepeats)) => { - return RepeatPlan::Ignore; - } - None => {} - } - match current_context { - Some(context) => RepeatPlan::Reprocess { - context: context.clone(), - repetitions: key.repeat_count, - tracked: false, - }, - None => RepeatPlan::Ignore, - } - } - - pub(crate) fn reprocess_allowed( - &mut self, - lease_key: InputLeaseKey, - expected_context: &TerminalInputContext, - current_context: Option<&TerminalInputContext>, - tracked: bool, - ) -> bool { - let allowed = current_context == Some(expected_context); - if tracked && !allowed { - self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); - } - allowed - } - - pub(crate) fn remove_forwarded(&mut self, key: &InputLeaseKey) -> Option<ForwardedInputLease> { - match self.leases.remove(key) { - Some(InputLease::Forwarded(lease)) => Some(lease), - Some(InputLease::Consumed(_)) | None => None, - } - } - - #[cfg(test)] - pub(crate) fn contains(&self, key: &InputLeaseKey) -> bool { - self.leases.contains_key(key) - } - - pub(crate) fn insert_forwarded( - &mut self, - key: InputLeaseKey, - target: TerminalInputTarget, - original: TerminalKey, - ) { - self.leases.insert( - key, - InputLease::Forwarded(ForwardedInputLease { - target, - key: original, - }), - ); - } - - pub(crate) fn insert_consumed(&mut self, key: InputLeaseKey, disposition: ConsumedInputLease) { - self.leases.insert(key, InputLease::Consumed(disposition)); - } - - pub(crate) fn remove(&mut self, key: &InputLeaseKey) -> Option<InputLease> { - self.leases.remove(key) - } - - pub(crate) fn remove_source(&mut self, source_id: InputSourceId) -> Vec<ForwardedInputLease> { - let keys = self - .leases - .keys() - .filter(|key| key.source_id == source_id) - .copied() - .collect::<Vec<_>>(); - self.remove_keys(keys) - } - - pub(crate) fn remove_target( - &mut self, - target: &TerminalInputTarget, - ) -> Vec<ForwardedInputLease> { - let keys = self - .leases - .iter() - .filter_map(|(key, lease)| match lease { - InputLease::Forwarded(lease) if &lease.target == target => Some(*key), - InputLease::Forwarded(_) | InputLease::Consumed(_) => None, - }) - .collect::<Vec<_>>(); - self.remove_keys(keys) - } - - fn remove_keys( - &mut self, - keys: impl IntoIterator<Item = InputLeaseKey>, - ) -> Vec<ForwardedInputLease> { - keys.into_iter() - .filter_map(|key| match self.leases.remove(&key) { - Some(InputLease::Forwarded(lease)) => Some(lease), - Some(InputLease::Consumed(_)) | None => None, - }) - .collect() - } - - #[cfg(test)] - pub(crate) fn is_empty(&self) -> bool { - self.leases.is_empty() - } - - #[cfg(test)] - pub(crate) fn len(&self) -> usize { - self.leases.len() - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyModifiers}; - - use super::*; - - fn target() -> TerminalInputTarget { - TerminalInputTarget { - terminal_id: crate::terminal::TerminalId::alloc(), - } - } - - fn physical_generated_slash(repeat_count: u16) -> TerminalKey { - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_generated_text(Some("/".to_owned())) - .with_windows_record(crate::input::WindowsKeyRecord { - key_down: true, - repeat_count, - virtual_key_code: 0x37, - virtual_scan_code: 0x08, - unicode: u16::from(b'/'), - control_key_state: 0x0010, - }) - } - - #[test] - fn remove_source_returns_forwarded_and_discards_consumed_leases() { - let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); - let forwarded = InputLeaseKey::new(7, &key); - let consumed = InputLeaseKey::new( - 7, - &TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - let other_source = InputLeaseKey::new(8, &key); - let first_target = target(); - let mut leases = InputLeaseTable::default(); - leases.insert_forwarded(forwarded, first_target.clone(), key.clone()); - leases.insert_consumed(consumed, ConsumedInputLease::SuppressRepeats); - leases.insert_forwarded(other_source, target(), key.clone()); - - assert_eq!( - leases.remove_source(7), - vec![ForwardedInputLease { - target: first_target, - key, - }] - ); - assert_eq!(leases.len(), 1); - assert!(leases.contains(&other_source)); - } - - #[test] - fn remove_target_closes_only_forwarded_leases_for_that_terminal() { - let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); - let removed_key = InputLeaseKey::new(7, &key); - let retained_key = InputLeaseKey::new(8, &key); - let removed_target = target(); - let retained_target = target(); - let mut leases = InputLeaseTable::default(); - leases.insert_forwarded(removed_key, removed_target.clone(), key.clone()); - leases.insert_forwarded(retained_key, retained_target, key.clone()); - - assert_eq!( - leases.remove_target(&removed_target), - vec![ForwardedInputLease { - target: removed_target, - key, - }] - ); - assert_eq!(leases.len(), 1); - assert!(leases.contains(&retained_key)); - } - - #[test] - fn duplicate_physical_press_normalizes_for_forwarded_and_consumed_leases() { - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 65, - virtual_scan_code: 30, - unicode: 97, - control_key_state: 0, - }; - let physical = - TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record); - let lease_key = InputLeaseKey::new(7, &physical); - let mut leases = InputLeaseTable::default(); - - assert_eq!( - leases.normalize_press(&lease_key, physical.clone()).kind, - crossterm::event::KeyEventKind::Press - ); - leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); - assert_eq!( - leases.normalize_press(&lease_key, physical.clone()).kind, - crossterm::event::KeyEventKind::Repeat - ); - leases.insert_forwarded(lease_key, target(), physical.clone()); - assert_eq!( - leases.normalize_press(&lease_key, physical.clone()).kind, - crossterm::event::KeyEventKind::Repeat - ); - } - - #[test] - fn physical_generated_text_keeps_native_repeat_lifecycle() { - let key = physical_generated_slash(3); - let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; - let forwarded_target = target(); - let mut leases = InputLeaseTable::default(); - - assert_eq!( - leases.normalize_press(&lease_key, key.clone()).kind, - crossterm::event::KeyEventKind::Press - ); - assert!(matches!( - leases.complete_press( - lease_key, - &key, - Some(&context), - Some(&context), - Some(forwarded_target.clone()), - ), - RepeatPlan::Ignore - )); - assert!(leases.contains(&lease_key)); - - let repeated = key.with_repeat_count(1); - let repeated = leases.normalize_press(&lease_key, repeated); - assert_eq!(repeated.kind, crossterm::event::KeyEventKind::Repeat); - assert!(matches!( - leases.plan_repeat(lease_key, &repeated, Some(&context)), - RepeatPlan::Forwarded(target) if target == forwarded_target - )); - assert!(leases.remove_forwarded(&lease_key).is_some()); - } - - #[test] - fn consumed_grouped_physical_generated_text_reprocesses_repeats() { - let key = physical_generated_slash(3); - let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; - let mut leases = InputLeaseTable::default(); - - assert!(matches!( - leases.complete_press(lease_key, &key, Some(&context), Some(&context), None), - RepeatPlan::Reprocess { - context: TerminalInputContext::Pane, - repetitions: 2, - tracked: true, - } - )); - } - - #[test] - fn semantic_generated_text_remains_untracked() { - let key = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_generated_text(Some("/".to_owned())) - .with_repeat_count(3); - let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; - let mut leases = InputLeaseTable::default(); - - assert!(matches!( - leases.complete_press( - lease_key, - &key, - Some(&context), - Some(&context), - Some(target()) - ), - RepeatPlan::Ignore - )); - assert!(leases.is_empty()); - } - - #[test] - fn new_semantic_press_recomputes_consumed_repeat_disposition() { - let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_repeat_count(3); - let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; - let mut leases = InputLeaseTable::default(); - leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); - - let key = leases.normalize_press(&lease_key, key); - let plan = leases.complete_press(lease_key, &key, Some(&context), Some(&context), None); - - assert!(matches!( - plan, - RepeatPlan::Reprocess { - context: TerminalInputContext::Pane, - repetitions: 2, - tracked: true, - } - )); - } - - #[test] - fn physical_and_semantic_identities_do_not_collide() { - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 65, - virtual_scan_code: 30, - unicode: 97, - control_key_state: 0, - }; - let physical = - TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record); - let semantic = TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()); - - assert_ne!( - InputLeaseKey::new(7, &physical), - InputLeaseKey::new(7, &semantic) - ); - } -} 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)); - } -} diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs deleted file mode 100644 index e710459b..00000000 --- a/src/app/input/modal.rs +++ /dev/null @@ -1,2421 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -#[cfg(test)] -use ratatui::layout::Direction; -use ratatui::layout::Rect; - -use crate::{ - app::{ - state::{ - AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode, NavigatorStateFilter, - }, - App, - }, - input::TerminalKey, - layout::NavDirection, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ModalAction { - Continue, - Save, - Clear, - Cancel, - Confirm, - Apply, - Close, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ModalKeyBinding { - Enter, - Esc, - CtrlC, -} - -impl ModalKeyBinding { - fn matches(self, key: &KeyEvent) -> bool { - match self { - Self::Enter => key.code == KeyCode::Enter, - Self::Esc => key.code == KeyCode::Esc, - Self::CtrlC => { - key.code == KeyCode::Char('c') - && key.modifiers == crossterm::event::KeyModifiers::CONTROL - } - } - } -} - -#[derive(Debug, Clone, Copy)] -pub(super) struct ModalActionSpec<A> { - pub action: A, - pub bindings: &'static [ModalKeyBinding], -} - -pub(super) fn modal_action_from_key<A: Copy>( - key: &KeyEvent, - specs: &[ModalActionSpec<A>], -) -> Option<A> { - specs - .iter() - .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key))) - .map(|spec| spec.action) -} - -pub(super) fn modal_action_from_buttons<A: Copy>( - col: u16, - row: u16, - buttons: &[(Rect, A)], -) -> Option<A> { - buttons.iter().find_map(|(rect, action)| { - (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) - .then_some(*action) - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum GlobalMenuAction { - Detach, - WhatsNew, - Keybinds, - ReloadConfig, - Settings, -} - -pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> { - let mut actions = vec![ - GlobalMenuAction::Settings, - GlobalMenuAction::Keybinds, - GlobalMenuAction::ReloadConfig, - ]; - if state.update_available.is_some() || state.latest_release_notes_available { - actions.push(GlobalMenuAction::WhatsNew); - } - actions.push(GlobalMenuAction::Detach); - actions -} - -pub(super) fn open_global_menu(state: &mut AppState) { - state.global_menu = MenuListState::new(0); - state.mode = Mode::GlobalMenu; -} - -pub(super) fn open_keybind_help(state: &mut AppState) { - state.keybind_help.scroll = 0; - state.keybind_help.query.clear(); - state.keybind_help.search_focused = false; - state.mode = Mode::KeybindHelp; -} - -fn open_update_release_notes(state: &mut AppState) { - let Some(notes) = crate::release_notes::load_latest() else { - return; - }; - - state.release_notes = Some(crate::app::state::ReleaseNotesState { - version: notes.version, - body: notes.body, - scroll: 0, - preview: notes.preview, - }); - state.mode = Mode::ReleaseNotes; -} - -pub(super) fn request_detach(state: &mut AppState) { - if state.detach_exits { - state.should_quit = true; - } else { - state.detach_requested = true; - } -} - -pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) { - match action { - GlobalMenuAction::Detach => { - leave_modal(state); - request_detach(state); - } - GlobalMenuAction::WhatsNew => open_update_release_notes(state), - GlobalMenuAction::Keybinds => open_keybind_help(state), - GlobalMenuAction::ReloadConfig => { - state.request_reload_config = true; - leave_modal(state); - } - GlobalMenuAction::Settings => super::settings::open_settings(state), - } -} - -pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) { - let actions = global_menu_actions(state); - match key.code { - KeyCode::Esc => leave_modal(state), - KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(), - KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()), - KeyCode::Enter => { - if let Some(action) = actions.get(state.global_menu.highlighted).copied() { - apply_global_menu_action(state, action); - } - } - _ => {} - } -} - -pub(crate) fn handle_navigator_key( - state: &mut AppState, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - key: KeyEvent, -) { - if state.navigator.search_focused { - match key.code { - KeyCode::Esc => { - state.navigator.search_focused = false; - } - KeyCode::Enter => { - state.accept_navigator_selection_from(terminal_runtimes); - } - KeyCode::Backspace => { - state.navigator.state_filter = None; - state.navigator.query.pop(); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Up => state.move_navigator_selection_from(terminal_runtimes, -1), - KeyCode::Down => state.move_navigator_selection_from(terminal_runtimes, 1), - KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => { - state.move_navigator_selection_from(terminal_runtimes, 1) - } - KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => { - state.move_navigator_selection_from(terminal_runtimes, -1) - } - KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { - state.navigator.query.clear(); - state.navigator.state_filter = None; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char(c) - if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => - { - insert_navigator_search_text(state, terminal_runtimes, &c.to_string()); - } - _ => {} - } - return; - } - - match key.code { - KeyCode::Esc => { - leave_modal(state); - } - KeyCode::Enter => { - state.accept_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char('/') => { - state.navigator.state_filter = None; - state.navigator.search_focused = true; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Backspace if state.navigator.state_filter.is_some() => { - state.navigator.state_filter = None; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char('a') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = None; - state.clamp_navigator_selection_from(terminal_runtimes); - } - KeyCode::Char('b') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Blocked); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('w') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('i') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Idle); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('d') if key.modifiers.is_empty() => { - state.navigator.query.clear(); - state.navigator.state_filter = Some(NavigatorStateFilter::Done); - state.select_first_navigator_match_from(terminal_runtimes); - } - KeyCode::Char('j') | KeyCode::Down if key.modifiers.is_empty() => { - state.move_navigator_selection_from(terminal_runtimes, 1) - } - KeyCode::Char('k') | KeyCode::Up if key.modifiers.is_empty() => { - state.move_navigator_selection_from(terminal_runtimes, -1) - } - KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => state - .move_navigator_selection_by_lines_from( - terminal_runtimes, - (state.navigator_body_rect().height / 2).max(1) as isize, - ), - KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => state - .move_navigator_selection_by_lines_from( - terminal_runtimes, - -((state.navigator_body_rect().height / 2).max(1) as isize), - ), - KeyCode::Char(' ') => state.toggle_selected_navigator_workspace_from(terminal_runtimes), - KeyCode::Home => { - state.navigator.selected = 0; - state.ensure_navigator_selection_visible_from(terminal_runtimes); - } - KeyCode::End | KeyCode::Char('G') => { - state.navigator.selected = state - .navigator_rows_from(terminal_runtimes) - .len() - .saturating_sub(1); - state.ensure_navigator_selection_visible_from(terminal_runtimes); - } - _ => {} - } -} - -pub(crate) fn insert_navigator_search_text( - state: &mut AppState, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - text: &str, -) { - if !state.navigator.search_focused { - return; - } - state.navigator.state_filter = None; - state.navigator.query.push_str(text); - state.select_first_navigator_match_from(terminal_runtimes); -} - -pub(crate) fn insert_keybind_help_query_text(state: &mut AppState, text: &str) { - if !state.keybind_help.search_focused { - return; - } - state - .keybind_help - .query - .extend(text.chars().filter(|ch| !ch.is_control())); - state.keybind_help.scroll = 0; -} - -pub(super) fn keybind_help_back(state: &mut AppState) { - if state.keybind_help.search_focused { - state.keybind_help.query.clear(); - state.keybind_help.search_focused = false; - state.keybind_help.scroll = 0; - } else { - leave_modal(state); - } -} - -pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) { - if state.keybind_help.search_focused { - let text_char = keybind_help_text_char(key.clone()); - match key.code { - KeyCode::Up => state.scroll_keybind_help(-1), - KeyCode::Down => state.scroll_keybind_help(1), - KeyCode::PageUp => state.scroll_keybind_help(-8), - KeyCode::PageDown => state.scroll_keybind_help(8), - KeyCode::Home => state.keybind_help.scroll = 0, - KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), - KeyCode::Backspace => { - state.keybind_help.query.pop(); - state.keybind_help.scroll = 0; - } - KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { - state.keybind_help.query.clear(); - state.keybind_help.scroll = 0; - } - KeyCode::Esc => keybind_help_back(state), - KeyCode::Enter => leave_modal(state), - _ => { - if let Some(character) = text_char { - insert_keybind_help_query_text(state, &character.to_string()); - } - } - } - return; - } - - match key.code { - KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1), - KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1), - KeyCode::PageUp => state.scroll_keybind_help(-8), - KeyCode::PageDown => state.scroll_keybind_help(8), - KeyCode::Home => state.keybind_help.scroll = 0, - KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), - _ if keybind_help_text_char(key.clone()) == Some('/') => { - state.keybind_help.search_focused = true; - state.keybind_help.scroll = 0; - } - KeyCode::Esc => keybind_help_back(state), - KeyCode::Enter => leave_modal(state), - _ if keybind_help_text_char(key.clone()) == Some('?') => leave_modal(state), - _ => {} - } -} - -fn keybind_help_text_char(key: TerminalKey) -> Option<char> { - if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { - return None; - } - if let Some(character) = key.shifted_codepoint.and_then(char::from_u32) { - return Some(character); - } - let KeyCode::Char(character) = key.code else { - return None; - }; - Some(character) -} - -pub(super) fn open_rename_workspace( - state: &mut AppState, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ws_idx: usize, -) { - state.pending_workspace_create_cwd = None; - state.selected = ws_idx; - state.rename_pane_target = None; - state.name_input = - state.workspaces[ws_idx].display_name_from(&state.terminals, terminal_runtimes); - state.name_input_replace_on_type = false; - state.mode = Mode::RenameWorkspace; -} - -pub(crate) fn open_new_workspace_dialog(state: &mut AppState, cwd: std::path::PathBuf) { - let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = Some(cwd); - state.rename_pane_target = None; - state.name_input = suggested_name; - state.name_input_replace_on_type = true; - state.mode = Mode::RenameWorkspace; -} - -pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { - if let Some(name) = ws.active_tab_display_name() { - state.name_input = name; - state.name_input_replace_on_type = replace_on_type; - state.mode = Mode::RenameTab; - } - } -} - -pub(super) fn open_rename_pane(state: &mut AppState, pane_id: crate::layout::PaneId) { - let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) else { - return; - }; - let Some(pane) = ws.pane_state(pane_id) else { - return; - }; - let terminal = state.terminals.get(&pane.attached_terminal_id); - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = Some(pane_id); - state.name_input = terminal - .and_then(|t| t.manual_label.clone()) - .unwrap_or_default(); - state.name_input_replace_on_type = terminal.and_then(|t| t.manual_label.as_ref()).is_none(); - state.mode = Mode::RenamePane; -} - -fn workspace_create_label(input: &str, suggested_name: &str) -> Option<String> { - let name = input.trim(); - (!name.is_empty() && name != suggested_name).then(|| name.to_string()) -} - -fn next_new_tab_default_name(state: &AppState) -> String { - state - .active - .and_then(|i| state.workspaces.get(i)) - .map(|ws| (ws.tabs.len() + 1).to_string()) - .unwrap_or_else(|| "1".to_string()) -} - -pub(super) fn open_new_tab_dialog(state: &mut AppState) { - state.creating_new_tab = true; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input = next_new_tab_default_name(state); - state.name_input_replace_on_type = true; - state.mode = Mode::RenameTab; -} - -pub(super) fn leave_modal(state: &mut AppState) { - if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - } -} - -pub(super) const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec { - action: ModalAction::Continue, - bindings: &[ModalKeyBinding::Enter], -}]; - -pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec { - action: ModalAction::Close, - bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc], -}]; - -pub(super) const RENAME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ - ModalActionSpec { - action: ModalAction::Save, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Clear, - bindings: &[ModalKeyBinding::CtrlC], - }, - ModalActionSpec { - action: ModalAction::Cancel, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -pub(super) const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ - ModalActionSpec { - action: ModalAction::Confirm, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Cancel, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -pub(super) const SETTINGS_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ - ModalActionSpec { - action: ModalAction::Apply, - bindings: &[ModalKeyBinding::Enter], - }, - ModalActionSpec { - action: ModalAction::Close, - bindings: &[ModalKeyBinding::Esc], - }, -]; - -#[cfg(test)] -pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { - match action { - ModalAction::Save => { - let new_name = if state.name_input.trim().is_empty() { - state.name_input.clone() - } else { - state.name_input.trim().to_string() - }; - match state.mode { - Mode::RenameWorkspace - if state.pending_workspace_create_cwd.is_none() - && !state.workspaces.is_empty() - && !new_name.is_empty() => - { - let workspace_id = state.workspaces[state.selected].id.clone(); - state.workspaces[state.selected].set_custom_name(new_name); - crate::logging::workspace_renamed(&workspace_id); - state.mark_session_dirty(); - } - Mode::RenameTab if state.creating_new_tab => { - state.request_new_tab = true; - let default_name = next_new_tab_default_name(state); - state.requested_new_tab_name = - if new_name.is_empty() || new_name == default_name { - None - } else { - Some(new_name) - }; - } - Mode::RenameTab => { - if let Some(ws_idx) = state.active { - if let Some(ws) = state.workspaces.get_mut(ws_idx) { - let workspace_id = ws.id.clone(); - let active_tab = ws.active_tab; - let keep_auto_name = ws - .tabs - .get(active_tab) - .is_some_and(|tab| tab.is_auto_named()) - && ws - .tab_display_name(active_tab) - .is_some_and(|name| new_name == name); - if let Some(tab) = ws.active_tab_mut() { - if !new_name.is_empty() && !keep_auto_name { - tab.set_custom_name(new_name); - let tab_id = ws - .public_tab_number(active_tab) - .map(|number| { - crate::workspace::public_tab_id_for_number( - &workspace_id, - number, - ) - }) - .unwrap_or_else(|| workspace_id.clone()); - crate::logging::tab_renamed(&workspace_id, &tab_id); - state.mark_session_dirty(); - } - } - } - } - } - Mode::RenamePane => { - if let (Some(ws_idx), Some(pane_id)) = (state.active, state.rename_pane_target) - { - if let Some(ws) = state.workspaces.get(ws_idx) { - if let Some(pane) = ws.pane_state(pane_id) { - let terminal_id = pane.attached_terminal_id.clone(); - if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.set_manual_label(new_name); - state.mark_session_dirty(); - } - } - } - } - } - _ => {} - } - state.creating_new_tab = false; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); - } - ModalAction::Clear => { - state.name_input.clear(); - state.name_input_replace_on_type = false; - } - ModalAction::Cancel => { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); - } - _ => {} - } -} - -fn clear_rename_input(state: &mut AppState) { - state.name_input.clear(); - state.name_input_replace_on_type = false; -} - -pub(crate) fn insert_rename_input_text(state: &mut AppState, text: &str) { - if state.name_input_replace_on_type { - clear_rename_input(state); - } - state.name_input.push_str(text); -} - -fn delete_rename_input_char(state: &mut AppState) { - if state.name_input_replace_on_type { - clear_rename_input(state); - } else { - state.name_input.pop(); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RenameWordDeleteClass { - Word, - Separator, -} - -fn rename_word_delete_class(ch: char) -> RenameWordDeleteClass { - if ch.is_alphanumeric() || ch == '_' { - RenameWordDeleteClass::Word - } else { - RenameWordDeleteClass::Separator - } -} - -fn delete_rename_input_word(state: &mut AppState) { - if state.name_input_replace_on_type { - clear_rename_input(state); - return; - } - - while state - .name_input - .chars() - .last() - .is_some_and(char::is_whitespace) - { - state.name_input.pop(); - } - - let Some(class) = state - .name_input - .chars() - .last() - .map(rename_word_delete_class) - else { - return; - }; - - while state - .name_input - .chars() - .last() - .is_some_and(|ch| !ch.is_whitespace() && rename_word_delete_class(ch) == class) - { - state.name_input.pop(); - } -} - -fn handle_rename_edit_key(state: &mut AppState, key: KeyEvent) { - match key.code { - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - clear_rename_input(state); - } - KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => { - clear_rename_input(state); - } - KeyCode::Backspace - if key.modifiers.contains(KeyModifiers::CONTROL) - || key.modifiers.contains(KeyModifiers::ALT) => - { - delete_rename_input_word(state); - } - KeyCode::Char('h' | 'w') if key.modifiers.contains(KeyModifiers::CONTROL) => { - delete_rename_input_word(state); - } - KeyCode::Backspace => delete_rename_input_char(state), - KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { - insert_rename_input_text(state, &c.to_string()); - } - _ => {} - } -} - -#[cfg(test)] -pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) { - if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { - apply_rename_action(state, action); - return; - } - - handle_rename_edit_key(state, key); -} - -#[cfg(test)] -pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - if key.code == KeyCode::Esc - || key.code == KeyCode::Enter - || state.keybinds.resize_mode.matches_prefix_key(&raw_key) - || state.keybinds.resize_mode.matches_direct_key(&raw_key) - { - if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - } - return; - } - - match key.code { - KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left), - KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right), - KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down), - KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up), - _ => {} - } -} - -pub(super) fn open_confirm_close(state: &mut AppState) { - state.begin_workspace_close_confirmation(state.selected); -} - -#[cfg(test)] -pub(super) fn confirm_close_accept(state: &mut AppState) { - if let Some(ws_idx) = state.take_confirmed_workspace_close_index() { - state.selected = ws_idx; - state.close_selected_workspace(); - } - if state.workspaces.is_empty() { - state.mode = Mode::Navigate; - } else { - state.mode = Mode::Terminal; - } -} - -pub(super) fn confirm_close_cancel(state: &mut AppState) { - state.confirm_close_workspace_id = None; - state.mode = Mode::Navigate; -} - -#[cfg(test)] -pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { - match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { - Some(ModalAction::Confirm) => confirm_close_accept(state), - Some(ModalAction::Cancel) => confirm_close_cancel(state), - _ => {} - } -} - -#[cfg(test)] -pub(super) fn apply_context_menu_action( - state: &mut AppState, - terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, - menu: ContextMenuState, - idx: usize, -) { - let item = menu.items().get(idx).copied(); - match (menu.kind, item) { - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => { - state.request_new_linked_worktree = Some(ws_idx); - leave_modal(state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => { - state.request_remove_linked_worktree = Some(ws_idx); - leave_modal(state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => { - state.request_open_existing_worktree = Some(ws_idx); - leave_modal(state); - } - ( - ContextMenuKind::GitWorkspace { - ws_idx, collapsed, .. - }, - Some("Collapse" | "Expand"), - ) => { - if let Some(key) = state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.clone()) - { - if collapsed { - state.collapsed_space_keys.remove(&key); - } else { - state.collapsed_space_keys.insert(key); - } - state.mark_session_dirty(); - } - leave_modal(state); - } - ( - ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Rename"), - ) => { - open_rename_workspace(state, terminal_runtimes, ws_idx); - } - ( - ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Close" | "Close group"), - ) => { - state.selected = ws_idx; - if state.confirm_close { - open_confirm_close(state); - } else { - state.close_selected_workspace(); - state.mode = Mode::Navigate; - } - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - open_new_tab_dialog(state); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - open_rename_active_tab(state, false); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - if !state.close_tab() { - state.mode = if state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { - open_rename_pane(state, pane_id); - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Clear pane name"), - ) => { - if let Some(ws) = state.workspaces.get(ws_idx) { - if let Some(pane) = ws.pane_state(pane_id) { - let terminal_id = pane.attached_terminal_id.clone(); - if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.clear_manual_label(); - state.mark_session_dirty(); - } - } - } - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - source_pane_id, - .. - }, - Some("Swap with focused pane"), - ) => { - if let Some(source_pane_id) = source_pane_id { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - if let Some(tab) = state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - { - if tab.layout.swap_panes(source_pane_id, pane_id) { - tab.layout.focus_pane(source_pane_id); - state.mark_session_dirty(); - } - } - } - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Split right"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - state.split_pane(terminal_runtimes, Direction::Horizontal); - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Split down"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - state.split_pane(terminal_runtimes, Direction::Vertical); - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Zoom"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - state.toggle_zoom(); - state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - .. - }, - Some("Close pane"), - ) => { - state.selected = ws_idx; - state.active = Some(ws_idx); - state.switch_tab(tab_idx); - state.focus_pane_in_workspace(ws_idx, pane_id); - if !state.close_pane() { - state.mode = if state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - _ => leave_modal(state), - } -} - -#[cfg(test)] -pub(crate) fn handle_context_menu_key( - state: &mut AppState, - terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry, - key: KeyEvent, -) { - match key.code { - KeyCode::Esc => { - state.context_menu = None; - leave_modal(state); - } - KeyCode::Up => { - if let Some(menu) = &mut state.context_menu { - menu.list.move_prev(); - } - } - KeyCode::Down => { - if let Some(menu) = &mut state.context_menu { - menu.list.move_next(menu.items().len()); - } - } - KeyCode::Enter => { - if let Some(menu) = state.context_menu.take() { - let idx = menu.list.highlighted; - apply_context_menu_action(state, terminal_runtimes, menu, idx); - } - } - _ => {} - } -} - -impl App { - pub(crate) fn handle_rename_key_via_api(&mut self, key: KeyEvent) { - if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { - self.apply_rename_mouse_action_via_api(action); - return; - } - - handle_rename_edit_key(&mut self.state, key); - } - - fn save_rename_modal_via_api(&mut self) { - let new_name = if self.state.name_input.trim().is_empty() { - self.state.name_input.clone() - } else { - self.state.name_input.trim().to_string() - }; - - match self.state.mode { - Mode::RenameWorkspace => { - if let Some(cwd) = self.state.pending_workspace_create_cwd.take() { - let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); - let label = workspace_create_label(&new_name, &suggested_name); - self.runtime_workspace_create( - "tui.workspace.create_named", - crate::api::schema::WorkspaceCreateParams { - cwd: Some(cwd.display().to_string()), - focus: true, - label, - env: Default::default(), - }, - ); - } else if !self.state.workspaces.is_empty() && !new_name.is_empty() { - let workspace_id = self.public_workspace_id(self.state.selected); - self.runtime_workspace_rename( - "tui.workspace.rename", - crate::api::schema::WorkspaceRenameParams { - workspace_id, - label: new_name, - }, - ); - } - } - Mode::RenameTab if self.state.creating_new_tab => { - let default_name = next_new_tab_default_name(&self.state); - let label = if new_name.is_empty() || new_name == default_name { - None - } else { - Some(new_name) - }; - self.runtime_tab_create( - "tui.tab.create_named", - crate::api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label, - env: Default::default(), - }, - ); - } - Mode::RenameTab if !new_name.is_empty() => { - let Some(ws_idx) = self.state.active else { - cancel_rename_modal(&mut self.state); - return; - }; - let tab_idx = self.state.workspaces[ws_idx].active_tab; - let keep_auto_name = self.state.workspaces[ws_idx] - .tabs - .get(tab_idx) - .is_some_and(|tab| tab.is_auto_named()) - && self.state.workspaces[ws_idx] - .tab_display_name(tab_idx) - .is_some_and(|name| new_name == name); - if !keep_auto_name { - if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { - self.runtime_tab_rename( - "tui.tab.rename", - crate::api::schema::TabRenameParams { - tab_id, - label: new_name, - }, - ); - } - } - } - Mode::RenamePane => { - if let (Some(ws_idx), Some(pane_id)) = - (self.state.active, self.state.rename_pane_target) - { - if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.runtime_pane_rename( - "tui.pane.rename", - crate::api::schema::PaneRenameParams { - pane_id, - label: Some(new_name), - }, - ); - } - } - } - _ => {} - } - - cancel_rename_modal(&mut self.state); - } - - pub(super) fn apply_rename_mouse_action_via_api(&mut self, action: ModalAction) { - match action { - ModalAction::Save => self.save_rename_modal_via_api(), - ModalAction::Clear => { - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - } - ModalAction::Cancel => cancel_rename_modal(&mut self.state), - _ => {} - } - } - - pub(super) fn confirm_close_accept_via_api(&mut self) { - if let Some(ws_idx) = self.state.take_confirmed_workspace_close_index() { - self.close_workspace_idx_with_group_via_api(ws_idx); - } - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - pub(crate) fn handle_resize_key_via_api(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - if key.code == KeyCode::Esc - || key.code == KeyCode::Enter - || self.state.keybinds.resize_mode.matches_prefix_key(&raw_key) - || self.state.keybinds.resize_mode.matches_direct_key(&raw_key) - { - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - return; - } - - let direction = match key.code { - KeyCode::Char('h') | KeyCode::Left => Some(NavDirection::Left), - KeyCode::Char('l') | KeyCode::Right => Some(NavDirection::Right), - KeyCode::Char('j') | KeyCode::Down => Some(NavDirection::Down), - KeyCode::Char('k') | KeyCode::Up => Some(NavDirection::Up), - _ => None, - }; - if let Some(direction) = direction { - self.runtime_pane_resize( - "tui.pane.resize", - crate::api::schema::PaneResizeParams { - pane_id: None, - direction: super::navigate::api_pane_direction(direction), - amount: None, - }, - ); - } - } - - pub(crate) fn handle_confirm_close_key_via_api(&mut self, key: KeyEvent) { - match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { - Some(ModalAction::Confirm) => { - self.confirm_close_accept_via_api(); - } - Some(ModalAction::Cancel) => confirm_close_cancel(&mut self.state), - _ => {} - } - } - - pub(crate) fn handle_context_menu_key_via_api(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.context_menu = None; - leave_modal(&mut self.state); - } - KeyCode::Up => { - if let Some(menu) = &mut self.state.context_menu { - menu.list.move_prev(); - } - } - KeyCode::Down => { - if let Some(menu) = &mut self.state.context_menu { - menu.list.move_next(menu.items().len()); - } - } - KeyCode::Enter => { - if let Some(menu) = self.state.context_menu.take() { - let idx = menu.list.highlighted; - self.apply_context_menu_action_via_api(menu, idx); - } - } - _ => {} - } - } - - pub(crate) fn apply_context_menu_action_via_api(&mut self, menu: ContextMenuState, idx: usize) { - let item = menu.items().get(idx).copied(); - match (menu.kind, item) { - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => { - self.state.request_new_linked_worktree = Some(ws_idx); - leave_modal(&mut self.state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => { - self.state.request_remove_linked_worktree = Some(ws_idx); - leave_modal(&mut self.state); - } - (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => { - self.state.request_open_existing_worktree = Some(ws_idx); - leave_modal(&mut self.state); - } - ( - ContextMenuKind::GitWorkspace { - ws_idx, collapsed, .. - }, - Some("Collapse" | "Expand"), - ) => { - if let Some(key) = self - .state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.clone()) - { - if collapsed { - self.state.collapsed_space_keys.remove(&key); - } else { - self.state.collapsed_space_keys.insert(key); - } - self.state.mark_session_dirty(); - } - leave_modal(&mut self.state); - } - ( - ContextMenuKind::Workspace { ws_idx } - | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Rename"), - ) => open_rename_workspace(&mut self.state, &self.terminal_runtimes, ws_idx), - ( - ContextMenuKind::Workspace { ws_idx } - | ContextMenuKind::GitWorkspace { ws_idx, .. }, - Some("Close" | "Close group"), - ) => { - self.state.selected = ws_idx; - if self.state.confirm_close { - open_confirm_close(&mut self.state); - } else { - self.close_workspace_idx_with_group_via_api(ws_idx); - self.state.mode = Mode::Navigate; - } - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { - self.focus_workspace_idx_via_api(ws_idx); - self.focus_tab_idx_via_api(tab_idx); - open_new_tab_dialog(&mut self.state); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { - self.focus_workspace_idx_via_api(ws_idx); - self.focus_tab_idx_via_api(tab_idx); - open_rename_active_tab(&mut self.state, false); - } - (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { - self.focus_workspace_idx_via_api(ws_idx); - self.focus_tab_idx_via_api(tab_idx); - if !self.close_active_tab_via_api_requires_confirmation() { - leave_modal(&mut self.state); - } - } - (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { - open_rename_pane(&mut self.state, pane_id); - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Clear pane name"), - ) => { - if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.runtime_pane_rename( - "tui.pane.clear_name", - crate::api::schema::PaneRenameParams { - pane_id, - label: None, - }, - ); - } - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some(action @ ("Send right-clicks to pane" | "Use Herdr right-click menu")), - ) => { - if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.runtime_pane_input_set( - "tui.pane.input.set", - crate::api::schema::PaneInputSetParams { - pane_id, - right_click: if action == "Send right-clicks to pane" { - crate::api::schema::PaneRightClickTarget::Pane - } else { - crate::api::schema::PaneRightClickTarget::Herdr - }, - }, - ); - } - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, - pane_id, - source_pane_id: Some(source_pane_id), - .. - }, - Some("Swap with focused pane"), - ) => { - let source_public_id = self.public_pane_id(ws_idx, source_pane_id); - let target_public_id = self.public_pane_id(ws_idx, pane_id); - if let (Some(source_public_id), Some(target_public_id)) = - (source_public_id, target_public_id) - { - self.runtime_pane_swap( - "tui.pane.swap_exact", - crate::api::schema::PaneSwapParams { - pane_id: None, - direction: None, - source_pane_id: Some(source_public_id), - target_pane_id: Some(target_public_id), - }, - ); - self.focus_pane_internal_via_api(ws_idx, source_pane_id); - } - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Split right"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Right); - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Split down"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Down); - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Zoom"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.zoom_focused_pane_via_api(); - self.state.mode = Mode::Terminal; - } - ( - ContextMenuKind::Pane { - ws_idx, pane_id, .. - }, - Some("Close pane"), - ) => { - self.focus_pane_internal_via_api(ws_idx, pane_id); - if !self.close_focused_pane_via_api_requires_confirmation() { - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - } - _ => leave_modal(&mut self.state), - } - } -} - -fn cancel_rename_modal(state: &mut AppState) { - state.creating_new_tab = false; - state.requested_new_tab_name = None; - state.pending_workspace_create_cwd = None; - state.rename_pane_target = None; - state.name_input.clear(); - state.name_input_replace_on_type = false; - leave_modal(state); -} - -impl AppState { - pub(super) fn global_menu_item_at(&self, col: u16, row: u16) -> Option<GlobalMenuAction> { - let rect = self.global_menu_rect(); - if col <= rect.x - || col >= rect.x + rect.width.saturating_sub(1) - || row <= rect.y - || row >= rect.y + rect.height.saturating_sub(1) - { - return None; - } - let idx = (row - rect.y - 1) as usize; - global_menu_actions(self).get(idx).copied() - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use ratatui::layout::Rect; - - use super::super::{capture_snapshot, state_with_workspaces}; - use super::*; - use crate::workspace::Workspace; - - fn config_env_lock() -> &'static std::sync::Mutex<()> { - crate::config::test_config_env_lock() - } - - fn temp_config_path(name: &str) -> std::path::PathBuf { - let unique = format!( - "herdr-modal-{name}-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - std::env::temp_dir().join(unique).join("config.toml") - } - - fn app_with_test_workspaces(names: &[&str]) -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &crate::config::Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); - app.state.ensure_test_terminals(); - app.state.active = (!app.state.workspaces.is_empty()).then_some(0); - app.state.selected = 0; - app - } - - #[test] - fn workspace_create_label_preserves_auto_name_for_suggestion_or_blank() { - assert_eq!(workspace_create_label("project", "project"), None); - assert_eq!(workspace_create_label("", "project"), None); - assert_eq!(workspace_create_label(" ", "project"), None); - assert_eq!( - workspace_create_label(" logs ", "project").as_deref(), - Some("logs") - ); - } - - fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { - state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/worktree-{ws_idx}").into(), - is_linked_worktree: ws_idx != 0, - }); - } - - #[test] - fn custom_resize_key_exits_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::Resize; - state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g"); - - handle_resize_key( - &mut state, - TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn direct_resize_key_exits_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::Resize; - state.keybinds.resize_mode = crate::config::ActionKeybinds::direct("ctrl+alt+r"); - - handle_resize_key( - &mut state, - TerminalKey::new( - KeyCode::Char('r'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - ), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn resize_key_exit_matches_enhanced_shifted_punctuation() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::Resize; - state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("?"); - - handle_resize_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn detach_requests_client_detach_in_persistence_mode() { - let mut state = state_with_workspaces(&["test"]); - state.detach_exits = false; - - request_detach(&mut state); - - assert!(state.detach_requested); - assert!(!state.should_quit); - } - - #[test] - fn detach_exits_in_no_session_mode() { - let mut state = state_with_workspaces(&["test"]); - state.detach_exits = true; - - request_detach(&mut state); - - assert!(state.should_quit); - assert!(!state.detach_requested); - } - - #[test] - fn global_menu_whats_new_opens_saved_release_notes() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("whats-new-saved-release-notes"); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - crate::release_notes::save_pending(env!("CARGO_PKG_VERSION"), "### Changed\n- Menu") - .unwrap(); - - let mut state = state_with_workspaces(&["test"]); - state.latest_release_notes_available = true; - - assert!(global_menu_actions(&state).contains(&GlobalMenuAction::WhatsNew)); - - apply_global_menu_action(&mut state, GlobalMenuAction::WhatsNew); - - assert_eq!(state.mode, Mode::ReleaseNotes); - assert_eq!( - state - .release_notes - .as_ref() - .map(|notes| notes.body.as_str()), - Some("### Changed\n- Menu") - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn rename_modal_keyboard_and_mouse_share_actions() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameWorkspace; - state.name_input = "hello".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - assert!(state.name_input.is_empty()); - - state.name_input = "renamed".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Terminal); - assert_eq!(state.workspaces[0].display_name(), "renamed"); - let snapshot = capture_snapshot(&state); - assert_eq!( - snapshot.workspaces[0].custom_name.as_deref(), - Some("renamed") - ); - - state.view.sidebar_rect = Rect::new(0, 0, 26, 20); - state.view.terminal_area = Rect::new(26, 0, 80, 20); - state.mode = Mode::RenameWorkspace; - state.name_input = "mouse".into(); - let inner = state.rename_modal_inner().unwrap(); - let (save, _, _) = crate::ui::rename_button_rects(inner); - let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]); - assert_eq!(action, Some(ModalAction::Save)); - } - - #[test] - fn tab_rename_updates_captured_snapshot() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "logs".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - let snapshot = capture_snapshot(&state); - assert_eq!( - snapshot.workspaces[0].tabs[0].custom_name.as_deref(), - Some("logs") - ); - } - - #[test] - fn rename_cancel_returns_to_terminal_when_workspace_is_active() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "test".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(state.name_input.is_empty()); - } - - #[test] - fn rename_modal_replaces_prefilled_text_on_first_type() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "2".into(); - state.name_input_replace_on_type = true; - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "n"); - assert!(!state.name_input_replace_on_type); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "ne"); - } - - #[test] - fn rename_modal_replaces_prefilled_text_on_paste() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameTab; - state.name_input = "2".into(); - state.name_input_replace_on_type = true; - - insert_rename_input_text(&mut state, "feature/logs"); - - assert_eq!(state.name_input, "feature/logs"); - assert!(!state.name_input_replace_on_type); - - insert_rename_input_text(&mut state, "-copy"); - - assert_eq!(state.name_input, "feature/logs-copy"); - } - - #[test] - fn rename_modal_handles_line_editing_shortcuts() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameWorkspace; - state.name_input = "website zero".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()), - ); - assert_eq!(state.name_input, "website zer"); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website "); - - state.name_input = "website-zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), - ); - assert_eq!(state.name_input, "website-"); - - state.name_input = "website-zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website-"); - - state.name_input = "website-zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website-"); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER), - ); - assert!(state.name_input.is_empty()); - - state.name_input = "website zero".into(); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), - ); - assert!(state.name_input.is_empty()); - } - - #[test] - fn rename_modal_does_not_insert_modified_shortcut_chars() { - let mut state = state_with_workspaces(&["test"]); - state.mode = Mode::RenameWorkspace; - state.name_input = "website".into(); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), - ); - assert_eq!(state.name_input, "website"); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT), - ); - assert_eq!(state.name_input, "websiteZ"); - } - - #[test] - fn keybind_help_slash_focuses_filter_and_preserves_vim_scroll() { - let mut state = state_with_workspaces(&["test"]); - state.keybind_help.query = "stale".into(); - state.keybind_help.search_focused = true; - state.view.terminal_area = Rect::new(0, 0, 100, 30); - - open_keybind_help(&mut state); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.keybind_help.scroll, 1); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty()), - ); - assert_eq!(state.keybind_help.scroll, 0); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty()), - ); - assert!(state.keybind_help.query.is_empty()); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - for character in "work".chars() { - state.keybind_help.scroll = 2; - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char(character), KeyModifiers::empty()), - ); - } - - assert!(state.keybind_help.search_focused); - assert_eq!(state.keybind_help.query, "work"); - assert_eq!(state.keybind_help.scroll, 0); - } - - #[test] - fn keybind_help_query_supports_backspace_clear_and_sanitized_paste() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - - insert_keybind_help_query_text(&mut state, "work\nspace"); - assert_eq!(state.keybind_help.query, "workspace"); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Backspace, KeyModifiers::empty()), - ); - assert_eq!(state.keybind_help.query, "workspac"); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL), - ); - assert!(state.keybind_help.query.is_empty()); - } - - #[test] - fn keybind_help_escape_leaves_search_before_closing() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - state.keybind_help.search_focused = true; - state.keybind_help.query = "work".into(); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::KeybindHelp); - assert!(!state.keybind_help.search_focused); - assert!(state.keybind_help.query.is_empty()); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn enhanced_shifted_slash_focuses_keybind_help_filter() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('7'), KeyModifiers::SHIFT) - .with_shifted_codepoint('/' as u32), - ); - - assert!(state.keybind_help.search_focused); - } - - #[test] - fn enhanced_shifted_question_mark_closes_keybind_help_when_not_searching() { - let mut state = state_with_workspaces(&["test"]); - open_keybind_help(&mut state); - - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.mode, Mode::Terminal); - - open_keybind_help(&mut state); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - handle_keybind_help_key( - &mut state, - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ); - - assert_eq!(state.keybind_help.query, "?"); - } - - #[test] - fn navigator_search_accepts_pasted_text_when_focused() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = true; - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - - insert_navigator_search_text(&mut state, &terminal_runtimes, "beta"); - - assert_eq!(state.navigator.query, "beta"); - assert_eq!(state.navigator.state_filter, None); - } - - #[test] - fn navigator_search_ignores_paste_when_search_is_not_focused() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = false; - - insert_navigator_search_text(&mut state, &terminal_runtimes, "beta"); - - assert!(state.navigator.query.is_empty()); - } - - #[test] - fn navigator_empty_search_escape_returns_to_commands() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = true; - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(!state.navigator.search_focused); - assert!(state.navigator.query.is_empty()); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('w'), KeyModifiers::empty()), - ); - - assert_eq!( - state.navigator.state_filter, - Some(NavigatorStateFilter::Working) - ); - assert!(state.navigator.query.is_empty()); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn navigator_search_escape_blurs_then_next_escape_closes() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.search_focused = true; - state.navigator.query = "a".into(); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(!state.navigator.search_focused); - assert_eq!(state.navigator.query, "a"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - - assert_eq!(state.navigator.selected, 1); - assert_eq!(state.navigator.query, "a"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('/'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(state.navigator.search_focused); - assert_eq!(state.navigator.query, "a"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('l'), KeyModifiers::empty()), - ); - - assert_eq!(state.navigator.query, "al"); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - assert!(!state.navigator.search_focused); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn navigator_ignores_modified_j_and_k() { - let mut state = state_with_workspaces(&["alpha", "beta"]); - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.mode = Mode::Navigator; - state.navigator.selected = 1; - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), - ); - - assert_eq!(state.navigator.selected, 1); - - handle_navigator_key( - &mut state, - &terminal_runtimes, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - - assert_eq!(state.navigator.selected, 1); - } - - #[test] - fn open_rename_active_tab_can_prefill_default_new_tab_name() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_add_tab(None); - state.workspaces[0].switch_tab(1); - - open_rename_active_tab(&mut state, true); - - assert_eq!(state.mode, Mode::RenameTab); - assert_eq!(state.name_input, "2"); - assert!(state.name_input_replace_on_type); - } - - #[test] - fn cancel_new_tab_dialog_leaves_workspace_unchanged() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(!state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - assert_eq!(state.workspaces[0].tabs.len(), 1); - } - - #[test] - fn saving_new_tab_dialog_requests_creation_with_name() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - state.name_input = "logs".into(); - state.name_input_replace_on_type = false; - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs")); - } - - #[test] - fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - } - - #[test] - fn closing_first_auto_tab_compacts_remaining_auto_tab_label_and_next_prompt() { - let mut state = state_with_workspaces(&["test"]); - open_new_tab_dialog(&mut state); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref()); - state.request_new_tab = false; - state.requested_new_tab_name = None; - - state.workspaces[0].close_tab(0); - state.workspaces[0].switch_tab(0); - - assert_eq!( - state.workspaces[0].tab_display_name(0).as_deref(), - Some("1") - ); - assert!(state.workspaces[0].tabs[0].custom_name.is_none()); - - open_new_tab_dialog(&mut state); - assert_eq!(state.name_input, "2"); - } - - #[test] - fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_add_tab(None); - state.workspaces[0].switch_tab(1); - - open_rename_active_tab(&mut state, false); - handle_rename_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert!(state.workspaces[0].tabs[1].custom_name.is_none()); - assert_eq!( - state.workspaces[0].tab_display_name(1).as_deref(), - Some("2") - ); - } - - #[test] - fn confirm_close_keyboard_actions_are_direct_not_focused() { - let mut state = state_with_workspaces(&["a", "b"]); - state.selected = 1; - open_confirm_close(&mut state); - - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::Navigate); - assert_eq!(state.workspaces.len(), 2); - - open_confirm_close(&mut state); - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces.len(), 1); - } - - #[test] - fn confirm_close_for_linked_worktree_closes_workspace_only() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.selected = 1; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - open_confirm_close(&mut state); - handle_confirm_close_key( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn context_menu_close_group_opens_group_close_confirmation() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.active = Some(0); - state.selected = 1; - state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: false, - has_worktree_children: true, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - - apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, 1); - - assert_eq!(state.selected, 0); - assert_eq!(state.mode, Mode::ConfirmClose); - - confirm_close_accept(&mut state); - - assert!(state.workspaces.is_empty()); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn context_menu_toggles_pane_right_click_passthrough() { - let mut app = app_with_test_workspaces(&["main"]); - app.state.active = Some(0); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let menu = ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let idx = menu - .items() - .iter() - .position(|item| *item == "Send right-clicks to pane") - .unwrap(); - app.apply_context_menu_action_via_api(menu, idx); - - assert!( - app.state.workspaces[0] - .pane_state(pane_id) - .unwrap() - .right_click_passthrough - ); - } - - #[test] - fn context_menu_close_pane_last_parent_group_pane_keeps_confirmation_mode() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.active = Some(0); - state.selected = 1; - state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }); - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - let pane_id = state.workspaces[0].tabs[0].root_pane; - let menu = ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let idx = menu - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane item"); - let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - - apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, idx); - - assert_eq!(state.selected, 0); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.workspaces.len(), 2); - } - - #[test] - fn api_confirm_close_accept_closes_parent_worktree_group() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.selected = 0; - open_confirm_close(&mut app.state); - - app.handle_confirm_close_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert!(app.state.workspaces.is_empty()); - assert_eq!(app.state.mode, Mode::Navigate); - assert_eq!(app.event_hub.events_after(0).len(), 2); - } - - #[test] - fn api_confirm_close_accept_keeps_the_original_workspace_target() { - let mut app = app_with_test_workspaces(&["main", "issue", "other"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.selected = 0; - open_confirm_close(&mut app.state); - - app.focus_workspace_idx_via_api(2); - assert_eq!(app.state.selected, 2); - assert_eq!(app.state.mode, Mode::ConfirmClose); - - app.handle_confirm_close_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "other"); - assert_eq!( - app.event_hub - .events_after(0) - .iter() - .filter(|(_, event)| matches!( - event.event, - crate::api::schema::EventKind::WorkspaceClosed - )) - .count(), - 2 - ); - } - - #[test] - fn api_context_menu_close_tab_last_parent_group_workspace_keeps_confirmation_mode() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::ContextMenu; - let menu = ContextMenuState { - kind: ContextMenuKind::Tab { - ws_idx: 0, - tab_idx: 0, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let idx = menu - .items() - .iter() - .position(|item| *item == "Close") - .expect("close tab item"); - - app.apply_context_menu_action_via_api(menu, idx); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - } - - #[test] - fn api_context_menu_enter_close_pane_last_parent_group_pane_keeps_confirmation_mode() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::ContextMenu; - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let mut menu = ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - let close_idx = menu - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane item"); - menu.list.highlighted = close_idx; - app.state.context_menu = Some(menu); - - app.handle_context_menu_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - assert!(app.state.context_menu.is_none()); - } -} diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs deleted file mode 100644 index bf82126b..00000000 --- a/src/app/input/mouse.rs +++ /dev/null @@ -1,4778 +0,0 @@ -use bytes::Bytes; -use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::layout::{Direction, Rect}; -use tracing::warn; - -use crate::{ - app::state::{ - AgentPanelSort, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, - MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout, - WorkspacePressState, - }, - layout::{PaneInfo, SplitBorder}, - selection::Selection, - terminal::TerminalRuntimeRegistry, -}; - -#[cfg(test)] -use super::WheelRouting; -use super::{ - modal::{ - apply_global_menu_action, confirm_close_cancel, global_menu_actions, leave_modal, - modal_action_from_buttons, open_global_menu, open_new_tab_dialog, ModalAction, - }, - settings::SettingsAction, - ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD, -}; - -pub(super) enum MouseAction { - NewWorkspace, - Settings(SettingsAction), - FocusWorkspace { - ws_idx: usize, - }, - FocusTab { - tab_idx: usize, - }, - FocusPane { - ws_idx: usize, - pane_id: crate::layout::PaneId, - }, - FocusToastTarget, - MoveWorkspace { - source_ws_idx: usize, - insert_idx: usize, - }, - MoveWorkspaceBlock { - params: crate::api::schema::WorkspaceMoveBlockParams, - }, - MoveTab { - ws_idx: usize, - source_tab_idx: usize, - insert_idx: usize, - }, - SetSplitRatio { - path: Vec<bool>, - ratio: f32, - }, - RenameModal(ModalAction), - ConfirmCloseAccept, - ContextMenu { - menu: ContextMenuState, - idx: usize, - }, -} - -enum MobileMouseResult { - Ignored, - Consumed, - Action(MouseAction), -} - -impl AppState { - pub(crate) fn handle_pane_mouse_only( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - mouse: MouseEvent, - ) { - if self.mode != Mode::Terminal { - return; - } - let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { - return; - }; - - match mouse.kind { - MouseEventKind::ScrollUp - | MouseEventKind::ScrollDown - | MouseEventKind::ScrollLeft - | MouseEventKind::ScrollRight => { - self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse); - } - MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { - self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); - } - MouseEventKind::Moved => { - self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse); - } - } - } - - pub(super) fn handle_mouse( - &mut self, - terminal_runtimes: &mut TerminalRuntimeRegistry, - source_id: crate::app::InputSourceId, - mouse: MouseEvent, - ) -> Option<MouseAction> { - if self.mode == Mode::Onboarding { - self.handle_onboarding_mouse(mouse); - return None; - } - - if self.mode == Mode::Terminal - && self.clickable_toast_at(mouse.column, mouse.row) - && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - { - return Some(MouseAction::FocusToastTarget); - } - - if self.mode == Mode::Terminal - && self.clickable_toast_at(mouse.column, mouse.row) - && matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) - { - return None; - } - - if self.mode == Mode::Settings { - return self.handle_settings_mouse(mouse).map(MouseAction::Settings); - } - - let launcher_enabled = self.view.layout != ViewLayout::Mobile - && !self.sidebar_collapsed - && matches!( - self.mode, - Mode::Terminal - | Mode::Navigate - | Mode::Resize - | Mode::GlobalMenu - | Mode::KeybindHelp - ); - let launcher = self.global_launcher_rect(); - let launcher_hit = launcher_enabled - && mouse.column >= launcher.x - && mouse.column < launcher.x + launcher.width - && mouse.row >= launcher.y - && mouse.row < launcher.y + launcher.height; - - if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu { - let actions = global_menu_actions(self); - let hovered = self - .global_menu_item_at(mouse.column, mouse.row) - .and_then(|action| actions.iter().position(|item| *item == action)); - self.global_menu.hover(hovered); - return None; - } - - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit { - if self.mode == Mode::GlobalMenu { - leave_modal(self); - } else { - open_global_menu(self); - } - return None; - } - - if self.mode == Mode::GlobalMenu { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) { - apply_global_menu_action(self, action); - } else { - leave_modal(self); - } - } - return None; - } - - if self.mode == Mode::KeybindHelp { - return None; - } - - if self.view.layout == ViewLayout::Mobile { - match self.handle_mobile_mouse(mouse) { - MobileMouseResult::Ignored => {} - MobileMouseResult::Consumed => return None, - MobileMouseResult::Action(action) => return Some(action), - } - } - - let sidebar = self.view.sidebar_rect; - let in_sidebar = mouse.column >= sidebar.x - && mouse.column < sidebar.x + sidebar.width - && mouse.row >= sidebar.y - && mouse.row < sidebar.y + sidebar.height; - - if self.handle_right_click_passthrough(terminal_runtimes, source_id, mouse, in_sidebar) { - return None; - } - - if self.mode == Mode::OpenExistingWorktree { - match mouse.kind { - MouseEventKind::ScrollUp => { - if let Some(open) = &mut self.worktree_open { - open.select_previous_filtered(); - } - return None; - } - MouseEventKind::ScrollDown => { - if let Some(open) = &mut self.worktree_open { - open.select_next_filtered(); - } - return None; - } - _ => {} - } - } - - if matches!( - self.mode, - Mode::NewLinkedWorktree | Mode::OpenExistingWorktree | Mode::ConfirmRemoveWorktree - ) && !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - { - return None; - } - - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - self.selection = None; - self.selection_autoscroll = None; - self.clear_chrome_press(source_id); - - if self.mode == Mode::ConfirmClose { - let popup = self.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (confirm, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - return Some(MouseAction::ConfirmCloseAccept); - } - Some(ModalAction::Cancel) | None => confirm_close_cancel(self), - _ => {} - } - return None; - } - - if self.mode == Mode::NewLinkedWorktree { - if let Some(inner) = - crate::ui::new_linked_worktree_inner_rect(self.screen_rect()) - { - let (create, cancel) = crate::ui::new_linked_worktree_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (create, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - self.request_submit_worktree_create = true; - } - Some(ModalAction::Cancel) - if !self - .worktree_create - .as_ref() - .is_some_and(|create| create.creating) => - { - self.worktree_create = None; - self.name_input.clear(); - self.name_input_replace_on_type = false; - leave_modal(self); - } - _ => {} - } - } - return None; - } - - if self.mode == Mode::OpenExistingWorktree { - if let Some(open) = self.worktree_open.as_ref() { - if let Some(inner) = crate::ui::open_existing_worktree_inner_rect( - self.screen_rect(), - open.entries.len(), - ) { - let filtered = open.filtered_indices(); - let max_rows = - crate::ui::open_existing_worktree_max_visible_rows(inner); - let start = - crate::ui::open_existing_worktree_visible_start(open, max_rows); - if mouse.row == inner.y.saturating_add(1) - && mouse.column >= inner.x - && mouse.column < inner.x.saturating_add(inner.width) - { - if let Some(open) = &mut self.worktree_open { - open.search_focused = true; - } - return None; - } - let row_idx = if rect_contains(inner, mouse.column, mouse.row) { - mouse - .row - .checked_sub(inner.y.saturating_add(3)) - .map(usize::from) - .map(|row| row / 2) - .filter(|row| *row < max_rows) - .and_then(|row| filtered.get(start + row).copied()) - } else { - None - }; - if let Some(entry_idx) = row_idx { - if let Some(open) = &mut self.worktree_open { - open.selected = entry_idx; - } - self.request_submit_worktree_open = true; - return None; - } - - let (open_button, cancel) = - crate::ui::open_existing_worktree_button_rects(inner); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (open_button, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - self.request_submit_worktree_open = true; - } - Some(ModalAction::Cancel) => { - self.worktree_open = None; - leave_modal(self); - } - _ => {} - } - } - } - return None; - } - - if self.mode == Mode::ConfirmRemoveWorktree { - if let Some(popup) = crate::ui::remove_worktree_popup_rect(self.screen_rect()) { - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let force_confirmation = self - .worktree_remove - .as_ref() - .is_some_and(|remove| remove.force_confirmation); - let (remove, cancel) = - crate::ui::remove_worktree_button_rects(inner, force_confirmation); - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (remove, ModalAction::Confirm), - (cancel, ModalAction::Cancel), - ], - ) { - Some(ModalAction::Confirm) => { - self.request_submit_worktree_remove = true; - } - Some(ModalAction::Cancel) - if !self - .worktree_remove - .as_ref() - .is_some_and(|remove| remove.removing) => - { - self.worktree_remove = None; - leave_modal(self); - } - _ => {} - } - } - return None; - } - - if matches!( - self.mode, - Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane - ) { - let action = self - .rename_modal_inner() - .map(crate::ui::rename_button_rects) - .and_then(|(save, clear, cancel)| { - modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (save, ModalAction::Save), - (clear, ModalAction::Clear), - (cancel, ModalAction::Cancel), - ], - ) - }) - .unwrap_or(ModalAction::Cancel); - return Some(MouseAction::RenameModal(action)); - } - - if self.mode == Mode::ContextMenu { - let item_idx = self.context_menu_item_at(mouse.column, mouse.row); - if let Some(menu) = self.context_menu.take() { - if let Some(idx) = item_idx { - return Some(MouseAction::ContextMenu { menu, idx }); - } else { - leave_modal(self); - } - } - return None; - } - - if self.on_sidebar_divider(mouse.column, mouse.row) { - self.drag = Some(DragState { - target: DragTarget::SidebarDivider, - }); - self.set_manual_sidebar_width(mouse.column); - return None; - } - - if self.on_sidebar_section_divider(mouse.column, mouse.row) { - self.drag = Some(DragState { - target: DragTarget::SidebarSectionDivider, - }); - self.set_sidebar_section_split(mouse.row); - return None; - } - - if !in_sidebar { - if let Some(border) = self.find_border_at(mouse.column, mouse.row) { - let grab_offset = match border.direction { - Direction::Horizontal => border.pos.saturating_sub(mouse.column), - Direction::Vertical => border.pos.saturating_sub(mouse.row), - }; - self.drag = Some(DragState { - target: DragTarget::PaneSplit { - path: border.path.clone(), - direction: border.direction, - area: border.area, - grab_offset, - }, - }); - return None; - } - - if let Some((pane_id, target)) = - self.scrollbar_target_at(terminal_runtimes, mouse.column, mouse.row) - { - self.focus_pane(pane_id); - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::PaneScrollbar { - pane_id, - grab_row_offset, - }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_pane_scroll_offset( - terminal_runtimes, - pane_id, - offset_from_bottom, - ); - } - } - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - return None; - } - } - - if self.mode_bar_covers_tab_row(mouse.column, mouse.row) { - return None; - } - - if self.on_tab_scroll_left_button(mouse.column, mouse.row) { - self.scroll_tabs_left(); - return None; - } - if self.on_tab_scroll_right_button(mouse.column, mouse.row) { - self.scroll_tabs_right(); - return None; - } - if let (Some(ws_idx), Some(tab_idx)) = - (self.active, self.tab_at(mouse.column, mouse.row)) - { - self.tab_presses.insert( - source_id, - TabPressState { - ws_idx, - tab_idx, - start_col: mouse.column, - start_row: mouse.row, - }, - ); - return None; - } - if self.on_new_tab_button(mouse.column, mouse.row) { - if self.prompt_new_tab_name { - open_new_tab_dialog(self); - } else { - self.request_new_tab = true; - self.mode = Mode::Terminal; - } - return None; - } - - if in_sidebar { - if self.on_sidebar_toggle(mouse.column, mouse.row) { - self.sidebar_collapsed = !self.sidebar_collapsed; - return None; - } - - if self.sidebar_collapsed { - if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusWorkspace { ws_idx: idx }); - } - - if let Some((ws_idx, _tab_idx, pane_id)) = - self.collapsed_agent_detail_target_at(mouse.row) - { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusPane { ws_idx, pane_id }); - } - return None; - } - - let new_button = self.sidebar_new_button_rect(); - let on_new_button = mouse.row >= new_button.y - && mouse.row < new_button.y + new_button.height - && mouse.column >= new_button.x - && mouse.column < new_button.x + new_button.width; - if on_new_button { - return Some(MouseAction::NewWorkspace); - } - - if let Some(target) = - self.workspace_list_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::WorkspaceListScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_workspace_list_offset_from_bottom(offset_from_bottom); - } - } - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - if let Some(card) = cards.iter().find(|card| { - let chevron = crate::ui::workspace_group_chevron_rect(card); - mouse.row == chevron.y && mouse.column == chevron.x && chevron.width > 0 - }) { - if let Some((key, collapsed)) = - crate::ui::workspace_parent_group_state(self, card.ws_idx) - { - if collapsed { - self.collapsed_space_keys.remove(&key); - } else { - self.collapsed_space_keys.insert(key); - } - self.mark_session_dirty(); - return None; - } - } - - if let Some(idx) = self.workspace_at_row(mouse.row) { - self.workspace_presses.insert( - source_id, - WorkspacePressState { - ws_idx: idx, - start_col: mouse.column, - start_row: mouse.row, - }, - ); - return None; - } - - if self.on_agent_panel_sort_toggle(mouse.column, mouse.row) { - self.agent_panel_sort = match self.agent_panel_sort { - AgentPanelSort::Spaces => AgentPanelSort::Priority, - AgentPanelSort::Priority => AgentPanelSort::Spaces, - }; - self.agent_panel_scroll = 0; - self.mark_session_dirty(); - return None; - } - - if let Some(target) = - self.agent_panel_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.drag = Some(DragState { - target: DragTarget::AgentPanelScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.set_agent_panel_offset_from_bottom(offset_from_bottom); - } - } - return None; - } - - if let Some((ws_idx, _tab_idx, pane_id)) = - self.agent_detail_target_at(mouse.row) - { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusPane { ws_idx, pane_id }); - } - } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - - if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { - self.selection = None; - self.selection_autoscroll = None; - return self.mouse_pane_focus_action(info.id); - } - - let (row, col) = ( - mouse.row - info.inner_rect.y, - mouse.column - info.inner_rect.x, - ); - self.selection = Some(Selection::anchor( - info.id, - row, - col, - self.pane_scroll_metrics(terminal_runtimes, info.id), - )); - return self.mouse_pane_focus_action(info.id); - } else if let Some(info) = self.view.pane_infos.iter().find(|p| { - mouse.column >= p.rect.x - && mouse.column < p.rect.x + p.rect.width - && mouse.row >= p.rect.y - && mouse.row < p.rect.y + p.rect.height - }) { - let id = info.id; - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } - return self.mouse_pane_focus_action(id); - } - } - - MouseEventKind::Drag(MouseButton::Left) => { - if self.selection.is_some() { - self.update_selection_drag(terminal_runtimes, mouse.column, mouse.row); - return None; - } - - if (self.drag.is_none() || self.chrome_drag_owned_by_other(source_id)) - && !self.chrome_press_pending(source_id) - { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { - self.selection = None; - self.selection_autoscroll = None; - return None; - } - } - } - - let workspace_drop_target = self.workspace_drop_target_at_row(mouse.row); - let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row); - if self.drag.is_none() { - if let Some(press) = self.workspace_presses.get(&source_id) { - let delta_col = mouse.column.abs_diff(press.start_col); - let delta_row = mouse.row.abs_diff(press.start_row); - let can_reorder = self.workspaces.get(press.ws_idx).is_some_and(|ws| { - ws.worktree_space() - .is_none_or(|space| !space.is_linked_worktree) - }); - if workspace_drop_target.is_some() - && can_reorder - && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD - { - self.drag = Some(DragState { - target: DragTarget::WorkspaceReorder { - source_id, - source_ws_idx: press.ws_idx, - drop_target: workspace_drop_target, - }, - }); - } - } else if let Some(press) = self.tab_presses.get(&source_id) { - let delta_col = mouse.column.abs_diff(press.start_col); - let delta_row = mouse.row.abs_diff(press.start_row); - // Require a real drop target before opening a reorder, - // so a report from off the tab bar cannot start a drag - // that has nowhere to land. - if tab_drop_index.is_some() - && delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD - { - self.drag = Some(DragState { - target: DragTarget::TabReorder { - source_id, - ws_idx: press.ws_idx, - source_tab_idx: press.tab_idx, - insert_idx: tab_drop_index, - }, - }); - } - } - } - - if let Some(DragState { - target: - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - drop_target, - .. - }, - }) = &mut self.drag - { - if *drag_source_id == source_id { - *drop_target = workspace_drop_target; - } - } else if let Some(DragState { - target: - DragTarget::TabReorder { - source_id: drag_source_id, - ws_idx, - insert_idx, - .. - }, - }) = &mut self.drag - { - if *drag_source_id == source_id && self.active == Some(*ws_idx) { - *insert_idx = tab_drop_index; - } - } else if let Some(drag) = &self.drag { - match &drag.target { - DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {} - DragTarget::WorkspaceListScrollbar { grab_row_offset } => { - if let Some(offset_from_bottom) = - self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.set_workspace_list_offset_from_bottom(offset_from_bottom); - } - } - DragTarget::AgentPanelScrollbar { grab_row_offset } => { - if let Some(offset_from_bottom) = - self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.set_agent_panel_offset_from_bottom(offset_from_bottom); - } - } - DragTarget::PaneSplit { - path, - direction, - area, - grab_offset, - } => { - let ratio = match direction { - Direction::Horizontal => { - (mouse - .column - .saturating_add(*grab_offset) - .saturating_sub(area.x)) - as f32 - / area.width.max(1) as f32 - } - Direction::Vertical => { - (mouse - .row - .saturating_add(*grab_offset) - .saturating_sub(area.y)) - as f32 - / area.height.max(1) as f32 - } - }; - let ratio = ratio.clamp(0.1, 0.9); - let path = path.clone(); - return Some(MouseAction::SetSplitRatio { path, ratio }); - } - DragTarget::PaneScrollbar { - pane_id, - grab_row_offset, - } => { - if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( - terminal_runtimes, - *pane_id, - mouse.row, - *grab_row_offset, - ) { - self.set_pane_scroll_offset( - terminal_runtimes, - *pane_id, - offset_from_bottom, - ); - } - } - DragTarget::SidebarDivider => { - self.set_manual_sidebar_width(mouse.column); - } - DragTarget::SidebarSectionDivider => { - self.set_sidebar_section_split(mouse.row); - } - DragTarget::ReleaseNotesScrollbar { .. } - | DragTarget::ProductAnnouncementScrollbar { .. } - | DragTarget::KeybindHelpScrollbar { .. } => {} - } - } - } - - MouseEventKind::Up(MouseButton::Left) => { - // Mouse-up either finishes a drag selection or releases after a - // double-click word selection; the latter is already finalized. - if let Some(selection) = self.selection.as_ref() { - let was_click = selection.was_just_click(); - let was_finalized = selection.is_finalized(); - - self.clear_chrome_press(source_id); - self.drag = None; - self.selection_autoscroll = None; - if was_click { - self.selection = None; - } else if was_finalized { - // Double-click already finalized this word selection. - } else if self.copy_on_select { - self.copy_selection(terminal_runtimes); - } else if let Some(selection) = self.selection.as_mut() { - selection.finish(); - } - return None; - } - - let foreign_chrome_drag = self.chrome_drag_owned_by_other(source_id); - if (self.drag.is_none() || foreign_chrome_drag) - && !self.chrome_press_pending(source_id) - { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { - self.selection = None; - self.selection_autoscroll = None; - return None; - } - } - } - - let workspace_press = self.workspace_presses.remove(&source_id); - let tab_press = self.tab_presses.remove(&source_id); - if foreign_chrome_drag { - return self.chrome_press_action(workspace_press, tab_press); - } - - match self.drag.take() { - Some(DragState { - target: - DragTarget::WorkspaceReorder { - source_ws_idx, - drop_target: Some(drop_target), - .. - }, - }) => { - if let Some(params) = - self.workspace_move_block_params(source_ws_idx, drop_target) - { - if self - .workspaces - .get(source_ws_idx) - .is_some_and(|workspace| workspace.worktree_space().is_some()) - { - return Some(MouseAction::MoveWorkspaceBlock { params }); - } - let insert_idx = params - .before_workspace_id - .as_ref() - .and_then(|id| { - self.workspaces - .iter() - .position(|workspace| workspace.id == *id) - }) - .unwrap_or(self.workspaces.len()); - return Some(MouseAction::MoveWorkspace { - source_ws_idx, - insert_idx, - }); - } - } - Some(DragState { - target: - DragTarget::TabReorder { - ws_idx, - source_tab_idx, - insert_idx: Some(insert_idx), - .. - }, - }) => { - if self.active == Some(ws_idx) { - self.mode = Mode::Terminal; - return Some(MouseAction::MoveTab { - ws_idx, - source_tab_idx, - insert_idx, - }); - } - } - Some(_) => {} - None => return self.chrome_press_action(workspace_press, tab_press), - } - } - - MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle) - if !in_sidebar => - { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - let _ = self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); - } - } - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if self.mode_bar_covers_tab_row(mouse.column, mouse.row) => {} - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if self.on_tab_bar(mouse.column, mouse.row) => - { - match mouse.kind { - MouseEventKind::ScrollUp => { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if !ws.tabs.is_empty() { - let prev = if ws.active_tab == 0 { - ws.tabs.len() - 1 - } else { - ws.active_tab - 1 - }; - return Some(MouseAction::FocusTab { tab_idx: prev }); - } - } - } - MouseEventKind::ScrollDown => { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if !ws.tabs.is_empty() { - let next = (ws.active_tab + 1) % ws.tabs.len(); - return Some(MouseAction::FocusTab { tab_idx: next }); - } - } - } - _ => {} - } - } - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - if !in_sidebar && self.scroll_selection_with_wheel(terminal_runtimes, mouse) => {} - - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { - self.selection = None; - self.selection_autoscroll = None; - self.handle_terminal_wheel(terminal_runtimes, mouse); - } - - MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight - if self.mode == Mode::Terminal && !in_sidebar => - { - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse); - } - } - - MouseEventKind::ScrollUp if in_sidebar => { - let agent_area = self.agent_panel_rect(); - let over_agent_panel = agent_area != Rect::default() - && mouse.row >= agent_area.y - && mouse.row < agent_area.y + agent_area.height; - if over_agent_panel { - if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( - self, agent_area, - )) { - self.scroll_agent_panel(-1); - } - } else if crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), - ) { - self.scroll_workspace_list(-1); - } else { - self.move_selected_workspace_by_visible_delta(-1); - } - } - MouseEventKind::ScrollDown if in_sidebar => { - let agent_area = self.agent_panel_rect(); - let over_agent_panel = agent_area != Rect::default() - && mouse.row >= agent_area.y - && mouse.row < agent_area.y + agent_area.height; - if over_agent_panel { - if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( - self, agent_area, - )) { - self.scroll_agent_panel(1); - } - } else if crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), - ) { - self.scroll_workspace_list(1); - } else { - self.move_selected_workspace_by_visible_delta(1); - } - } - - MouseEventKind::Moved if self.mode == Mode::ContextMenu => { - let hovered = self.context_menu_item_at(mouse.column, mouse.row); - if let Some(menu) = &mut self.context_menu { - menu.list.hover(hovered); - } - } - - MouseEventKind::Moved if self.mode == Mode::Terminal && !in_sidebar => { - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - let _ = self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse); - } - } - - MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => { - self.clear_chrome_press(source_id); - if self - .workspace_list_scrollbar_target_at(mouse.column, mouse.row) - .is_some() - { - return None; - } - if let Some(idx) = self.workspace_at_row(mouse.row) { - self.selected = idx; - let kind = self - .workspaces - .get(idx) - .and_then(|ws| { - let group_state = crate::ui::workspace_parent_group_state(self, idx); - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&self.terminals, terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - let is_linked_worktree = ws.worktree_space().map_or_else( - || { - git_space - .as_ref() - .is_some_and(|space| space.is_linked_worktree) - }, - |space| space.is_linked_worktree, - ); - let show_git_menu = ws.worktree_space().is_some() - || git_space - .as_ref() - .is_some_and(|space| !space.is_linked_worktree); - show_git_menu.then_some(ContextMenuKind::GitWorkspace { - ws_idx: idx, - is_linked_worktree, - has_worktree_children: group_state.is_some(), - collapsed: group_state - .as_ref() - .is_some_and(|(_, collapsed)| *collapsed), - }) - }) - .unwrap_or(ContextMenuKind::Workspace { ws_idx: idx }); - self.context_menu = Some(ContextMenuState { - kind, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - MouseEventKind::Down(MouseButton::Right) - if !self.mode_bar_covers_tab_row(mouse.column, mouse.row) - && self.tab_at(mouse.column, mouse.row).is_some() => - { - if let (Some(ws_idx), Some(tab_idx)) = - (self.active, self.tab_at(mouse.column, mouse.row)) - { - self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Tab { ws_idx, tab_idx }, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { - if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - let ws_idx = self.active?; - let tab_idx = self - .workspaces - .get(ws_idx) - .map(|ws| ws.active_tab_index())?; - let previous_focused_pane_id = self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.focused_pane_id()); - let source_pane_id = - previous_focused_pane_id.filter(|pane_id| *pane_id != info.id); - let pane_state = self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.pane_state(info.id)); - let has_manual_label = pane_state - .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| terminal.manual_label.as_ref()) - .is_some(); - let right_click_passthrough = - pane_state.is_some_and(|pane| pane.right_click_passthrough); - self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id: info.id, - source_pane_id, - has_manual_label, - right_click_passthrough, - }, - x: mouse.column, - y: mouse.row, - list: MenuListState::new(0), - }); - self.mode = Mode::ContextMenu; - } - } - - _ => {} - } - - None - } - - fn handle_mobile_mouse(&mut self, mouse: MouseEvent) -> MobileMouseResult { - if self.mode == Mode::Navigate { - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_mobile_switcher_at(mouse.column, mouse.row, -1); - return MobileMouseResult::Consumed; - } - MouseEventKind::ScrollDown => { - self.scroll_mobile_switcher_at(mouse.column, mouse.row, 1); - return MobileMouseResult::Consumed; - } - MouseEventKind::Down(MouseButton::Left) => {} - _ => return MobileMouseResult::Consumed, - } - } else if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return MobileMouseResult::Ignored; - } - - if self.mode != Mode::Navigate { - if !matches!(self.mode, Mode::Terminal | Mode::Resize) { - return MobileMouseResult::Ignored; - } - if rect_contains(self.view.mobile_menu_hit_area, mouse.column, mouse.row) { - self.mobile_switcher_scroll = 0; - self.mode = Mode::Navigate; - return MobileMouseResult::Consumed; - } - return MobileMouseResult::Ignored; - } - - let areas = crate::ui::mobile_switcher_areas(self); - if rect_contains(areas.close, mouse.column, mouse.row) { - self.mode = Mode::Terminal; - return MobileMouseResult::Consumed; - } - - match crate::ui::mobile_switcher_target_at(self, mouse.column, mouse.row) { - Some(crate::ui::MobileSwitcherTarget::NewWorkspace) => { - return MobileMouseResult::Action(MouseAction::NewWorkspace); - } - Some(crate::ui::MobileSwitcherTarget::Workspace(ws_idx)) => { - self.mode = Mode::Terminal; - return MobileMouseResult::Action(MouseAction::FocusWorkspace { ws_idx }); - } - Some(crate::ui::MobileSwitcherTarget::NewTab) => { - if self.prompt_new_tab_name { - open_new_tab_dialog(self); - } else { - self.request_new_tab = true; - self.mode = Mode::Terminal; - } - } - Some(crate::ui::MobileSwitcherTarget::Tab(tab_idx)) => { - self.mode = Mode::Terminal; - return MobileMouseResult::Action(MouseAction::FocusTab { tab_idx }); - } - Some(crate::ui::MobileSwitcherTarget::Agent { - ws_idx, - tab_idx: _, - pane_id, - }) => { - self.mode = Mode::Terminal; - return MobileMouseResult::Action(MouseAction::FocusPane { ws_idx, pane_id }); - } - Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => { - let actions = global_menu_actions(self); - if let Some(action) = actions.get(action_idx).copied() { - apply_global_menu_action(self, action); - } - } - None => {} - } - - MobileMouseResult::Consumed - } - - fn scroll_mobile_switcher_at(&mut self, _col: u16, _row: u16, delta: i16) { - let max_scroll = crate::ui::mobile_switcher_max_scroll(self); - apply_scroll( - &mut self.mobile_switcher_scroll, - delta.saturating_mul(2), - max_scroll, - ); - } - - pub(super) fn screen_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - let terminal = self.view.terminal_area; - let x = sidebar.x.min(terminal.x); - let y = sidebar.y.min(terminal.y); - let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width); - let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height); - Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) - } - - pub(crate) fn context_menu_rect(&self) -> Option<Rect> { - let menu = self.context_menu.as_ref()?; - let screen = self.screen_rect(); - let max_item_w = menu - .items() - .iter() - .map(|item| item.len() as u16) - .max() - .unwrap_or(0); - let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1)); - let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1)); - let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w)); - let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h)); - Some(Rect::new(x, y, menu_w, menu_h)) - } - - pub(crate) fn confirm_close_rect(&self) -> Rect { - crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default() - } - - fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> { - let menu_rect = self.context_menu_rect()?; - let inner_x = menu_rect.x + 1; - let inner_y = menu_rect.y + 1; - let inner_w = menu_rect.width.saturating_sub(2); - let inner_h = menu_rect.height.saturating_sub(2); - let item_count = self - .context_menu - .as_ref() - .map(|menu| menu.items().len() as u16) - .unwrap_or(0); - if col >= inner_x - && col < inner_x + inner_w - && row >= inner_y - && row < inner_y + inner_h.min(item_count) - { - Some((row - inner_y) as usize) - } else { - None - } - } - - pub(super) fn tab_at(&self, col: u16, row: u16) -> Option<usize> { - self.view - .tab_hit_areas - .iter() - .enumerate() - .find_map(|(idx, area)| { - (area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width) - .then_some(idx) - }) - } - - fn mode_bar_covers_tab_row(&self, col: u16, row: u16) -> bool { - self.tab_bar_position == crate::config::TabBarPositionConfig::Bottom - && matches!( - self.mode, - Mode::Navigate | Mode::Prefix | Mode::Copy | Mode::Resize - ) - && self.on_tab_bar(col, row) - } - - pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_bar_rect; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_scroll_left_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool { - let area = self.view.tab_scroll_right_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn tab_drop_index_at(&self, col: u16, row: u16) -> Option<usize> { - if !self.on_tab_bar(col, row) { - return None; - } - - let visible_tabs: Vec<_> = self - .view - .tab_hit_areas - .iter() - .enumerate() - .filter(|(_, rect)| rect.width > 0) - .collect(); - let (first_idx, first_rect) = *visible_tabs.first()?; - let (last_idx, last_rect) = *visible_tabs.last()?; - - if self.on_tab_scroll_left_button(col, row) { - return Some(0); - } - if self.on_tab_scroll_right_button(col, row) { - return self - .active - .and_then(|idx| self.workspaces.get(idx)) - .map(|ws| ws.tabs.len()); - } - - let left_edge = if first_idx == 0 { - first_rect.x - } else { - self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width - }; - let right_edge = if self - .active - .and_then(|idx| self.workspaces.get(idx)) - .is_some_and(|ws| last_idx + 1 >= ws.tabs.len()) - { - last_rect.x + last_rect.width - } else { - self.view.tab_scroll_right_hit_area.x.saturating_sub(1) - }; - - if col <= left_edge { - return Some(first_idx); - } - if col >= right_edge { - return Some(last_idx + 1); - } - - for (idx, rect) in visible_tabs { - let midpoint = rect.x + rect.width / 2; - if col < midpoint { - return Some(idx); - } - if col < rect.x + rect.width { - return Some(idx + 1); - } - } - - Some(last_idx + 1) - } - - pub(super) fn on_new_tab_button(&self, col: u16, row: u16) -> bool { - let area = self.view.new_tab_hit_area; - area.width > 0 - && row >= area.y - && row < area.y + area.height - && col >= area.x - && col < area.x + area.width - } - - pub(super) fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> { - self.view.split_borders.iter().find(|b| match b.direction { - Direction::Horizontal if self.pane_borders && !self.pane_gaps => { - col == b.pos && row >= b.area.y && row < b.area.y + b.area.height - } - Direction::Horizontal if self.pane_borders && self.pane_gaps => { - row >= b.area.y - && row < b.area.y + b.area.height - && col >= b.pos.saturating_sub(1) - && col <= b.pos - } - Direction::Horizontal if !self.pane_borders && self.pane_gaps => { - row >= b.area.y - && row < b.area.y + b.area.height - && b.pos.checked_sub(1).is_some_and(|gap_col| { - col == gap_col && self.pane_frame_at(col, row).is_none() - }) - } - Direction::Vertical if self.pane_borders && !self.pane_gaps => { - row == b.pos && col >= b.area.x && col < b.area.x + b.area.width - } - Direction::Vertical if self.pane_borders && self.pane_gaps => { - col >= b.area.x - && col < b.area.x + b.area.width - && row >= b.pos.saturating_sub(1) - && row <= b.pos - } - Direction::Vertical if !self.pane_borders && self.pane_gaps => { - col >= b.area.x - && col < b.area.x + b.area.width - && b.pos.checked_sub(1).is_some_and(|gap_row| { - row == gap_row && self.pane_frame_at(col, row).is_none() - }) - } - _ => false, - }) - } - - pub(super) fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|p| { - col >= p.inner_rect.x - && col < p.inner_rect.x + p.inner_rect.width - && row >= p.inner_rect.y - && row < p.inner_rect.y + p.inner_rect.height - }) - } - - pub(super) fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.pane_at(col, row) - .or_else(|| self.pane_frame_at(col, row)) - } - - fn chrome_press_pending(&self, source_id: crate::app::InputSourceId) -> bool { - self.tab_presses.contains_key(&source_id) || self.workspace_presses.contains_key(&source_id) - } - - fn chrome_drag_owned_by_other(&self, source_id: crate::app::InputSourceId) -> bool { - self.drag.as_ref().is_some_and(|drag| { - matches!( - drag.target, - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - .. - } | DragTarget::TabReorder { - source_id: drag_source_id, - .. - } if drag_source_id != source_id - ) - }) - } - - fn chrome_press_action( - &mut self, - workspace_press: Option<WorkspacePressState>, - tab_press: Option<TabPressState>, - ) -> Option<MouseAction> { - if let Some(press) = workspace_press { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusWorkspace { - ws_idx: press.ws_idx, - }); - } - if let Some(press) = tab_press { - if self.active == Some(press.ws_idx) { - self.mode = Mode::Terminal; - return Some(MouseAction::FocusTab { - tab_idx: press.tab_idx, - }); - } - } - None - } - - pub(crate) fn clear_chrome_gesture(&mut self, source_id: crate::app::InputSourceId) { - if self.drag.as_ref().is_some_and(|drag| { - matches!( - drag.target, - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - .. - } | DragTarget::TabReorder { - source_id: drag_source_id, - .. - } if drag_source_id == source_id - ) - }) { - self.drag = None; - } - self.clear_chrome_press(source_id); - } - - fn clear_chrome_press(&mut self, source_id: crate::app::InputSourceId) { - self.tab_presses.remove(&source_id); - self.workspace_presses.remove(&source_id); - } - - fn mouse_pane_focus_action(&self, pane_id: crate::layout::PaneId) -> Option<MouseAction> { - let ws_idx = self.active?; - (self - .workspaces - .get(ws_idx) - .and_then(|workspace| workspace.focused_pane_id()) - != Some(pane_id)) - .then_some(MouseAction::FocusPane { ws_idx, pane_id }) - } - - pub(crate) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|info| info.id == pane_id) - } - - pub(super) fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { - self.view.pane_infos.iter().find(|p| { - col >= p.rect.x - && col < p.rect.x + p.rect.width - && row >= p.rect.y - && row < p.rect.y + p.rect.height - }) - } - - pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) { - let _ = pane_id; - } - - fn clickable_toast_at(&self, col: u16, row: u16) -> bool { - self.toast - .as_ref() - .is_some_and(|toast| toast.target.is_some()) - && rect_contains(self.view.toast_hit_area, col, row) - } - - #[cfg(test)] - pub(crate) fn focus_toast_target(&mut self) { - let Some(target) = self.toast.as_ref().and_then(|toast| toast.target.clone()) else { - return; - }; - let Some(ws_idx) = self - .workspaces - .iter() - .position(|workspace| workspace.id == target.workspace_id) - else { - return; - }; - let Some(_tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else { - return; - }; - - self.focus_pane_in_workspace(ws_idx, target.pane_id); - self.toast = None; - self.settle_terminal_mode_after_focus(); - } - - pub(crate) fn scroll_pane_up( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - lines: usize, - ) { - if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - { - rt.scroll_up(lines); - } - } - } - - pub(crate) fn scroll_pane_down( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - lines: usize, - ) { - if let Some(ws_idx) = self.active { - if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - { - rt.scroll_down(lines); - } - } - } - - pub(crate) fn pane_scroll_metrics( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - ) -> Option<crate::pane::ScrollMetrics> { - self.active - .and_then(|i| self.runtime_for_pane_in_workspace(terminal_runtimes, i, pane_id)) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - } - - fn handle_right_click_passthrough( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - source_id: crate::app::InputSourceId, - mouse: MouseEvent, - in_sidebar: bool, - ) -> bool { - if let Some(gesture) = self.right_click_passthrough.clone() { - match mouse.kind { - MouseEventKind::Drag(MouseButton::Right) - | MouseEventKind::Up(MouseButton::Right) => { - let forwarded_mouse = - self.strip_right_click_passthrough_modifiers(mouse, gesture.modifiers); - let _ = self.forward_pane_mouse_button( - terminal_runtimes, - &gesture.pane_info, - forwarded_mouse, - ); - if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Right)) { - self.right_click_passthrough = None; - } - return true; - } - _ => { - self.right_click_passthrough = None; - } - } - } - - if self.mode != Mode::Terminal - || in_sidebar - || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) - { - return false; - } - - let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { - return false; - }; - let configured_modifiers = self - .right_click_passthrough_modifiers - .filter(|modifiers| mouse.modifiers == *modifiers); - let pane_passthrough = mouse.modifiers.is_empty() - && self.active.is_some_and(|ws_idx| { - self.workspaces - .get(ws_idx) - .and_then(|workspace| workspace.pane_state(info.id)) - .is_some_and(|pane| pane.right_click_passthrough) - }); - let Some(modifiers) = configured_modifiers - .or_else(|| pane_passthrough.then(crossterm::event::KeyModifiers::empty)) - else { - return false; - }; - - self.focus_pane(info.id); - let forwarded_mouse = self.strip_right_click_passthrough_modifiers(mouse, modifiers); - if !self.forward_pane_mouse_button(terminal_runtimes, &info, forwarded_mouse) { - return false; - } - - self.selection = None; - self.selection_autoscroll = None; - self.clear_chrome_press(source_id); - self.drag = None; - self.context_menu = None; - self.right_click_passthrough = Some(RightClickPassthroughGesture { - pane_info: info, - modifiers, - }); - true - } - - fn strip_right_click_passthrough_modifiers( - &self, - mouse: MouseEvent, - modifiers: crossterm::event::KeyModifiers, - ) -> MouseEvent { - MouseEvent { - modifiers: mouse.modifiers.difference(modifiers), - ..mouse - } - } - - pub(super) fn handle_terminal_wheel( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - mouse: MouseEvent, - ) { - let lines_per_notch = self.mouse_scroll_lines; - - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - if self.forward_pane_wheel(terminal_runtimes, &info, mouse) { - return; - } - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_pane_up(terminal_runtimes, info.id, lines_per_notch) - } - MouseEventKind::ScrollDown => { - self.scroll_pane_down(terminal_runtimes, info.id, lines_per_notch) - } - _ => {} - } - return; - } - - if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_pane_up(terminal_runtimes, info.id, lines_per_notch) - } - MouseEventKind::ScrollDown => { - self.scroll_pane_down(terminal_runtimes, info.id, lines_per_notch) - } - _ => {} - } - return; - } - - if let Some(ws_idx) = self.active { - if let Some(rt) = self.focused_runtime_in_workspace(terminal_runtimes, ws_idx) { - match mouse.kind { - MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch), - MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch), - _ => {} - } - } - } - } - - fn pane_mouse_position( - &self, - runtime: &crate::terminal::TerminalRuntime, - inner: Rect, - mouse: MouseEvent, - ) -> Option<crate::input::mouse::Position> { - let column = mouse.column.saturating_sub(inner.x); - let row = mouse.row.saturating_sub(inner.y); - let cell = crate::input::mouse::Position::Cell { column, row }; - let Some(host) = self.host_mouse_pixels else { - return Some(cell); - }; - let wants_pixels = runtime.sgr_pixel_mouse_enabled(); - if !wants_pixels { - return Some(cell); - } - let Some((width_px, height_px)) = runtime.pixel_size() else { - return Some(cell); - }; - Some( - host.pane_position(inner, width_px, height_px) - .unwrap_or(cell), - ) - } - - pub(super) fn forward_pane_mouse_button( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_button(mouse.kind, position, mouse.modifiers) else { - return false; - }; - rt.scroll_reset(); - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event"); - } - true - } - - pub(super) fn forward_pane_mouse_motion( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_motion(mouse.kind, position, mouse.modifiers) else { - return false; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse motion event"); - } - true - } - - fn forward_pane_reported_wheel( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - if rt.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) { - return false; - } - rt.scroll_reset(); - let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, position, mouse.modifiers) else { - warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); - return true; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); - } - true - } - - pub(super) fn forward_pane_wheel( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - info: &PaneInfo, - mouse: MouseEvent, - ) -> bool { - let Some(ws_idx) = self.active else { - return false; - }; - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - else { - return false; - }; - match rt.wheel_routing() { - Some(crate::pane::WheelRouting::HostScroll) | None => false, - Some(crate::pane::WheelRouting::MouseReport) => { - self.forward_pane_reported_wheel(terminal_runtimes, info, mouse) - } - Some(crate::pane::WheelRouting::AlternateScroll) => { - rt.scroll_reset(); - let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else { - return true; - }; - if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { - warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key"); - } - true - } - } - } - - pub(super) fn set_pane_scroll_offset( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - offset_from_bottom: usize, - ) { - for ws_idx in 0..self.workspaces.len() { - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - else { - continue; - }; - rt.set_scroll_offset_from_bottom(offset_from_bottom); - return; - } - } - - pub(super) fn scrollbar_target_at( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - col: u16, - row: u16, - ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { - let ws_idx = self.active?; - let info = self.view.pane_infos.iter().find(|info| { - crate::ui::pane_scrollbar_rect(info).is_some_and(|track| { - col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height - }) - })?; - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; - let metrics = rt.scroll_metrics()?; - if metrics.max_offset_from_bottom == 0 { - return None; - } - let track = crate::ui::pane_scrollbar_rect(info)?; - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset })) - } else { - Some(( - info.id, - ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }, - )) - } - } - - pub(super) fn scrollbar_offset_for_pane_row( - &self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - row: u16, - grab_row_offset: u16, - ) -> Option<usize> { - let ws_idx = self.active?; - let info = self - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id)?; - let track = crate::ui::pane_scrollbar_rect(info)?; - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; - let metrics = rt.scroll_metrics()?; - if metrics.max_offset_from_bottom == 0 { - return None; - } - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } -} - -#[cfg(test)] -pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting { - if input_state.mouse_protocol_mode.reporting_enabled() { - WheelRouting::MouseReport - } else if input_state.alternate_screen && input_state.mouse_alternate_scroll { - WheelRouting::AlternateScroll - } else { - WheelRouting::HostScroll - } -} - -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - rect.width > 0 - && rect.height > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height -} - -fn apply_scroll(scroll: &mut usize, delta: i16, max_scroll: usize) { - if delta.is_negative() { - *scroll = scroll.saturating_sub(delta.unsigned_abs() as usize); - } else { - *scroll = scroll.saturating_add(delta as usize).min(max_scroll); - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::{Direction, Rect}; - - use super::super::{ - app_for_mouse_test, capture_snapshot, mouse, numbered_lines_bytes, root_layout_ratio, - }; - use super::*; - use crate::app::input::modal::handle_context_menu_key; - use crate::{ - app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode, ViewLayout}, - detect::{Agent, AgentState}, - workspace::Workspace, - }; - - #[test] - fn tab_click_survives_stray_drag_report_off_the_tab_bar() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.active_tab = 1; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let press_col = first_tab.x + 1; - let stray_row = area.height - 1; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - first_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - #[test] - fn workspace_click_survives_stray_drag_report_off_the_workspace_list() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("first"), Workspace::test_new("second")]; - app.state.active = Some(1); - app.state.selected = 1; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let first_workspace = app.state.view.workspace_card_areas[0]; - let press_col = first_workspace.rect.x + 1; - let press_row = first_workspace.rect.y; - let stray_row = area.height - 1; - assert!(app.state.workspace_drop_target_at_row(stray_row).is_none()); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - press_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.active, Some(0)); - } - - #[test] - fn concurrent_input_sources_keep_their_tab_clicks() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.test_add_tab(None); - ws.active_tab = 2; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Up(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 0); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 1); - } - - #[test] - fn concurrent_input_sources_keep_their_workspace_clicks() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("first"), - Workspace::test_new("second"), - Workspace::test_new("third"), - ]; - app.state.active = Some(2); - app.state.selected = 2; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first = app.state.view.workspace_card_areas[0].rect; - let second = app.state.view.workspace_card_areas[1].rect; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first.x + 1, - first.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second.x + 1, - second.y, - ), - ); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), first.x + 1, first.y), - ); - assert_eq!(app.state.active, Some(0)); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second.x + 1, - second.y, - ), - ); - assert_eq!(app.state.active, Some(1)); - } - - #[test] - fn tab_click_completes_while_other_source_reorders() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.test_add_tab(None); - ws.active_tab = 2; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - let last_tab = app.state.view.tab_hit_areas[2]; - let drop_col = last_tab.x + last_tab.width; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Drag(MouseButton::Left), - drop_col, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::TabReorder { source_id: 41, .. }) - )); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, first_tab.y), - ); - assert!(app.state.drag.is_none()); - } - - #[test] - fn releasing_input_source_clears_only_its_pending_tab_click() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.clear_input_source(41); - - assert!(!app.state.tab_presses.contains_key(&41)); - assert!(app.state.tab_presses.contains_key(&42)); - } - - #[tokio::test] - async fn other_input_source_pane_gesture_is_not_swallowed_by_chrome_press() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let info = app.state.view.pane_infos[0].clone(); - let pane_id = info.id; - 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[?1000h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let pane_col = info.inner_rect.x; - let pane_row = info.inner_rect.y; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Down(MouseButton::Left), pane_col, pane_row), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Up(MouseButton::Left), pane_col, pane_row), - ); - - 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") - ); - } - - #[test] - fn other_input_source_cannot_release_tab_reorder() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("second")); - ws.test_add_tab(Some("third")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let source = app.state.view.tab_hit_areas[0]; - let target = app.state.view.tab_hit_areas[2]; - let drop_col = target.x + target.width; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - source.x + 1, - source.y, - ), - ); - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Drag(MouseButton::Left), drop_col, source.y), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, source.y), - ); - - assert!(app.state.drag.is_some()); - assert_eq!(app.state.workspaces[0].tabs[0].custom_name, None); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, source.y), - ); - - assert!(app.state.drag.is_none()); - assert_eq!(app.state.workspaces[0].tabs[2].custom_name.as_deref(), None); - } - - #[tokio::test] - async fn tab_click_survives_stray_drag_report_into_a_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.active_tab = 1; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let info = app - .state - .view - .pane_infos - .first() - .cloned() - .expect("visible pane"); - app.state.insert_test_runtime( - info.id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width.max(1), - info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let press_col = first_tab.x + 1; - let stray_row = info.inner_rect.bottom().saturating_sub(1); - assert!( - app.state.pane_mouse_target(press_col, stray_row).is_some(), - "stray coordinates must land on the pane for this to test anything" - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - first_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - fn mark_worktree_space_member(workspace: &mut Workspace, ws_idx: usize, key: &str) { - workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/worktree-{ws_idx}").into(), - is_linked_worktree: ws_idx != 0, - }); - } - - #[tokio::test] - async fn terminal_wheel_uses_configured_mouse_scroll_lines() { - let mut app = app_for_mouse_test(); - 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.tabs[0].runtimes.insert( - 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; - app.state.mouse_scroll_lines = 7; - - app.handle_mouse(mouse( - MouseEventKind::ScrollUp, - info.inner_rect.x + 1, - info.inner_rect.y + 1, - )); - - let 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 wheel"); - assert_eq!(metrics.offset_from_bottom, 7); - } - - #[tokio::test] - async fn mouse_dispatcher_forwards_horizontal_wheel_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 (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"\x1b[?1000h\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; - assert!( - app.state.mouse_capture, - "reproduction must use the default Herdr mouse dispatcher" - ); - - let outer_column = info.inner_rect.x + 2; - let outer_row = info.inner_rect.y + 3; - for (button, expected_kind, ingress) in [ - (66, MouseEventKind::ScrollLeft, "monolithic"), - (67, MouseEventKind::ScrollRight, "headless"), - ] { - let input = format!("\x1b[<{button};{};{}M", outer_column + 1, outer_row + 1); - let mut events = crate::raw_input::parse_raw_input_bytes_sync(input.as_bytes()); - let event = events - .pop() - .expect("horizontal SGR wheel input should parse"); - let crate::raw_input::RawInputEvent::Mouse(mouse) = &event else { - panic!("expected parsed mouse event"); - }; - assert!(events.is_empty(), "expected one parsed mouse event"); - assert_eq!(mouse.kind, expected_kind); - - if ingress == "monolithic" { - assert!(app.handle_raw_input_event(event).await); - } else { - app.route_client_events(vec![event], false); - } - - assert_eq!( - input_rx - .try_recv() - .expect("horizontal wheel should reach pane"), - Bytes::from(format!("\x1b[<{button};3;4M")) - ); - } - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn horizontal_wheel_stays_inert_for_non_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 (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 0, - b"", - 1, - ); - 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; - - let input = format!( - "\x1b[<66;{};{}M", - info.inner_rect.x + 3, - info.inner_rect.y + 4 - ); - let event = crate::raw_input::parse_raw_input_bytes_sync(input.as_bytes()) - .pop() - .expect("horizontal SGR wheel input should parse"); - - assert!(app.handle_raw_input_event(event).await); - - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn pane_right_click_passthrough_is_isolated() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let passthrough_pane = ws.tabs[0].root_pane; - let default_pane = ws.test_split(Direction::Horizontal); - ws.pane_state_mut(passthrough_pane) - .unwrap() - .right_click_passthrough = true; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let passthrough_info = app.state.pane_info_by_id(passthrough_pane).unwrap().clone(); - let default_info = app.state.pane_info_by_id(default_pane).unwrap().clone(); - let (passthrough_runtime, mut passthrough_input) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - passthrough_info.inner_rect.width, - passthrough_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - let (default_runtime, mut default_input) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - default_info.inner_rect.width, - default_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state - .insert_test_runtime(passthrough_pane, passthrough_runtime); - app.state.insert_test_runtime(default_pane, default_runtime); - - let col = passthrough_info.inner_rect.x + 2; - let row = passthrough_info.inner_rect.y + 3; - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Right), col, row)); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.context_menu.is_none()); - assert_eq!( - passthrough_input.try_recv().unwrap(), - Bytes::from_static(b"\x1b[<2;3;4M") - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - default_info.inner_rect.x + 2, - default_info.inner_rect.y + 3, - )); - - assert!(default_input.try_recv().is_err()); - assert!(matches!( - app.state.context_menu.as_ref().map(|menu| &menu.kind), - Some(ContextMenuKind::Pane { pane_id, .. }) if *pane_id == default_pane - )); - } - - #[tokio::test] - async fn pane_right_click_passthrough_falls_back_when_mouse_reporting_is_off() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.pane_state_mut(pane_id).unwrap().right_click_passthrough = true; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.pane_info_by_id(pane_id).unwrap().clone(); - app.state.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width, - info.inner_rect.height, - b"", - ), - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - info.inner_rect.x + 2, - info.inner_rect.y + 3, - )); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - } - - #[tokio::test] - async fn configured_right_click_passthrough_forwards_gesture_outside_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 (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.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); - - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y + 3; - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse(MouseEventKind::Down(MouseButton::Right), col, row) - }); - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse(MouseEventKind::Drag(MouseButton::Right), 0, 0) - }); - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse(MouseEventKind::Up(MouseButton::Right), 0, 0) - }); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.context_menu.is_none()); - assert!(app.state.right_click_passthrough.is_none()); - assert_eq!( - input_rx.try_recv().expect("forwarded right mouse down"), - Bytes::from_static(b"\x1b[<2;3;4M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded right mouse drag"), - Bytes::from_static(b"\x1b[<34;1;1M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded right mouse up"), - Bytes::from_static(b"\x1b[<2;1;1m") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn captured_left_press_focuses_target_before_forwarding() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let source = ws.tabs[0].root_pane; - let target = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(source); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app - .state - .pane_info_by_id(target) - .expect("target pane info") - .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, - ); - app.state.insert_test_runtime(target, runtime); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - info.inner_rect.x + 1, - info.inner_rect.y + 1, - )); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(target)); - assert_eq!( - input_rx.try_recv().expect("forwarded captured left press"), - Bytes::from_static(b"\x1b[<0;2;2M") - ); - } - - #[tokio::test] - async fn pane_mouse_only_forwards_moved_events_for_any_motion_apps() { - 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[?1003h\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.handle_pane_mouse_only( - &app.terminal_runtimes, - mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - ), - ); - - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn pane_mouse_motion_uses_computed_inner_rect_offsets() { - 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_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\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; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.view.pane_infos[0].clone(); - assert!(info.inner_rect.x > 0, "sidebar offset should be present"); - assert!(info.inner_rect.y > 0, "tab bar offset should be present"); - - app.state.handle_pane_mouse_only( - &app.terminal_runtimes, - mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - ), - ); - - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn ordinary_cell_mouse_downgrades_pixel_mode_to_cell_coordinates() { - 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_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", - 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.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.view.pane_infos[0].clone(); - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap() - .resize(info.inner_rect.height, info.inner_rect.width, 10, 20); - assert!(info.inner_rect.x > 0, "sidebar offset should be present"); - assert!(info.inner_rect.y > 0, "tab bar offset should be present"); - - app.handle_mouse(mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - )); - - assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), - Bytes::from_static(b"\x1b[<35;3;4M") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn dedicated_client_pixel_mouse_preserves_subcell_position() { - 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_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", - 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.mouse_capture = false; - app.state.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let inner = app.state.view.pane_infos[0].inner_rect; - let geometry = crate::input::mouse::HostGeometry::new(106, 20, 1_060, 400).unwrap(); - let x = u32::from(inner.x + 2) * 10 + 8; - let y = u32::from(inner.y + 3) * 20 + 9; - let report = format!("\x1b[<35;{x};{y}M"); - app.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap(); - assert_eq!(runtime.pixel_size(), None); - assert_eq!( - app.state.pane_mouse_position( - runtime, - inner, - mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 3), - ), - Some(crate::input::mouse::Position::Cell { column: 2, row: 3 }) - ); - runtime.resize(inner.height, inner.width, 10, 20); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap(); - assert_eq!( - runtime.pixel_size(), - Some((u32::from(inner.width) * 10, u32::from(inner.height) * 20)) - ); - assert_eq!( - app.state.pane_mouse_position( - runtime, - inner, - mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 3), - ), - Some(crate::input::mouse::Position::Pixels { x: 28, y: 69 }) - ); - app.state.host_mouse_pixels = None; - - assert!(app.route_client_pixel_mouse(7, report.as_bytes(), geometry)); - assert_eq!( - input_rx.try_recv().expect("forwarded exact mouse motion"), - Bytes::from_static(b"\x1b[<35;28;69M") - ); - assert!(input_rx.try_recv().is_err()); - assert!(app.state.host_mouse_pixels.is_none()); - } - - #[tokio::test] - async fn mouse_dispatcher_does_not_forward_motion_behind_herdr_modes() { - 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_and_scrollback_bytes( - 80, - 18, - 0, - b"\x1b[?1003h\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::Navigate; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.view.pane_infos[0].clone(); - - app.handle_mouse(mouse( - MouseEventKind::Moved, - info.inner_rect.x + 2, - info.inner_rect.y + 3, - )); - - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn unset_right_click_passthrough_keeps_modified_right_click_as_herdr_menu() { - 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.right_click_passthrough_modifiers = None; - - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse( - MouseEventKind::Down(MouseButton::Right), - info.inner_rect.x + 2, - info.inner_rect.y + 3, - ) - }); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - assert!(app.state.right_click_passthrough.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn pane_right_click_keeps_focus_and_swap_menu_swaps_with_focused_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let source = ws.tabs[0].root_pane; - let target = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(source); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); - let target_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == target) - .expect("target pane info") - .clone(); - let source_rect_before = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == source) - .expect("source pane info") - .rect; - let target_rect_before = target_info.rect; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - target_info.inner_rect.x, - target_info.inner_rect.y, - )); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - let menu = app.state.context_menu.as_mut().expect("pane context menu"); - assert!(matches!( - menu.kind, - ContextMenuKind::Pane { - pane_id, - source_pane_id: Some(source_pane_id), - .. - } if pane_id == target && source_pane_id == source - )); - let swap_idx = menu - .items() - .iter() - .position(|item| *item == "Swap with focused pane") - .expect("swap item"); - menu.list.highlighted = swap_idx; - - handle_context_menu_key( - &mut app.state, - &mut app.terminal_runtimes, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - assert_eq!( - app.state - .view - .pane_infos - .iter() - .find(|info| info.id == source) - .unwrap() - .rect, - target_rect_before - ); - assert_eq!( - app.state - .view - .pane_infos - .iter() - .find(|info| info.id == target) - .unwrap() - .rect, - source_rect_before - ); - } - - #[tokio::test] - async fn normal_right_click_keeps_focus_and_exposes_swap_for_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let source = ws.tabs[0].root_pane; - let target = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(source); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); - let target_info = app - .state - .pane_info_by_id(target) - .expect("target pane info") - .clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - target_info.inner_rect.width, - target_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(target, runtime); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - target_info.inner_rect.x, - target_info.inner_rect.y, - )); - - assert!(input_rx.try_recv().is_err()); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - let menu = app.state.context_menu.as_mut().expect("pane context menu"); - assert!(matches!( - menu.kind, - ContextMenuKind::Pane { - pane_id, - source_pane_id: Some(source_pane_id), - .. - } if pane_id == target && source_pane_id == source - )); - assert!(menu.items().contains(&"Swap with focused pane")); - } - - #[tokio::test] - async fn right_click_passthrough_requires_exact_modifier_match() { - 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.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); - - let col = info.inner_rect.x + 2; - let row = info.inner_rect.y + 3; - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ..mouse(MouseEventKind::Down(MouseButton::Right), col, row) - }); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - assert!(app.state.right_click_passthrough.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn right_click_passthrough_does_not_forward_pane_frame_clicks() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let other_pane = ws.test_split(Direction::Vertical); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("pane info") - .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, - ); - app.state.insert_test_runtime(pane_id, runtime); - app.state.insert_test_runtime( - other_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""), - ); - - assert!(app.state.pane_at(info.rect.x, info.rect.y).is_none()); - assert!(app - .state - .pane_mouse_target(info.rect.x, info.rect.y) - .is_some()); - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse( - MouseEventKind::Down(MouseButton::Right), - info.rect.x, - info.rect.y, - ) - }); - - assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); - assert!(app.state.right_click_passthrough.is_none()); - assert!(input_rx.try_recv().is_err()); - } - - fn sample_worktree_open_state() -> crate::app::state::WorktreeOpenState { - crate::app::state::WorktreeOpenState { - source_workspace_id: "source".into(), - source_existing_membership: None, - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![ - crate::app::state::WorktreeOpenEntry { - path: "/repo/herdr".into(), - branch: Some("main".into()), - is_linked_worktree: false, - already_open_ws_idx: Some(0), - }, - crate::app::state::WorktreeOpenEntry { - path: "/repo/herdr-issue".into(), - branch: Some("worktree/issue".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - ], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - } - } - - #[test] - fn hovering_context_menu_updates_highlight() { - let mut app = app_for_mouse_test(); - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 0 }, - x: 2, - y: 2, - list: MenuListState::new(0), - }); - app.state.mode = Mode::ContextMenu; - - let menu = app.state.context_menu_rect().unwrap(); - app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); - - assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1); - } - - #[test] - fn clicking_agent_toast_focuses_target_pane() { - let mut app = app_for_mouse_test(); - let active = Workspace::test_new("active"); - let mut background = Workspace::test_new("background"); - let first_pane = background.tabs[0].root_pane; - let target_pane = background.test_split(Direction::Horizontal); - background.tabs[0].layout.focus_pane(first_pane); - - app.state.workspaces = vec![active, background]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - app.state.toast_config.delay_seconds = 0; - let target_terminal_id = app.state.workspaces[1] - .panes - .get(&target_pane) - .unwrap() - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&target_terminal_id) - .unwrap() - .state = AgentState::Working; - - app.state - .handle_app_event(crate::events::AppEvent::StateChanged { - pane_id: target_pane, - agent: Some(Agent::Pi), - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: std::time::Instant::now(), - }); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let hit = app.state.view.toast_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - hit.x + 1, - hit.y + 1, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane)); - assert!(app.state.toast.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - - app.state.last_pane(); - - assert_eq!(app.state.active, Some(0)); - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(app.state.workspaces[0].tabs[0].root_pane) - ); - } - - #[test] - fn toast_click_does_not_steal_mouse_from_settings_overlay() { - let mut app = app_for_mouse_test(); - let active = Workspace::test_new("active"); - let background = Workspace::test_new("background"); - let target_pane = background.tabs[0].root_pane; - let workspace_id = background.id.clone(); - - app.state.workspaces = vec![active, background]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "pi finished".into(), - context: "background · 2".into(), - position: None, - target: Some(crate::app::state::ToastTarget { - workspace_id, - pane_id: target_pane, - }), - }); - app.state.mode = Mode::Settings; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let hit = app.state.view.toast_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - hit.x + 1, - hit.y + 1, - )); - - assert_eq!(app.state.active, Some(0)); - assert!(app.state.toast.is_some()); - } - - #[test] - fn clicking_confirm_close_accepts_workspace_close() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.begin_workspace_close_confirmation(1); - - let popup = app.state.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, _) = crate::ui::confirm_close_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - confirm.x, - confirm.y, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_rename_save_submits_workspace_rename_through_api_path() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("old")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameWorkspace; - app.state.name_input = "new".into(); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 24)); - let inner = app.state.rename_modal_inner().unwrap(); - let (save, _, _) = crate::ui::rename_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - save.x, - save.y, - )); - - assert_eq!(app.state.workspaces[0].custom_name.as_deref(), Some("new")); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceRenamed) - })); - } - - #[test] - fn clicking_open_worktree_row_selects_and_requests_open() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - let inner = - crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - inner.x + 1, - inner.y + 5, - )); - - assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); - assert!(app.state.request_submit_worktree_open); - } - - #[test] - fn clicking_open_worktree_buttons_requests_open_or_cancels() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - let inner = - crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); - let (open, _) = crate::ui::open_existing_worktree_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - open.x, - open.y, - )); - - assert!(app.state.worktree_open.is_some()); - assert!(app.state.request_submit_worktree_open); - - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - let inner = - crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); - let (_, cancel) = crate::ui::open_existing_worktree_button_rects(inner); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - cancel.x, - cancel.y, - )); - - assert!(app.state.worktree_open.is_none()); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn scrolling_open_worktree_picker_moves_selection() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(sample_worktree_open_state()); - - app.handle_mouse(mouse(MouseEventKind::ScrollDown, 1, 1)); - assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); - - app.handle_mouse(mouse(MouseEventKind::ScrollUp, 1, 1)); - assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 0); - } - - #[test] - fn clicking_remove_worktree_buttons_requests_remove_or_cancels() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::ConfirmRemoveWorktree; - app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { - workspace_id: "issue".into(), - repo_root: "/repo/herdr".into(), - path: "/repo/herdr-issue".into(), - error: None, - removing: false, - force_confirmation: false, - }); - let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (remove, _) = crate::ui::remove_worktree_button_rects(inner, false); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - remove.x, - remove.y, - )); - - assert!(app.state.worktree_remove.is_some()); - assert!(app.state.request_submit_worktree_remove); - - let mut app = app_for_mouse_test(); - app.state.mode = Mode::ConfirmRemoveWorktree; - app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { - workspace_id: "issue".into(), - repo_root: "/repo/herdr".into(), - path: "/repo/herdr-issue".into(), - error: None, - removing: false, - force_confirmation: false, - }); - let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (_, cancel) = crate::ui::remove_worktree_button_rects(inner, false); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - cancel.x, - cancel.y, - )); - - assert!(app.state.worktree_remove.is_none()); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn clicking_confirm_close_accepts_after_workspace_context_menu_close() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - handle_context_menu_key( - &mut app.state, - &mut app.terminal_runtimes, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.selected, 1); - - let popup = app.state.confirm_close_rect(); - let inner = Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - let (confirm, _) = crate::ui::confirm_close_button_rects(inner); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - confirm.x + 1, - confirm.y, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "a"); - } - - #[test] - fn clicking_context_menu_close_routes_through_api_path() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.confirm_close = false; - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - - let menu = app.state.context_menu_rect().unwrap(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 2, - )); - - assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "a"); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::WorkspaceClosed) - })); - } - - #[cfg(unix)] - #[tokio::test] - async fn keyboard_context_menu_split_keeps_new_runtime() { - let mut app = app_for_mouse_test(); - app.state.default_shell = "/usr/bin/true".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; - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let runtime_count = app.terminal_runtimes.len(); - app.state.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx: 0, - tab_idx: 0, - pane_id, - source_pane_id: None, - has_manual_label: false, - right_click_passthrough: false, - }, - x: 2, - y: 2, - list: MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - - handle_context_menu_key( - &mut app.state, - &mut app.terminal_runtimes, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert_eq!(app.terminal_runtimes.len(), runtime_count + 1); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - } - - #[test] - fn dragging_pane_split_updates_captured_layout_ratio() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let before = capture_snapshot(&app.state); - let drag_row = border.area.y.saturating_add(1); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - border.pos, - drag_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - border.pos.saturating_add(6), - drag_row, - )); - - let after = capture_snapshot(&app.state); - assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); - } - - #[test] - fn pane_split_hitbox_does_not_overlap_right_pane_content() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_gaps = false; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert!(app - .state - .find_border_at(border.pos.saturating_sub(1), row) - .is_none()); - assert!(app.state.find_border_at(border.pos, row).is_some()); - assert!(app - .state - .find_border_at(border.pos.saturating_add(1), row) - .is_none()); - } - - #[test] - fn pane_split_hitbox_does_not_overlap_bottom_pane_content() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_gaps = false; - app.state.workspaces[0].test_split(Direction::Vertical); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let col = border.area.x.saturating_add(1); - - assert!(app - .state - .find_border_at(col, border.pos.saturating_sub(1)) - .is_none()); - assert!(app.state.find_border_at(col, border.pos).is_some()); - assert!(app - .state - .find_border_at(col, border.pos.saturating_add(1)) - .is_none()); - } - - #[test] - fn borderless_no_gap_split_has_no_mouse_hitbox_over_content() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert!(app.state.find_border_at(border.pos, row).is_none()); - } - - #[test] - fn bordered_pane_gaps_keep_both_split_borders_draggable() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert!(app - .state - .find_border_at(border.pos.saturating_sub(1), row) - .is_some()); - assert!(app.state.find_border_at(border.pos, row).is_some()); - assert!(app - .state - .find_border_at(border.pos.saturating_add(1), row) - .is_none()); - } - - #[test] - fn borderless_pane_gap_is_not_a_pane_but_remains_split_draggable() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - let gap_col = border.pos.saturating_sub(1); - - assert!(app.state.pane_at(gap_col, row).is_none()); - assert!(app.state.find_border_at(gap_col, row).is_some()); - assert!(app.state.find_border_at(border.pos, row).is_none()); - } - - #[test] - fn borderless_gap_hitbox_is_empty_when_first_split_side_has_one_cell() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 2, 4)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - let candidate_gap_col = border.pos.saturating_sub(1); - - assert!(app.state.pane_frame_at(candidate_gap_col, row).is_some()); - assert!(app.state.find_border_at(candidate_gap_col, row).is_none()); - } - - #[test] - fn borderless_gap_hitbox_is_empty_when_first_split_side_has_zero_width() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.pane_borders = false; - app.state.pane_gaps = true; - app.state.workspaces[0].test_split(Direction::Horizontal); - app.state.workspaces[0].tabs[0] - .layout - .set_ratio_at(&[], 0.1); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 1, 4)); - let border = app.state.view.split_borders[0].clone(); - let row = border.area.y.saturating_add(1); - - assert_eq!(border.pos, 0); - assert!(app.state.find_border_at(0, row).is_none()); - } - - #[test] - fn selecting_from_right_pane_first_content_column_starts_selection() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let second_pane = ws.test_split(Direction::Horizontal); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let second_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - let col = second_info.inner_rect.x; - let row = second_info.inner_rect.y; - - assert!(app.state.find_border_at(col, row).is_none()); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - - assert!(app.state.drag.is_none()); - assert_eq!( - app.state - .selection - .as_ref() - .map(|selection| selection.pane_id), - Some(second_pane) - ); - } - - #[test] - fn selecting_from_bottom_pane_first_content_row_starts_selection() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let second_pane = ws.test_split(Direction::Vertical); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let second_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - let col = second_info.inner_rect.x; - let row = second_info.inner_rect.y; - - assert!(app.state.find_border_at(col, row).is_none()); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); - - assert!(app.state.drag.is_none()); - assert_eq!( - app.state - .selection - .as_ref() - .map(|selection| selection.pane_id), - Some(second_pane) - ); - } - - #[tokio::test] - async fn dragging_vertical_pane_split_still_resizes_when_pane_mouse_reporting_is_enabled() { - 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(Direction::Vertical); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let pane_infos = app.state.view.pane_infos.clone(); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info") - .clone(); - let second_info = pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - - app.state.insert_test_runtime( - first_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - first_info.inner_rect.width.max(1), - first_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - app.state.insert_test_runtime( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width.max(1), - second_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app - .state - .view - .split_borders - .iter() - .find(|border| border.direction == Direction::Vertical) - .expect("vertical split border") - .clone(); - let before = capture_snapshot(&app.state); - let drag_col = border.area.x.saturating_add(1); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - drag_col, - border.pos, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - drag_col, - border.pos.saturating_add(4), - )); - - let after = capture_snapshot(&app.state); - assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); - } - - #[tokio::test] - async fn dragging_horizontal_pane_split_still_resizes_when_pane_mouse_reporting_is_enabled() { - 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(Direction::Horizontal); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let pane_infos = app.state.view.pane_infos.clone(); - let first_info = pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info") - .clone(); - let second_info = pane_infos - .iter() - .find(|info| info.id == second_pane) - .expect("second pane info") - .clone(); - - app.state.insert_test_runtime( - first_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - first_info.inner_rect.width.max(1), - first_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - app.state.insert_test_runtime( - second_pane, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - second_info.inner_rect.width.max(1), - second_info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let border = app - .state - .view - .split_borders - .iter() - .find(|border| border.direction == Direction::Horizontal) - .expect("horizontal split border") - .clone(); - let before = capture_snapshot(&app.state); - let drag_row = border.area.y.saturating_add(1); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - border.pos, - drag_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - border.pos.saturating_add(6), - drag_row, - )); - - let after = capture_snapshot(&app.state); - assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); - } - - #[test] - fn wheel_routing_prefers_mouse_reporting() { - let input_state = crate::pane::InputState { - alternate_screen: true, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, - mouse_alternate_scroll: true, - modify_other_keys: false, - color_scheme_reporting: false, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport); - } - - #[test] - fn wheel_over_tab_bar_switches_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - ws.test_add_tab(Some("three")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let tab_bar = app.state.view.tab_bar_rect; - - app.handle_mouse(mouse(MouseEventKind::ScrollDown, tab_bar.x + 1, tab_bar.y)); - assert_eq!(app.state.workspaces[0].active_tab, 1); - - app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y)); - assert_eq!(app.state.workspaces[0].active_tab, 0); - - app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y)); - assert_eq!(app.state.workspaces[0].active_tab, 2); - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - tab_bar.x + tab_bar.width.saturating_sub(1), - tab_bar.y, - )); - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - #[test] - fn bottom_mode_bar_consumes_hidden_tab_mouse_actions() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Prefix; - app.state.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let second_tab = app.state.view.tab_hit_areas[1]; - let new_tab = app.state.view.new_tab_hit_area; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_tab.x, - new_tab.y, - )); - - app.state.drag = Some(DragState { - target: DragTarget::SidebarDivider, - }); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!(app.state.workspaces[0].tabs.len(), 2); - assert!(app.state.context_menu.is_none()); - assert!(app.state.tab_presses.is_empty()); - assert!(app.state.drag.is_none()); - } - - #[test] - fn right_click_inactive_tab_opens_menu_without_switching_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let second_tab = app.state.view.tab_hit_areas[1]; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_tab.x + 1, - second_tab.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - let menu = app.state.context_menu.as_ref().expect("tab context menu"); - assert_eq!( - menu.kind, - ContextMenuKind::Tab { - ws_idx: 0, - tab_idx: 1 - } - ); - assert_eq!(app.state.mode, Mode::ContextMenu); - } - - #[test] - fn clicking_tab_context_menu_close_leaves_context_menu_mode() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let second_tab = app.state.view.tab_hit_areas[1]; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_tab.x + 1, - second_tab.y, - )); - - let menu = app - .state - .context_menu_rect() - .expect("tab context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 3, - )); - - assert_eq!(app.state.workspaces[0].tabs.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "one"); - assert!(app.state.context_menu.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app - .event_hub - .events_after(0) - .iter() - .any(|(_, event)| { matches!(event.event, crate::api::schema::EventKind::TabClosed) })); - } - - #[test] - fn clicking_pane_context_menu_close_leaves_context_menu_mode() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(second_pane); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let first_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info") - .clone(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - first_info.inner_rect.x + 1, - first_info.inner_rect.y + 1, - )); - - let menu_state = app.state.context_menu.as_ref().expect("pane context menu"); - let close_idx = menu_state - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane menu item"); - let menu = app - .state - .context_menu_rect() - .expect("pane context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1 + close_idx as u16, - )); - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(app.state.context_menu.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::PaneClosed) - })); - } - - #[test] - fn clicking_pane_context_menu_close_last_parent_group_pane_keeps_confirmation_mode() { - let mut app = app_for_mouse_test(); - let mut parent = Workspace::test_new("main"); - let pane_id = parent.tabs[0].root_pane; - mark_worktree_space_member(&mut parent, 0, "repo-key"); - let mut child = Workspace::test_new("issue"); - mark_worktree_space_member(&mut child, 1, "repo-key"); - app.state.workspaces = vec![parent, child]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let pane_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("pane info") - .clone(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - pane_info.inner_rect.x + 1, - pane_info.inner_rect.y + 1, - )); - - let menu_state = app.state.context_menu.as_ref().expect("pane context menu"); - let close_idx = menu_state - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane menu item"); - let menu = app - .state - .context_menu_rect() - .expect("pane context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1 + close_idx as u16, - )); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - assert!(app.state.context_menu.is_none()); - } - - #[test] - fn wheel_over_overflowing_tab_bar_switches_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.tabs[0].set_custom_name("very-long-one".into()); - ws.test_add_tab(Some("very-long-two")); - ws.test_add_tab(Some("very-long-three")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); - assert!(app.state.view.tab_scroll_right_hit_area.width > 0); - let tab_bar = app.state.view.tab_bar_rect; - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - tab_bar.x + tab_bar.width.saturating_sub(2), - tab_bar.y, - )); - assert_eq!(app.state.workspaces[0].active_tab, 1); - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - tab_bar.x + tab_bar.width.saturating_sub(2), - tab_bar.y, - )); - assert_eq!(app.state.workspaces[0].active_tab, 2); - } - - #[test] - fn wheel_outside_tab_bar_does_not_switch_tabs() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let terminal = app.state.view.terminal_area; - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - terminal.x + 1, - terminal.y + 1, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - #[test] - fn mobile_switch_button_opens_switcher_and_workspace_row_switches_workspace() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - assert_eq!(app.state.view.layout, ViewLayout::Mobile); - - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - - assert_eq!(app.state.mode, Mode::Navigate); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 4, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn mobile_workspace_panel_scroll_reaches_extra_workspaces() { - let mut app = app_for_mouse_test(); - app.state.workspaces = (0..12) - .map(|idx| Workspace::test_new(&format!("ws-{idx}"))) - .collect(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - assert_eq!(app.state.mode, Mode::Navigate); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - viewport.x + 2, - viewport.y, - )); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - assert_eq!(app.state.mobile_switcher_scroll, 2); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 2, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn mobile_global_scroll_reaches_tabs_and_switches_tab() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("two")); - ws.test_add_tab(Some("three")); - ws.test_add_tab(Some("four")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 12)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - viewport.x + 2, - viewport.y, - )); - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - viewport.x + 2, - viewport.y, - )); - assert_eq!(app.state.mobile_switcher_scroll, 4); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 4, - )); - assert_eq!(app.state.workspaces[0].active_tab, 2); - } - - #[test] - fn mobile_switcher_new_workspace_opens_prompt_when_enabled() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_workspace_name = true; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 1, - )); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.workspaces.len(), 1); - } - - #[test] - fn desktop_new_workspace_opens_prompt_when_enabled() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_workspace_name = true; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); - let new_workspace = app.state.sidebar_new_button_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_workspace.x + 1, - new_workspace.y, - )); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.workspaces.len(), 1); - } - - #[tokio::test] - async fn desktop_new_workspace_creates_immediately_by_default() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); - let new_workspace = app.state.sidebar_new_button_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_workspace.x + 1, - new_workspace.y, - )); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.pending_workspace_create_cwd.is_none()); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - } - - #[test] - fn mobile_switcher_new_tab_opens_dialog_when_enabled() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("logs")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 5, - )); - - assert_eq!(app.state.mode, Mode::RenameTab); - assert!(app.state.creating_new_tab); - } - - #[test] - fn mobile_switcher_new_tab_skips_dialog_when_prompt_disabled() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - ws.test_add_tab(Some("logs")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_tab_name = false; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 5, - )); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!app.state.creating_new_tab); - assert!(app.state.request_new_tab); - assert!(app.state.requested_new_tab_name.is_none()); - } - - #[test] - fn desktop_new_tab_button_skips_dialog_when_prompt_disabled() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prompt_new_tab_name = false; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); - let new_tab_area = app.state.view.new_tab_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_tab_area.x + 1, - new_tab_area.y, - )); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!app.state.creating_new_tab); - assert!(app.state.request_new_tab); - assert!(app.state.requested_new_tab_name.is_none()); - } - - #[test] - fn mobile_switcher_swallows_non_left_mouse_events() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - assert_eq!(app.state.mode, Mode::Navigate); - - let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - viewport.x + 2, - viewport.y + 2, - )); - - assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.state.context_menu.is_none()); - } - - #[test] - fn mobile_switch_button_does_not_bypass_rename_modal() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::RenameTab; - app.state.creating_new_tab = true; - app.state.name_input = "new tab".into(); - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!app.state.creating_new_tab); - assert!(!app.state.request_new_tab); - } - - #[test] - fn mobile_switcher_close_returns_to_terminal() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); - let switch = app.state.view.mobile_menu_hit_area; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - switch.x + 1, - switch.y + 1, - )); - assert_eq!(app.state.mode, Mode::Navigate); - - let close = crate::ui::mobile_switcher_areas(&app.state).close; - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - close.x + 1, - close.y, - )); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() { - let input_state = crate::pane::InputState { - alternate_screen: true, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::None, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, - mouse_alternate_scroll: true, - modify_other_keys: false, - color_scheme_reporting: false, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll); - } - - #[test] - fn wheel_routing_falls_back_to_host_scrollback() { - let input_state = crate::pane::InputState { - alternate_screen: false, - application_cursor: false, - bracketed_paste: false, - focus_reporting: false, - mouse_protocol_mode: crate::input::MouseProtocolMode::None, - mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, - mouse_alternate_scroll: true, - modify_other_keys: false, - color_scheme_reporting: false, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); - } -} diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs deleted file mode 100644 index 517e267d..00000000 --- a/src/app/input/navigate.rs +++ /dev/null @@ -1,3898 +0,0 @@ -use std::{ - fs, io, - io::Write, - process::Stdio, - time::{SystemTime, UNIX_EPOCH}, -}; - -use bytes::Bytes; -use crossterm::event::KeyCode; -#[cfg(test)] -use crossterm::event::KeyEvent; -use ratatui::layout::Direction; - -use crate::{ - app::{ - state::{AppState, Mode}, - App, - }, - input::TerminalKey, - layout::NavDirection, - terminal::TerminalRuntimeRegistry, -}; - -#[cfg(test)] -pub(crate) fn terminal_direct_navigation_action( - state: &AppState, - key: TerminalKey, -) -> Option<NavigateAction> { - action_for_key(state, key, BindingDispatch::Direct) -} - -pub(crate) fn terminal_direct_non_indexed_navigation_action( - state: &AppState, - key: &TerminalKey, -) -> Option<NavigateAction> { - non_indexed_action_for_key(state, key, BindingDispatch::Direct) -} - -pub(crate) fn terminal_direct_indexed_navigation_action( - state: &AppState, - key: &TerminalKey, -) -> Option<NavigateAction> { - indexed_navigation_action(state, key, BindingDispatch::Direct) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ActionContext { - Direct, - Prefix, - Navigate, -} - -impl App { - fn cancel_copy_mode_if_active(&mut self) { - if self.state.copy_mode.is_some() { - self.state.cancel_copy_mode(&self.terminal_runtimes); - } - } - - pub(crate) fn handle_prefix_key(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - self.state.update_dismissed = true; - - if matches!(key.code, KeyCode::Modifier(_)) { - return; - } - - if self.state.is_prefix_key(&raw_key) { - if self.state.copy_mode_pane_is_focused() { - self.state.cancel_copy_mode(&self.terminal_runtimes); - } - if !self.pass_through_key_to_focused_pane(raw_key) { - leave_command_mode(&mut self.state); - } - return; - } - - if key.code == KeyCode::Esc { - leave_command_mode(&mut self.state); - return; - } - - match prefix_binding_for_key(&self.state, &raw_key) { - Some(PrefixBindingMatch::Action(action)) => self.execute_prefix_key_action(action), - Some(PrefixBindingMatch::Command(binding)) => { - self.cancel_copy_mode_if_active(); - self.launch_custom_command(binding, ActionContext::Prefix); - } - None => leave_command_mode(&mut self.state), - } - } - - fn execute_prefix_key_action(&mut self, action: NavigateAction) { - if action == NavigateAction::EditScrollback { - let previous_mode = self.state.mode; - self.cancel_copy_mode_if_active(); - self.launch_focused_scrollback_editor(); - finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); - } else if action == NavigateAction::CopyMode { - self.cancel_copy_mode_if_active(); - self.execute_tui_navigate_action(action, ActionContext::Prefix); - } else if copy_mode_survives_prefix_action(action) { - self.execute_tui_navigate_action(action, ActionContext::Prefix); - if self.state.copy_mode.is_some() { - self.state.sync_copy_mode_with_focus(); - } - } else { - self.cancel_copy_mode_if_active(); - self.execute_tui_navigate_action(action, ActionContext::Prefix); - } - self.selection_autoscroll_deadline = None; - } - - pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); - self.state.update_dismissed = true; - - if key.code == KeyCode::Esc || self.state.is_prefix_key(&raw_key) { - leave_navigate_mode(&mut self.state); - return; - } - - if self - .state - .keybinds - .navigate - .workspace_up - .matches_direct_key(&raw_key) - { - self.state.move_selected_workspace_by_visible_delta(-1); - return; - } - if self - .state - .keybinds - .navigate - .workspace_down - .matches_direct_key(&raw_key) - { - self.state.move_selected_workspace_by_visible_delta(1); - return; - } - - if let Some(action) = navigate_reserved_action_for_key(&self.state, &raw_key) { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - return; - } - - if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, &raw_key) { - if action == NavigateAction::EditScrollback { - self.launch_focused_scrollback_editor(); - } else { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - } - self.selection_autoscroll_deadline = None; - return; - } - - if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { - self.launch_custom_command(binding, ActionContext::Navigate); - return; - } - - if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, &raw_key) { - self.execute_tui_navigate_action(action, ActionContext::Navigate); - self.selection_autoscroll_deadline = None; - } - } - - pub(super) fn execute_tui_navigate_action( - &mut self, - action: NavigateAction, - context: ActionContext, - ) { - let previous_mode = self.state.mode; - match action { - NavigateAction::NewWorkspace => { - self.begin_tui_workspace_create("tui.key.workspace.create"); - } - NavigateAction::NewWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { - workspace_can_start_worktree_action(&self.state, &self.terminal_runtimes, *idx) - }) { - self.state.request_new_linked_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::OpenWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { - workspace_can_start_worktree_action(&self.state, &self.terminal_runtimes, *idx) - }) { - self.state.request_open_existing_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RemoveWorktree => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - self.state.request_remove_linked_worktree = Some(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RenameWorkspace => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - super::modal::open_rename_workspace( - &mut self.state, - &self.terminal_runtimes, - ws_idx, - ); - } - } - NavigateAction::CloseWorkspace => { - if let Some(ws_idx) = workspace_action_target(&self.state, context) { - self.state.selected = ws_idx; - if self.state.confirm_close { - super::modal::open_confirm_close(&mut self.state); - } else { - self.close_workspace_idx_with_group_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - } - NavigateAction::SwitchWorkspace(idx) => { - if let Some(ws_idx) = self.state.workspace_at_visible_position(idx) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::SwitchTab(idx) => { - if self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx)) - .is_some_and(|ws| idx < ws.tabs.len()) - { - self.focus_tab_idx_via_api(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::FocusAgent(idx) => { - if let Some((ws_idx, pane_id)) = self.agent_entry_target(idx) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::WorkspacePicker => { - self.state.mobile_switcher_scroll = 0; - self.state.mode = Mode::Navigate; - } - NavigateAction::PreviousWorkspace => { - if let Some(ws_idx) = self.relative_visible_workspace(-1) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextWorkspace => { - if let Some(ws_idx) = self.relative_visible_workspace(1) { - self.focus_workspace_idx_via_api(ws_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::PreviousAgent => { - if let Some((idx, ws_idx, pane_id)) = self.relative_agent_entry(false) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextAgent => { - if let Some((idx, ws_idx, pane_id)) = self.relative_agent_entry(true) { - self.focus_pane_internal_via_api(ws_idx, pane_id); - self.state.ensure_agent_panel_entry_visible(idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NewTab => { - if self.state.active.is_some() { - if self.state.prompt_new_tab_name { - super::modal::open_new_tab_dialog(&mut self.state); - } else { - self.runtime_tab_create( - "tui.key.tab.create", - crate::api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - leave_navigate_mode(&mut self.state); - } - } - } - NavigateAction::RenameTab => { - super::modal::open_rename_active_tab(&mut self.state, false) - } - NavigateAction::PreviousTab => { - if let Some(tab_idx) = self.relative_tab(-1) { - self.focus_tab_idx_via_api(tab_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::NextTab => { - if let Some(tab_idx) = self.relative_tab(1) { - self.focus_tab_idx_via_api(tab_idx); - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::MoveTabPrevious => { - if let Some((ws_idx, source, insert)) = self.active_tab_move(-1) { - self.move_tab_via_api(ws_idx, source, insert); - } - leave_navigate_mode(&mut self.state); - } - NavigateAction::MoveTabNext => { - if let Some((ws_idx, source, insert)) = self.active_tab_move(1) { - self.move_tab_via_api(ws_idx, source, insert); - } - leave_navigate_mode(&mut self.state); - } - NavigateAction::CloseTab => { - if !self.close_active_tab_via_api_requires_confirmation() { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::RenamePane => { - if let Some(pane_id) = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx)) - .and_then(|ws| ws.focused_pane_id()) - { - super::modal::open_rename_pane(&mut self.state, pane_id); - } - } - NavigateAction::FocusPaneLeft => { - self.focus_pane_direction_in_context(NavDirection::Left, context) - } - NavigateAction::FocusPaneDown => { - self.focus_pane_direction_in_context(NavDirection::Down, context) - } - NavigateAction::FocusPaneUp => { - self.focus_pane_direction_in_context(NavDirection::Up, context) - } - NavigateAction::FocusPaneRight => { - self.focus_pane_direction_in_context(NavDirection::Right, context) - } - NavigateAction::SwapPaneLeft => { - self.swap_pane_direction_via_api(NavDirection::Left); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneDown => { - self.swap_pane_direction_via_api(NavDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneUp => { - self.swap_pane_direction_via_api(NavDirection::Up); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SwapPaneRight => { - self.swap_pane_direction_via_api(NavDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SplitVertical => { - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::SplitHorizontal => { - self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ClosePane => { - if !self.close_focused_pane_via_api_requires_confirmation() { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::EditScrollback => {} - NavigateAction::CopyMode => self.state.enter_copy_mode(&self.terminal_runtimes), - NavigateAction::Zoom => { - self.zoom_focused_pane_via_api(); - leave_navigate_mode(&mut self.state); - } - NavigateAction::EnterResizeMode => self.state.mode = Mode::Resize, - NavigateAction::ResizePaneLeft => { - self.resize_pane_direction_via_api(NavDirection::Left); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneDown => { - self.resize_pane_direction_via_api(NavDirection::Down); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneUp => { - self.resize_pane_direction_via_api(NavDirection::Up); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ResizePaneRight => { - self.resize_pane_direction_via_api(NavDirection::Right); - leave_navigate_mode(&mut self.state); - } - NavigateAction::ToggleSidebar => { - self.state.sidebar_collapsed = !self.state.sidebar_collapsed; - leave_navigate_mode(&mut self.state); - } - NavigateAction::CyclePaneNext => { - self.cycle_pane_via_api(false); - leave_navigate_mode(&mut self.state); - } - NavigateAction::CyclePanePrevious => { - self.cycle_pane_via_api(true); - leave_navigate_mode(&mut self.state); - } - NavigateAction::LastPane => { - self.last_pane_via_api(); - leave_navigate_mode(&mut self.state); - } - NavigateAction::Help => super::modal::open_keybind_help(&mut self.state), - NavigateAction::Settings => super::settings::open_settings(&mut self.state), - NavigateAction::ReloadConfig => { - self.runtime_server_reload_config("tui.server.reload_config"); - leave_navigate_mode(&mut self.state); - } - NavigateAction::OpenNotificationTarget => { - self.focus_toast_target_via_api(); - if self.state.mode == Mode::Navigate { - leave_navigate_mode(&mut self.state); - } - } - NavigateAction::Detach => { - super::modal::request_detach(&mut self.state); - leave_navigate_mode(&mut self.state); - } - NavigateAction::OpenNavigator => { - self.state.open_navigator_from(&self.terminal_runtimes) - } - } - - finish_action_context(&mut self.state, context, previous_mode); - } - - pub(crate) fn focus_workspace_idx_via_api(&mut self, ws_idx: usize) { - let workspace_id = self.public_workspace_id(ws_idx); - self.runtime_workspace_focus("tui.workspace.focus", workspace_id); - } - - pub(crate) fn close_workspace_idx_with_group_via_api(&mut self, ws_idx: usize) { - let workspace_id = self.public_workspace_id(ws_idx); - self.runtime_workspace_close_group("tui.workspace.close", workspace_id); - } - - pub(crate) fn move_workspace_via_api(&mut self, source_ws_idx: usize, insert_idx: usize) { - let workspace_id = self.public_workspace_id(source_ws_idx); - self.runtime_workspace_move( - "tui.workspace.move", - crate::api::schema::WorkspaceMoveParams { - workspace_id, - insert_index: insert_idx, - }, - ); - } - - pub(crate) fn move_workspace_block_via_api( - &mut self, - params: crate::api::schema::WorkspaceMoveBlockParams, - ) { - self.runtime_workspace_move_block("tui.workspace.move_block", params); - } - - pub(crate) fn focus_tab_idx_via_api(&mut self, tab_idx: usize) { - let Some(ws_idx) = self.state.active else { - return; - }; - let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { - return; - }; - self.runtime_tab_focus("tui.tab.focus", tab_id); - } - - pub(crate) fn close_active_tab_via_api_requires_confirmation(&mut self) -> bool { - let Some(ws_idx) = self.state.active else { - return false; - }; - if self - .state - .workspaces - .get(ws_idx) - .is_some_and(|ws| ws.tabs.len() <= 1) - { - if self.state.confirm_implicit_worktree_group_close(ws_idx) { - return true; - } - self.close_workspace_idx_with_group_via_api(ws_idx); - return false; - } - let tab_idx = self.state.workspaces[ws_idx].active_tab_index(); - let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { - return false; - }; - self.runtime_tab_close("tui.tab.close", tab_id); - false - } - - pub(crate) fn move_tab_via_api( - &mut self, - ws_idx: usize, - source_tab_idx: usize, - insert_idx: usize, - ) { - let Some(tab_id) = self.public_tab_id(ws_idx, source_tab_idx) else { - return; - }; - self.runtime_tab_move( - "tui.tab.move", - crate::api::schema::TabMoveParams { - tab_id, - insert_index: insert_idx, - }, - ); - } - - pub(crate) fn focus_pane_internal_via_api( - &mut self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - ) { - let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { - return; - }; - self.runtime_pane_focus("tui.pane.focus", pane_id); - } - - pub(crate) fn focus_pane_direction_via_api(&mut self, direction: NavDirection) { - if let Some((ws_idx, target)) = self.directional_pane_target_from_view(direction) { - self.focus_pane_internal_via_api(ws_idx, target); - return; - } - self.runtime_pane_focus_direction( - "tui.pane.focus_direction", - crate::api::schema::PaneFocusDirectionParams { - pane_id: None, - direction: api_pane_direction(direction), - }, - ); - } - - fn focus_pane_direction_in_context(&mut self, direction: NavDirection, context: ActionContext) { - let preserve_navigate_mode = - context == ActionContext::Navigate && self.state.mode == Mode::Navigate; - self.focus_pane_direction_via_api(direction); - if preserve_navigate_mode { - self.state.mode = Mode::Navigate; - } - } - - pub(crate) fn resize_pane_direction_via_api(&mut self, direction: NavDirection) { - self.runtime_pane_resize( - "tui.pane.resize", - crate::api::schema::PaneResizeParams { - pane_id: None, - direction: api_pane_direction(direction), - amount: None, - }, - ); - } - - pub(crate) fn swap_pane_direction_via_api(&mut self, direction: NavDirection) { - if let Some((ws_idx, source, target)) = self.directional_pane_swap_from_view(direction) { - let source_pane_id = self.public_pane_id(ws_idx, source); - let target_pane_id = self.public_pane_id(ws_idx, target); - if let (Some(source_pane_id), Some(target_pane_id)) = (source_pane_id, target_pane_id) { - self.runtime_pane_swap( - "tui.pane.swap_exact", - crate::api::schema::PaneSwapParams { - pane_id: None, - direction: None, - source_pane_id: Some(source_pane_id), - target_pane_id: Some(target_pane_id), - }, - ); - return; - } - } - self.runtime_pane_swap( - "tui.pane.swap", - crate::api::schema::PaneSwapParams { - pane_id: None, - direction: Some(api_pane_direction(direction)), - source_pane_id: None, - target_pane_id: None, - }, - ); - } - - pub(crate) fn split_focused_pane_via_api( - &mut self, - direction: crate::api::schema::SplitDirection, - ) { - self.runtime_pane_split( - "tui.pane.split", - crate::api::schema::PaneSplitParams { - workspace_id: None, - target_pane_id: None, - direction, - ratio: None, - cwd: None, - focus: true, - right_click: Default::default(), - env: Default::default(), - }, - ); - } - - pub(crate) fn close_focused_pane_via_api_requires_confirmation(&mut self) -> bool { - let Some((ws_idx, pane_id)) = self.focused_pane_target() else { - return false; - }; - let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { - return false; - }; - self.runtime_pane_close("tui.pane.close", pane_id); - self.state.mode == Mode::ConfirmClose - } - - pub(crate) fn zoom_focused_pane_via_api(&mut self) { - self.runtime_pane_zoom( - "tui.pane.zoom", - crate::api::schema::PaneZoomParams { - pane_id: None, - mode: crate::api::schema::PaneZoomMode::Toggle, - }, - ); - } - - pub(crate) fn set_split_ratio_via_api(&mut self, path: Vec<bool>, ratio: f32) { - self.runtime_layout_set_split_ratio( - "tui.layout.set_split_ratio", - crate::api::schema::LayoutSetSplitRatioParams { - tab_id: None, - pane_id: None, - path, - ratio, - }, - ); - } - - pub(crate) fn cycle_pane_via_api(&mut self, reverse: bool) { - let Some((ws_idx, pane_id)) = self.focused_pane_target() else { - return; - }; - let Some(tab) = self.state.workspaces[ws_idx].active_tab() else { - return; - }; - let ids = tab.layout.pane_ids(); - let Some(pos) = ids.iter().position(|id| *id == pane_id) else { - return; - }; - let target = if reverse { - ids[(pos + ids.len() - 1) % ids.len()] - } else { - ids[(pos + 1) % ids.len()] - }; - self.focus_pane_internal_via_api(ws_idx, target); - } - - pub(crate) fn last_pane_via_api(&mut self) { - let Some(target) = self.state.previous_pane_focus.clone() else { - return; - }; - let Some((ws_idx, _tab_idx)) = self.state.pane_focus_target_indices(&target) else { - self.state.previous_pane_focus = None; - return; - }; - if self.state.current_pane_focus_target().as_ref() == Some(&target) { - self.state.previous_pane_focus = None; - return; - } - self.focus_pane_internal_via_api(ws_idx, target.pane_id); - } - - pub(crate) fn focus_toast_target_via_api(&mut self) { - let Some(target) = self - .state - .toast - .as_ref() - .and_then(|toast| toast.target.clone()) - else { - return; - }; - let Some(ws_idx) = self - .state - .workspaces - .iter() - .position(|workspace| workspace.id == target.workspace_id) - else { - return; - }; - self.focus_pane_internal_via_api(ws_idx, target.pane_id); - self.state.toast = None; - self.state.mode = Mode::Terminal; - } - - fn focused_pane_target(&self) -> Option<(usize, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; - Some((ws_idx, pane_id)) - } - - fn directional_pane_target_from_view( - &self, - direction: NavDirection, - ) -> Option<(usize, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let focused = self - .state - .view - .pane_infos - .iter() - .find(|pane| pane.is_focused)?; - let target = - crate::layout::find_in_direction(focused, direction, &self.state.view.pane_infos)?; - Some((ws_idx, target)) - } - - fn directional_pane_swap_from_view( - &self, - direction: NavDirection, - ) -> Option<(usize, crate::layout::PaneId, crate::layout::PaneId)> { - let ws_idx = self.state.active?; - let focused = self - .state - .view - .pane_infos - .iter() - .find(|pane| pane.is_focused)?; - let target = - crate::layout::find_in_direction(focused, direction, &self.state.view.pane_infos)?; - Some((ws_idx, focused.id, target)) - } - - fn relative_visible_workspace(&self, delta: isize) -> Option<usize> { - let order = self.state.visible_workspace_order(); - if order.is_empty() { - return None; - } - let current = self.state.active.unwrap_or(self.state.selected); - let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); - let next = (current_pos as isize + delta).rem_euclid(order.len() as isize) as usize; - order.get(next).copied() - } - - fn active_tab_move(&self, delta: isize) -> Option<(usize, usize, usize)> { - let ws_idx = self.state.active?; - let ws = self.state.workspaces.get(ws_idx)?; - let source = ws.active_tab; - let insert = tab_move_insert_index(ws.tabs.len(), source, delta)?; - Some((ws_idx, source, insert)) - } - - fn relative_tab(&self, delta: isize) -> Option<usize> { - let ws = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get(ws_idx))?; - if ws.tabs.is_empty() { - return None; - } - Some((ws.active_tab as isize + delta).rem_euclid(ws.tabs.len() as isize) as usize) - } - - fn agent_entry_target(&self, idx: usize) -> Option<(usize, crate::layout::PaneId)> { - let entries = crate::ui::agent_panel_entries(&self.state); - let target = entries.get(idx)?; - Some((target.ws_idx, target.pane_id)) - } - - fn relative_agent_entry(&self, forward: bool) -> Option<(usize, usize, crate::layout::PaneId)> { - let entries = crate::ui::agent_panel_entries(&self.state); - if entries.is_empty() { - return None; - } - let focused = self - .state - .active - .and_then(|idx| self.state.workspaces.get(idx)) - .and_then(crate::workspace::Workspace::focused_pane_id); - let current_idx = entries - .iter() - .position(|entry| Some(entry.pane_id) == focused); - let next_idx = match (current_idx, forward) { - (Some(idx), true) => (idx + 1) % entries.len(), - (Some(0), false) => entries.len() - 1, - (Some(idx), false) => idx - 1, - (None, true) => 0, - (None, false) => entries.len() - 1, - }; - let target = entries.get(next_idx)?; - Some((next_idx, target.ws_idx, target.pane_id)) - } - - fn pass_through_key_to_focused_pane(&mut self, key: TerminalKey) -> bool { - let Some(ws_idx) = self.state.active else { - return false; - }; - let Some(rt) = self - .state - .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) - else { - return false; - }; - - let bytes = rt.encode_terminal_key(key.clone()); - if bytes.is_empty() || rt.try_send_bytes(Bytes::from(bytes)).is_err() { - return false; - } - - self.state.mode = Mode::Terminal; - true - } - - pub(super) fn launch_custom_command( - &mut self, - binding: crate::config::CustomCommandKeybind, - context: ActionContext, - ) { - let previous_mode = self.state.mode; - let previous_toast = self.state.toast.clone(); - let result = match binding.action { - crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding), - crate::config::CustomCommandAction::Pane => { - self.spawn_pane_command(&binding.command, Vec::new()) - } - crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(&binding), - crate::config::CustomCommandAction::PluginAction => self - .invoke_plugin_action_from_keybind(binding.command.clone()) - .map_err(std::io::Error::other), - }; - match result { - Ok(()) => finish_custom_command_context(&mut self.state, context, previous_mode), - Err(err) => { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "custom command failed".to_string(), - context: err.to_string(), - position: None, - target: None, - }); - self.sync_toast_deadline(previous_toast); - finish_custom_command_context(&mut self.state, context, previous_mode); - } - } - } - - fn spawn_custom_popup_command( - &mut self, - binding: &crate::config::CustomCommandKeybind, - ) -> io::Result<()> { - self.spawn_popup_shell_command( - &binding.command, - None, - self.custom_command_env().0, - crate::app::popup::PopupGeometry { - width: binding.width, - height: binding.height, - }, - ) - } - - pub(crate) fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) { - let mut env = vec![( - crate::api::SOCKET_PATH_ENV_VAR.to_string(), - crate::api::socket_path().display().to_string(), - )]; - if let Ok(current_exe) = std::env::current_exe() { - env.push(( - "HERDR_BIN_PATH".to_string(), - current_exe.display().to_string(), - )); - } - - let mut cwd = None; - if let Some(ws_idx) = self.state.active { - env.push(( - "HERDR_ACTIVE_WORKSPACE_ID".to_string(), - self.public_workspace_id(ws_idx), - )); - if let Some(workspace) = self.state.workspaces.get(ws_idx) { - let tab_idx = workspace.active_tab_index(); - if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { - env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id)); - } - if let Some(pane_id) = workspace.focused_pane_id() { - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); - } - if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| { - tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) - }) { - env.push(( - "HERDR_ACTIVE_PANE_CWD".to_string(), - pane_cwd.display().to_string(), - )); - if pane_cwd.is_dir() { - cwd = Some(pane_cwd); - } - } - } - } - } - (env, cwd) - } - - fn spawn_custom_command( - &mut self, - binding: &crate::config::CustomCommandKeybind, - ) -> std::io::Result<()> { - let mut command = crate::platform::detached_custom_command_process(&binding.command); - command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - let (env, cwd) = self.custom_command_env(); - command.envs(env); - if let Some(cwd) = cwd { - command.current_dir(cwd); - } - let child = command.spawn()?; - self.detached_process_children.push(child); - Ok(()) - } - - pub(super) fn launch_focused_scrollback_editor(&mut self) { - let previous_toast = self.state.toast.clone(); - match self.open_focused_scrollback_in_editor() { - Ok(()) => self.sync_toast_deadline(previous_toast), - Err(err) => { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "edit scrollback failed".to_string(), - context: err.to_string(), - position: None, - target: None, - }); - self.sync_toast_deadline(previous_toast); - } - } - } - - fn open_focused_scrollback_in_editor(&mut self) -> std::io::Result<()> { - let ws_idx = self - .state - .active - .ok_or_else(|| std::io::Error::other("no active workspace"))?; - let ws = self - .state - .workspaces - .get(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let pane_id = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let scrollback = self - .state - .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) - .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? - .recent_unwrapped_text_snapshot(usize::MAX) - .text; - - let path = write_scrollback_temp_file(&scrollback)?; - - let argv = match crate::platform::scrollback_editor_argv(&path) { - Ok(argv) => argv, - Err(err) => { - let _ = fs::remove_file(&path); - return Err(err); - } - }; - let (env, _) = self.custom_command_env(); - let new_pane = match self.spawn_overlay_argv_command(&argv, None, env, vec![path.clone()]) { - Ok((_, new_pane)) => new_pane, - Err(err) => { - let _ = fs::remove_file(&path); - return Err(err); - } - }; - let terminal_id = new_pane.terminal.id.clone(); - self.terminal_runtimes - .insert(terminal_id.clone(), new_pane.runtime); - self.state - .remove_alias_shadowed_by_new_pane(new_pane.pane_id); - self.state.terminals.insert(terminal_id, new_pane.terminal); - - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::Finished, - title: "opened scrollback".to_string(), - context: format!("focused pane {public_pane_id}"), - position: None, - target: None, - }); - } - Ok(()) - } - - fn spawn_pane_command( - &mut self, - command: &str, - temp_files: Vec<std::path::PathBuf>, - ) -> std::io::Result<()> { - let Some(ws_idx) = self.state.active else { - return Err(std::io::Error::other("no active workspace")); - }; - let previous_focus_target = self.state.current_pane_focus_target(); - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); - let (env, _) = self.custom_command_env(); - - let ws = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let tab_idx = ws.active_tab_index(); - let previous_focus = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let cwd = ws.active_tab().and_then(|tab| { - tab.cwd_for_pane( - previous_focus, - &self.state.terminals, - &self.terminal_runtimes, - ) - }); - let new_pane = ws.split_focused_command( - Direction::Horizontal, - new_rows, - new_cols, - cwd, - command, - env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - )?; - let new_pane_id = new_pane.pane_id; - self.terminal_runtimes - .insert(new_pane.terminal.id.clone(), new_pane.runtime); - self.state - .terminals - .insert(new_pane.terminal.id.clone(), new_pane.terminal); - let new_focus_target = crate::app::state::PaneFocusTarget { - workspace_id: ws.id.clone(), - pane_id: new_pane_id, - }; - if previous_focus_target.as_ref() != Some(&new_focus_target) { - self.state.previous_pane_focus = previous_focus_target; - } - ws.active_tab_mut() - .expect("workspace must have an active tab") - .layout - .focus_pane(new_pane_id); - ws.active_tab_mut() - .expect("workspace must have an active tab") - .zoomed = true; - self.overlay_panes.insert( - new_pane_id, - super::super::OverlayPaneState { - ws_idx, - tab_idx, - previous_focus, - previous_zoomed, - temp_files, - }, - ); - self.state.remove_alias_shadowed_by_new_pane(new_pane_id); - self.state.mode = Mode::Terminal; - Ok(()) - } - - pub(crate) fn spawn_overlay_argv_command( - &mut self, - argv: &[String], - cwd: Option<std::path::PathBuf>, - extra_env: Vec<(String, String)>, - temp_files: Vec<std::path::PathBuf>, - ) -> std::io::Result<(usize, crate::workspace::NewPane)> { - let Some(ws_idx) = self.state.active else { - return Err(std::io::Error::other("no active workspace")); - }; - let previous_focus_target = self.state.current_pane_focus_target(); - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); - - let ws = self - .state - .workspaces - .get(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let previous_focus = ws - .focused_pane_id() - .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let cwd = cwd.or_else(|| { - ws.active_tab().and_then(|tab| { - tab.cwd_for_pane( - previous_focus, - &self.state.terminals, - &self.terminal_runtimes, - ) - }) - }); - - let (tab_idx, new_pane, workspace_id) = { - let ws = self - .state - .workspaces - .get_mut(ws_idx) - .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; - let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); - let result = ws.split_pane_argv_command( - previous_focus, - Direction::Horizontal, - new_rows, - new_cols, - cwd, - argv, - extra_env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - true, - ); - let (tab_idx, new_pane) = match result { - Some(Ok(result)) => result, - Some(Err(err)) => return Err(err), - None => return Err(std::io::Error::other("focused pane disappeared")), - }; - ws.tabs - .get_mut(tab_idx) - .ok_or_else(|| std::io::Error::other("plugin overlay tab disappeared"))? - .zoomed = true; - self.overlay_panes.insert( - new_pane.pane_id, - super::super::OverlayPaneState { - ws_idx, - tab_idx, - previous_focus, - previous_zoomed, - temp_files, - }, - ); - (tab_idx, new_pane, ws.id.clone()) - }; - - let new_focus_target = crate::app::state::PaneFocusTarget { - workspace_id, - pane_id: new_pane.pane_id, - }; - if previous_focus_target.as_ref() != Some(&new_focus_target) { - self.state.previous_pane_focus = previous_focus_target; - } - self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.mode = Mode::Terminal; - Ok((ws_idx, new_pane)) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum BindingDispatch { - Direct, - Prefix, -} - -enum PrefixBindingMatch { - Action(NavigateAction), - Command(crate::config::CustomCommandKeybind), -} - -fn prefix_binding_for_key(state: &AppState, key: &TerminalKey) -> Option<PrefixBindingMatch> { - exact_prefix_binding_for_key(state, key).or_else(|| { - generated_character_key(key) - .as_ref() - .and_then(|generated_key| exact_prefix_binding_for_key(state, generated_key)) - }) -} - -fn exact_prefix_binding_for_key(state: &AppState, key: &TerminalKey) -> Option<PrefixBindingMatch> { - non_indexed_action_for_key(state, key, BindingDispatch::Prefix) - .map(PrefixBindingMatch::Action) - .or_else(|| { - command_for_key(state, key, BindingDispatch::Prefix).map(PrefixBindingMatch::Command) - }) - .or_else(|| { - indexed_navigation_action(state, key, BindingDispatch::Prefix) - .map(PrefixBindingMatch::Action) - }) -} - -fn generated_character_key(key: &TerminalKey) -> Option<TerminalKey> { - let mut characters = key.generated_text.as_deref()?.chars(); - let character = characters.next()?; - if character.is_control() || characters.next().is_some() { - return None; - } - Some(TerminalKey::new( - KeyCode::Char(character), - crossterm::event::KeyModifiers::empty(), - )) -} - -pub(crate) fn command_for_key( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option<crate::config::CustomCommandKeybind> { - state - .keybinds - .custom_commands - .iter() - .find(|binding| match dispatch { - BindingDispatch::Direct => binding.bindings.matches_direct_key(key), - BindingDispatch::Prefix => binding.bindings.matches_prefix_key(key), - }) - .cloned() -} - -fn unmodified_digit_for_key(key: &TerminalKey) -> Option<char> { - ('1'..='9').find(|digit| { - crate::config::terminal_key_matches_combo( - key, - ( - KeyCode::Char(*digit), - crossterm::event::KeyModifiers::empty(), - ), - ) - }) -} - -#[cfg(test)] -pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKey) -> bool { - if let Some(c) = unmodified_digit_for_key(&key) { - let idx = (c as usize) - ('1' as usize); - if let Some(ws_idx) = state.workspace_at_visible_position(idx) { - state.switch_workspace(ws_idx); - leave_navigate_mode(state); - } - return true; - } - - let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); - if modifiers.is_empty() { - match code { - KeyCode::Enter => { - if !state.workspaces.is_empty() { - state.switch_workspace(state.selected); - leave_navigate_mode(state); - } - return true; - } - KeyCode::Tab => { - state.cycle_pane(false); - return true; - } - KeyCode::BackTab => { - state.cycle_pane(true); - return true; - } - KeyCode::Left => { - state.navigate_pane(NavDirection::Left); - return true; - } - KeyCode::Right => { - state.navigate_pane(NavDirection::Right); - return true; - } - _ => {} - } - } - - if state - .keybinds - .navigate - .workspace_up - .matches_direct_key(&key) - { - state.move_selected_workspace_by_visible_delta(-1); - return true; - } - if state - .keybinds - .navigate - .workspace_down - .matches_direct_key(&key) - { - state.move_selected_workspace_by_visible_delta(1); - return true; - } - if state.keybinds.navigate.pane_left.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Left); - return true; - } - if state.keybinds.navigate.pane_down.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Down); - return true; - } - if state.keybinds.navigate.pane_up.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Up); - return true; - } - if state.keybinds.navigate.pane_right.matches_direct_key(&key) { - state.navigate_pane(NavDirection::Right); - return true; - } - - false -} - -fn navigate_reserved_action_for_key(state: &AppState, key: &TerminalKey) -> Option<NavigateAction> { - if let Some(c) = unmodified_digit_for_key(key) { - return Some(NavigateAction::SwitchWorkspace( - (c as usize) - ('1' as usize), - )); - } - - let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); - if modifiers.is_empty() { - match code { - KeyCode::Enter => { - return (!state.workspaces.is_empty()).then_some(NavigateAction::SwitchWorkspace( - state - .visible_workspace_order() - .iter() - .position(|idx| *idx == state.selected) - .unwrap_or(state.selected), - )); - } - KeyCode::Tab => return Some(NavigateAction::CyclePaneNext), - KeyCode::BackTab => return Some(NavigateAction::CyclePanePrevious), - KeyCode::Left => return Some(NavigateAction::FocusPaneLeft), - KeyCode::Right => return Some(NavigateAction::FocusPaneRight), - _ => {} - } - } - - if state.keybinds.navigate.workspace_up.matches_direct_key(key) - || state - .keybinds - .navigate - .workspace_down - .matches_direct_key(key) - { - return None; - } - if state.keybinds.navigate.pane_left.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneLeft); - } - if state.keybinds.navigate.pane_down.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneDown); - } - if state.keybinds.navigate.pane_up.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneUp); - } - if state.keybinds.navigate.pane_right.matches_direct_key(key) { - return Some(NavigateAction::FocusPaneRight); - } - - None -} - -pub(super) fn api_pane_direction(direction: NavDirection) -> crate::api::schema::PaneDirection { - match direction { - NavDirection::Left => crate::api::schema::PaneDirection::Left, - NavDirection::Right => crate::api::schema::PaneDirection::Right, - NavDirection::Up => crate::api::schema::PaneDirection::Up, - NavDirection::Down => crate::api::schema::PaneDirection::Down, - } -} - -#[cfg(test)] -pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_dismissed = true; - let terminal_key = TerminalKey::from(key); - - if state.is_prefix_key(&terminal_key) || key.code == KeyCode::Esc { - leave_navigate_mode(state); - return; - } - - if handle_navigate_reserved_key(state, terminal_key.clone()) { - return; - } - - if let Some(action) = navigate_mode_action_for_key(state, terminal_key) { - execute_navigate_action_in_context( - state, - &mut terminal_runtimes, - action, - ActionContext::Navigate, - ); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NavigateAction { - NewWorkspace, - NewWorktree, - OpenWorktree, - RemoveWorktree, - RenameWorkspace, - CloseWorkspace, - SwitchWorkspace(usize), - SwitchTab(usize), - FocusAgent(usize), - WorkspacePicker, - PreviousWorkspace, - NextWorkspace, - PreviousAgent, - NextAgent, - NewTab, - RenameTab, - PreviousTab, - NextTab, - MoveTabPrevious, - MoveTabNext, - CloseTab, - RenamePane, - FocusPaneLeft, - FocusPaneDown, - FocusPaneUp, - FocusPaneRight, - SwapPaneLeft, - SwapPaneDown, - SwapPaneUp, - SwapPaneRight, - SplitVertical, - SplitHorizontal, - ClosePane, - EditScrollback, - CopyMode, - Zoom, - EnterResizeMode, - ResizePaneLeft, - ResizePaneDown, - ResizePaneUp, - ResizePaneRight, - ToggleSidebar, - CyclePaneNext, - CyclePanePrevious, - LastPane, - Help, - Settings, - ReloadConfig, - OpenNotificationTarget, - Detach, - OpenNavigator, -} - -fn copy_mode_survives_prefix_action(action: NavigateAction) -> bool { - matches!( - action, - NavigateAction::SwitchWorkspace(_) - | NavigateAction::SwitchTab(_) - | NavigateAction::FocusAgent(_) - | NavigateAction::PreviousWorkspace - | NavigateAction::NextWorkspace - | NavigateAction::PreviousAgent - | NavigateAction::NextAgent - | NavigateAction::PreviousTab - | NavigateAction::NextTab - | NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - | NavigateAction::CyclePaneNext - | NavigateAction::CyclePanePrevious - | NavigateAction::LastPane - | NavigateAction::OpenNotificationTarget - ) -} - -fn indexed_navigation_action( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option<NavigateAction> { - let kb = &state.keybinds; - let actual_modifiers = crate::config::normalize_key_combo((key.code, key.modifiers)).1; - - for exact_modifiers in [true, false] { - let trigger_matches = |binding: &crate::config::IndexedKeybind| { - let dispatch_matches = match dispatch { - BindingDispatch::Direct => binding.trigger.is_direct(), - BindingDispatch::Prefix => binding.trigger.is_prefix(), - }; - let expected_modifiers = crate::config::normalize_key_combo(binding.trigger.combo()).1; - dispatch_matches && (actual_modifiers == expected_modifiers) == exact_modifiers - }; - - for binding in &kb.switch_tab { - if trigger_matches(binding) { - if let Some(idx) = binding.matched_index(key) { - return Some(NavigateAction::SwitchTab(idx)); - } - } - } - for binding in &kb.switch_workspace { - if trigger_matches(binding) { - if let Some(idx) = binding.matched_index(key) { - return Some(NavigateAction::SwitchWorkspace(idx)); - } - } - } - for binding in &kb.focus_agent { - if trigger_matches(binding) { - if let Some(idx) = binding.matched_index(key) { - return Some(NavigateAction::FocusAgent(idx)); - } - } - } - } - - None -} - -fn action_matches( - bindings: &crate::config::ActionKeybinds, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> bool { - match dispatch { - BindingDispatch::Direct => bindings.matches_direct_key(key), - BindingDispatch::Prefix => bindings.matches_prefix_key(key), - } -} - -#[cfg(test)] -fn action_for_key( - state: &AppState, - key: TerminalKey, - dispatch: BindingDispatch, -) -> Option<NavigateAction> { - non_indexed_action_for_key(state, &key, dispatch) - .or_else(|| indexed_navigation_action(state, &key, dispatch)) -} - -fn non_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, - dispatch: BindingDispatch, -) -> Option<NavigateAction> { - let kb = &state.keybinds; - for (bindings, action) in [ - (&kb.help, NavigateAction::Help), - (&kb.settings, NavigateAction::Settings), - (&kb.workspace_picker, NavigateAction::WorkspacePicker), - (&kb.new_workspace, NavigateAction::NewWorkspace), - (&kb.new_worktree, NavigateAction::NewWorktree), - (&kb.open_worktree, NavigateAction::OpenWorktree), - (&kb.remove_worktree, NavigateAction::RemoveWorktree), - (&kb.rename_workspace, NavigateAction::RenameWorkspace), - (&kb.close_workspace, NavigateAction::CloseWorkspace), - (&kb.previous_workspace, NavigateAction::PreviousWorkspace), - (&kb.next_workspace, NavigateAction::NextWorkspace), - (&kb.previous_agent, NavigateAction::PreviousAgent), - (&kb.next_agent, NavigateAction::NextAgent), - (&kb.new_tab, NavigateAction::NewTab), - (&kb.rename_tab, NavigateAction::RenameTab), - (&kb.previous_tab, NavigateAction::PreviousTab), - (&kb.next_tab, NavigateAction::NextTab), - (&kb.move_tab_previous, NavigateAction::MoveTabPrevious), - (&kb.move_tab_next, NavigateAction::MoveTabNext), - (&kb.close_tab, NavigateAction::CloseTab), - (&kb.rename_pane, NavigateAction::RenamePane), - (&kb.edit_scrollback, NavigateAction::EditScrollback), - (&kb.copy_mode, NavigateAction::CopyMode), - (&kb.focus_pane_left, NavigateAction::FocusPaneLeft), - (&kb.focus_pane_down, NavigateAction::FocusPaneDown), - (&kb.focus_pane_up, NavigateAction::FocusPaneUp), - (&kb.focus_pane_right, NavigateAction::FocusPaneRight), - (&kb.swap_pane_left, NavigateAction::SwapPaneLeft), - (&kb.swap_pane_down, NavigateAction::SwapPaneDown), - (&kb.swap_pane_up, NavigateAction::SwapPaneUp), - (&kb.swap_pane_right, NavigateAction::SwapPaneRight), - (&kb.last_pane, NavigateAction::LastPane), - (&kb.cycle_pane_next, NavigateAction::CyclePaneNext), - (&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious), - (&kb.split_vertical, NavigateAction::SplitVertical), - (&kb.split_horizontal, NavigateAction::SplitHorizontal), - (&kb.close_pane, NavigateAction::ClosePane), - (&kb.zoom, NavigateAction::Zoom), - (&kb.resize_mode, NavigateAction::EnterResizeMode), - (&kb.resize_pane_left, NavigateAction::ResizePaneLeft), - (&kb.resize_pane_down, NavigateAction::ResizePaneDown), - (&kb.resize_pane_up, NavigateAction::ResizePaneUp), - (&kb.resize_pane_right, NavigateAction::ResizePaneRight), - (&kb.toggle_sidebar, NavigateAction::ToggleSidebar), - (&kb.reload_config, NavigateAction::ReloadConfig), - ( - &kb.open_notification_target, - NavigateAction::OpenNotificationTarget, - ), - (&kb.detach, NavigateAction::Detach), - (&kb.goto, NavigateAction::OpenNavigator), - ] { - if action_matches(bindings, key, dispatch) { - return Some(action); - } - } - None -} - -#[cfg(test)] -fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option<NavigateAction> { - let action = action_for_key(state, key, BindingDispatch::Prefix)?; - if matches!( - action, - NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - ) { - return None; - } - Some(action) -} - -fn navigate_mode_non_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, -) -> Option<NavigateAction> { - let action = non_indexed_action_for_key(state, key, BindingDispatch::Prefix)?; - if matches!( - action, - NavigateAction::FocusPaneLeft - | NavigateAction::FocusPaneDown - | NavigateAction::FocusPaneUp - | NavigateAction::FocusPaneRight - ) { - return None; - } - Some(action) -} - -fn navigate_mode_indexed_action_for_key( - state: &AppState, - key: &TerminalKey, -) -> Option<NavigateAction> { - indexed_navigation_action(state, key, BindingDispatch::Prefix) -} - -#[cfg(test)] -pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - execute_navigate_action_in_context( - state, - &mut terminal_runtimes, - action, - ActionContext::Navigate, - ); -} - -#[cfg(test)] -pub(super) fn execute_navigate_action_in_context( - state: &mut AppState, - terminal_runtimes: &mut TerminalRuntimeRegistry, - action: NavigateAction, - context: ActionContext, -) { - let previous_mode = state.mode; - match action { - NavigateAction::NewWorkspace => { - state.request_new_workspace = true; - leave_navigate_mode(state); - } - NavigateAction::NewWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) - .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) - { - state.request_new_linked_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::OpenWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) - .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) - { - state.request_open_existing_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::RemoveWorktree => { - if let Some(ws_idx) = workspace_action_target(state, context) { - state.request_remove_linked_worktree = Some(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::RenameWorkspace => { - if let Some(ws_idx) = workspace_action_target(state, context) { - super::modal::open_rename_workspace(state, terminal_runtimes, ws_idx); - } - } - NavigateAction::CloseWorkspace => { - if let Some(ws_idx) = workspace_action_target(state, context) { - state.selected = ws_idx; - if state.confirm_close { - super::modal::open_confirm_close(state); - } else { - state.close_selected_workspace(); - leave_navigate_mode(state); - } - } - } - NavigateAction::SwitchWorkspace(idx) => { - if let Some(ws_idx) = state.workspace_at_visible_position(idx) { - state.switch_workspace(ws_idx); - leave_navigate_mode(state); - } - } - NavigateAction::SwitchTab(idx) => { - let tab_exists = state - .active - .and_then(|ws_idx| state.workspaces.get(ws_idx)) - .is_some_and(|ws| idx < ws.tabs.len()); - if tab_exists { - state.switch_tab(idx); - leave_navigate_mode(state); - } - } - NavigateAction::FocusAgent(idx) => { - if state.focus_agent_entry(idx) { - leave_navigate_mode(state); - } - } - NavigateAction::WorkspacePicker => { - state.mobile_switcher_scroll = 0; - state.mode = Mode::Navigate; - } - NavigateAction::PreviousWorkspace => { - state.previous_workspace(); - leave_navigate_mode(state); - } - NavigateAction::NextWorkspace => { - state.next_workspace(); - leave_navigate_mode(state); - } - NavigateAction::PreviousAgent => { - state.previous_agent(); - leave_navigate_mode(state); - } - NavigateAction::NextAgent => { - state.next_agent(); - leave_navigate_mode(state); - } - NavigateAction::NewTab => { - if state.active.is_some() { - if state.prompt_new_tab_name { - super::modal::open_new_tab_dialog(state); - } else { - state.request_new_tab = true; - leave_navigate_mode(state); - } - } - } - NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false), - NavigateAction::PreviousTab => { - state.previous_tab(); - leave_navigate_mode(state); - } - NavigateAction::NextTab => { - state.next_tab(); - leave_navigate_mode(state); - } - NavigateAction::MoveTabPrevious => { - move_active_tab_relative(state, -1); - leave_navigate_mode(state); - } - NavigateAction::MoveTabNext => { - move_active_tab_relative(state, 1); - leave_navigate_mode(state); - } - NavigateAction::CloseTab => { - if !state.close_tab() { - leave_navigate_mode(state); - } - } - NavigateAction::RenamePane => { - if let Some(pane_id) = state - .active - .and_then(|ws_idx| state.workspaces.get(ws_idx)) - .and_then(|ws| ws.focused_pane_id()) - { - super::modal::open_rename_pane(state, pane_id); - } - } - NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left), - NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down), - NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), - NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), - NavigateAction::SwapPaneLeft => { - state.swap_pane(NavDirection::Left); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneDown => { - state.swap_pane(NavDirection::Down); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneUp => { - state.swap_pane(NavDirection::Up); - leave_navigate_mode(state); - } - NavigateAction::SwapPaneRight => { - state.swap_pane(NavDirection::Right); - leave_navigate_mode(state); - } - NavigateAction::SplitVertical => { - state.split_pane(terminal_runtimes, Direction::Horizontal); - leave_navigate_mode(state); - } - NavigateAction::SplitHorizontal => { - state.split_pane(terminal_runtimes, Direction::Vertical); - leave_navigate_mode(state); - } - NavigateAction::ClosePane => { - if !state.close_pane() { - leave_navigate_mode(state); - } - } - NavigateAction::EditScrollback => {} - NavigateAction::CopyMode => state.enter_copy_mode(terminal_runtimes), - NavigateAction::Zoom => { - state.toggle_zoom(); - leave_navigate_mode(state); - } - NavigateAction::EnterResizeMode => state.mode = Mode::Resize, - NavigateAction::ResizePaneLeft => { - state.resize_pane(NavDirection::Left); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneDown => { - state.resize_pane(NavDirection::Down); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneUp => { - state.resize_pane(NavDirection::Up); - leave_navigate_mode(state); - } - NavigateAction::ResizePaneRight => { - state.resize_pane(NavDirection::Right); - leave_navigate_mode(state); - } - NavigateAction::ToggleSidebar => { - state.sidebar_collapsed = !state.sidebar_collapsed; - leave_navigate_mode(state); - } - NavigateAction::CyclePaneNext => { - state.cycle_pane(false); - leave_navigate_mode(state); - } - NavigateAction::CyclePanePrevious => { - state.cycle_pane(true); - leave_navigate_mode(state); - } - NavigateAction::LastPane => { - state.last_pane(); - leave_navigate_mode(state); - } - NavigateAction::Help => super::modal::open_keybind_help(state), - NavigateAction::Settings => super::settings::open_settings(state), - NavigateAction::ReloadConfig => { - state.request_reload_config = true; - leave_navigate_mode(state); - } - NavigateAction::OpenNotificationTarget => { - state.focus_toast_target(); - if state.mode == Mode::Navigate { - leave_navigate_mode(state); - } - } - NavigateAction::Detach => { - super::modal::request_detach(state); - leave_navigate_mode(state); - } - NavigateAction::OpenNavigator => state.open_navigator_from(terminal_runtimes), - } - - finish_action_context(state, context, previous_mode); -} - -fn workspace_action_target(state: &AppState, context: ActionContext) -> Option<usize> { - let idx = match context { - ActionContext::Direct | ActionContext::Prefix => state.active.unwrap_or(state.selected), - ActionContext::Navigate => state.selected, - }; - (idx < state.workspaces.len()).then_some(idx) -} - -fn workspace_can_start_worktree_action( - state: &AppState, - terminal_runtimes: &TerminalRuntimeRegistry, - ws_idx: usize, -) -> bool { - let Some(ws) = state.workspaces.get(ws_idx) else { - return false; - }; - if ws - .worktree_space() - .is_some_and(|space| space.is_linked_worktree) - { - return false; - } - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&state.terminals, terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - !git_space.is_some_and(|space| space.is_linked_worktree) -} - -// Translate a one-step move into the pre-removal insertion slot that -// Workspace::move_tab expects, wrapping at either end. None when there is -// nothing to move. -fn tab_move_insert_index(len: usize, source: usize, delta: isize) -> Option<usize> { - if len <= 1 { - return None; - } - Some(if delta > 0 { - if source + 1 >= len { - 0 - } else { - source + 2 - } - } else if source == 0 { - len - } else { - source - 1 - }) -} - -#[cfg(test)] -fn move_active_tab_relative(state: &mut AppState, delta: isize) { - let Some(ws) = state - .active - .and_then(|ws_idx| state.workspaces.get_mut(ws_idx)) - else { - return; - }; - let source = ws.active_tab; - if let Some(insert) = tab_move_insert_index(ws.tabs.len(), source, delta) { - ws.move_tab(source, insert); - } -} - -fn leave_navigate_mode(state: &mut AppState) { - if state.active.is_some() { - state.mode = Mode::Terminal; - } -} - -fn finish_action_context(state: &mut AppState, context: ActionContext, previous_mode: Mode) { - if matches!(context, ActionContext::Direct | ActionContext::Prefix) - && state.mode == previous_mode - { - leave_command_mode(state); - } -} - -fn finish_custom_command_context( - state: &mut AppState, - context: ActionContext, - previous_mode: Mode, -) { - if context == ActionContext::Navigate { - leave_navigate_mode(state); - } else { - finish_action_context(state, context, previous_mode); - } -} - -fn leave_command_mode(state: &mut AppState) { - if state.copy_mode_pane_is_focused() { - state.mode = Mode::Copy; - } else if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - }; -} - -fn write_scrollback_temp_file(content: &str) -> io::Result<std::path::PathBuf> { - let mut last_collision = None; - for attempt in 0..16 { - let path = unique_scrollback_path(attempt); - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - - match options.open(&path) { - Ok(mut file) => { - file.write_all(content.as_bytes())?; - return Ok(path); - } - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { - last_collision = Some(err); - } - Err(err) => return Err(err), - } - } - - Err(last_collision.unwrap_or_else(|| { - io::Error::new( - io::ErrorKind::AlreadyExists, - "failed to create unique scrollback temp file", - ) - })) -} - -fn unique_scrollback_path(attempt: u32) -> std::path::PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!( - "herdr-scrollback-{}-{nanos}-{attempt}.txt", - std::process::id() - )) -} - -#[cfg(test)] -mod tests { - #[cfg(unix)] - use std::time::Duration; - - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, ModifierKeyCode}; - use ratatui::layout::Direction; - - use super::super::{state_with_workspaces, unique_temp_path}; - #[cfg(unix)] - use super::super::{wait_for_detached_process_reap, wait_for_file}; - use super::*; - use crate::{ - app::App, - config::Config, - input::TerminalKey, - raw_input::{parse_raw_input_bytes_sync, RawInputEvent}, - terminal::TerminalState, - workspace::Workspace, - }; - - fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { - state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/worktree-{ws_idx}").into(), - is_linked_worktree: ws_idx != 0, - }); - } - - fn app_with_test_workspaces(names: &[&str]) -> App { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); - app.state.ensure_test_terminals(); - app.state.active = (!app.state.workspaces.is_empty()).then_some(0); - app.state.selected = 0; - app - } - - #[test] - fn next_agent_starts_at_first_visible_entry_when_focused_agent_is_filtered_out() { - let mut app = app_with_test_workspaces(&["hidden", "first", "second"]); - for ws_idx in 0..app.state.workspaces.len() { - let pane_id = app.state.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = if ws_idx == 0 { - crate::detect::AgentState::Idle - } else { - crate::detect::AgentState::Working - }; - } - app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::Status, - ), - value: crate::api::schema::AgentViewValue::String("working".to_string()), - }), - sort: Vec::new(), - }); - - app.execute_tui_navigate_action(NavigateAction::NextAgent, ActionContext::Prefix); - - assert_eq!(app.state.active, Some(1)); - } - - #[test] - fn default_goto_key_opens_navigator() { - let mut state = state_with_workspaces(&["test"]); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Navigator); - } - - #[test] - fn custom_rename_key_enters_rename_mode() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.name_input, "test"); - } - - #[test] - fn rename_workspace_prefills_live_terminal_cwd_label() { - let mut state = state_with_workspaces(&["stale"]); - let root = state.workspaces[0].tabs[0].root_pane; - let terminal_id = state.workspaces[0].panes[&root] - .attached_terminal_id - .clone(); - state.workspaces[0].custom_name = None; - state.workspaces[0].identity_cwd = "/__herdr_original__".into(); - state.terminals.insert( - terminal_id.clone(), - TerminalState::new(terminal_id, "/__herdr_projects__".into()), - ); - state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.name_input, "__herdr_projects__"); - assert_eq!(state.workspaces[0].display_name(), "__herdr_original__"); - } - - #[test] - fn prefix_rename_workspace_targets_active_workspace_not_stale_selection() { - let mut state = state_with_workspaces(&["main", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.active = Some(1); - state.selected = 0; - state.mode = Mode::Prefix; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::RenameWorkspace, - ActionContext::Prefix, - ); - - assert_eq!(state.mode, Mode::RenameWorkspace); - assert_eq!(state.selected, 1); - assert_eq!(state.name_input, "issue"); - } - - #[test] - fn prefix_close_workspace_targets_active_linked_worktree_without_removing_checkout() { - let mut state = state_with_workspaces(&["main", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.active = Some(1); - state.selected = 0; - state.mode = Mode::Prefix; - state.confirm_close = false; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::CloseWorkspace, - ActionContext::Prefix, - ); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_new_workspace_key_requests_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.request_new_workspace); - assert_eq!(state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn new_workspace_key_opens_prefilled_prompt_and_preserves_captured_cwd() { - let cwd = unique_temp_path("workspace-name-suggestion"); - std::fs::create_dir_all(&cwd).unwrap(); - let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); - let mut app = app_with_test_workspaces(&["test"]); - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path(cwd.display().to_string()); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - app.state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert_eq!(app.state.name_input, suggested_name); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.pending_workspace_create_cwd.as_ref(), Some(&cwd)); - assert_eq!(app.state.workspaces.len(), 1); - - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path("/tmp/changed-after-prompt".into()); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.workspaces[1].identity_cwd, cwd); - assert!(app.state.workspaces[1].custom_name.is_none()); - assert!(app.state.pending_workspace_create_cwd.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(&cwd); - } - - #[tokio::test] - async fn new_workspace_prompt_saves_custom_name_atomically() { - let cwd = unique_temp_path("workspace-custom-name"); - std::fs::create_dir_all(&cwd).unwrap(); - let mut app = app_with_test_workspaces(&["test"]); - app.state.new_terminal_cwd = - crate::config::NewTerminalCwdConfig::Path(cwd.display().to_string()); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::NewWorkspace, ActionContext::Navigate); - app.state.name_input = " logs ".into(); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.workspaces[1].custom_name.as_deref(), Some("logs")); - assert_eq!(app.state.workspaces[1].identity_cwd, cwd); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(&cwd); - } - - #[test] - fn cancelling_new_workspace_prompt_creates_nothing() { - let mut app = app_with_test_workspaces(&["test"]); - app.state.prompt_new_workspace_name = true; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::NewWorkspace, ActionContext::Navigate); - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 1); - assert!(app.state.pending_workspace_create_cwd.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn custom_new_worktree_key_requests_selected_workspace() { - let mut state = state_with_workspaces(&["main", "scratch"]); - state.workspaces[1].identity_cwd = unique_temp_path("navigate-new-worktree-selected"); - state.mode = Mode::Navigate; - state.selected = 1; - state.active = Some(0); - state.keybinds.new_worktree = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.request_new_linked_worktree, Some(1)); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn worktree_actions_do_not_start_from_linked_child_workspace() { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - let mut state = state_with_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 1, "repo-key"); - state.mode = Mode::Navigate; - state.selected = 1; - state.active = Some(0); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewWorktree, - ActionContext::Navigate, - ); - assert_eq!(state.request_new_linked_worktree, None); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::OpenWorktree, - ActionContext::Navigate, - ); - assert_eq!(state.request_open_existing_worktree, None); - } - - #[test] - fn direct_new_worktree_action_targets_active_workspace() { - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - let mut state = state_with_workspaces(&["main", "scratch"]); - state.workspaces[0].identity_cwd = unique_temp_path("navigate-new-worktree-active"); - state.mode = Mode::Terminal; - state.selected = 1; - state.active = Some(0); - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewWorktree, - ActionContext::Direct, - ); - - assert_eq!(state.request_new_linked_worktree, Some(0)); - } - - #[test] - fn navigate_down_follows_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Navigate; - state.active = Some(0); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 2); - } - - #[test] - fn navigate_number_keys_follow_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Navigate; - state.active = Some(0); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('2'), KeyModifiers::empty()), - ); - - assert_eq!(state.active, Some(2)); - assert_eq!(state.selected, 2); - } - - #[test] - fn indexed_switch_workspace_keybind_follows_grouped_sidebar_visual_order() { - let mut state = state_with_workspaces(&["main", "normal", "issue"]); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 2, "repo-key"); - state.mode = Mode::Prefix; - state.active = Some(0); - state.selected = 0; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::SwitchWorkspace(1), - ActionContext::Prefix, - ); - - assert_eq!(state.active, Some(2)); - assert_eq!(state.selected, 2); - } - - #[test] - fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("g"); - assert!(!state.sidebar_collapsed); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.sidebar_collapsed); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_resize_key_enters_resize_mode() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Resize); - } - - #[test] - fn custom_reload_config_key_requests_reload_and_exits_navigate() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.reload_config = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.request_reload_config); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn custom_open_notification_key_focuses_current_toast_target() { - let mut state = state_with_workspaces(&["one", "two"]); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - state.keybinds.open_notification_target = crate::config::ActionKeybinds::prefix("g"); - let target_workspace_id = state.workspaces[1].id.clone(); - let target_pane = state.workspaces[1].tabs[0].root_pane; - state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "pi needs attention".into(), - context: "two".into(), - position: None, - target: Some(crate::app::state::ToastTarget { - workspace_id: target_workspace_id, - pane_id: target_pane, - }), - }); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.selected, 1); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(target_pane)); - assert!(state.toast.is_none()); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn movement_action_stays_in_navigate_mode() { - let mut state = state_with_workspaces(&["a", "b"]); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn navigate_workspace_keys_are_configurable() { - let mut state = state_with_workspaces(&["a", "b"]); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - state.selected = 0; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn navigate_pane_keys_are_configurable() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn focus_pane_prefix_rhs_does_not_create_navigate_mode_pane_shortcut() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -focus_pane_down = "prefix+f" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn customized_navigate_pane_key_disables_matching_prefix_rhs_fallback() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let below = state.workspaces[0].test_split(Direction::Vertical); - state.workspaces[0].layout.focus_pane(root); - state.view.pane_infos = state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn left_and_right_arrows_remain_permanent_navigate_pane_aliases() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - state.workspaces[0].layout.focus_pane(right); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24)); - let config: Config = toml::from_str( - r#" -[keys] -navigate_pane_left = "ctrl+h" -navigate_pane_right = "ctrl+l" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Left, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24)); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), - ); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - assert_eq!(state.mode, Mode::Navigate); - } - - #[test] - fn mobile_workspace_keyboard_navigation_keeps_selected_row_visible() { - let mut state = state_with_workspaces(&["a", "b", "c", "d"]); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Navigate; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 44, 8)); - assert_eq!(state.mobile_switcher_scroll, 0); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - - assert_eq!(state.selected, 1); - assert_eq!(state.mobile_switcher_scroll, 1); - } - - #[test] - fn terminal_direct_agent_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.next_agent = crate::config::ActionKeybinds::direct("alt+a"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('a'), KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::NextAgent)); - } - - #[test] - fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+left"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Left, KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); - } - - #[test] - fn terminal_direct_swap_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.swap_pane_right = crate::config::ActionKeybinds::direct("alt+shift+l"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT | KeyModifiers::SHIFT), - ); - - assert_eq!(action, Some(NavigateAction::SwapPaneRight)); - } - - #[test] - fn terminal_direct_resize_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.resize_pane_right = - crate::config::ActionKeybinds::direct("ctrl+shift+alt+right"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new( - KeyCode::Right, - KeyModifiers::CONTROL | KeyModifiers::SHIFT | KeyModifiers::ALT, - ), - ); - - assert_eq!(action, Some(NavigateAction::ResizePaneRight)); - } - - #[test] - fn prefix_resize_pane_binding_maps_to_navigation_action() { - let config: Config = toml::from_str( - r#" -[keys] -resize_pane_left = "prefix+shift+left" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Left, KeyModifiers::SHIFT), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::ResizePaneLeft)); - } - - #[test] - fn terminal_direct_move_tab_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.move_tab_next = crate::config::ActionKeybinds::direct("alt+shift+right"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Right, KeyModifiers::ALT | KeyModifiers::SHIFT), - ); - - assert_eq!(action, Some(NavigateAction::MoveTabNext)); - } - - fn tab_labels(state: &AppState) -> Vec<String> { - let ws = &state.workspaces[0]; - (0..ws.tabs.len()) - .map(|tab_idx| ws.tab_display_name(tab_idx).unwrap()) - .collect() - } - - #[test] - fn move_tab_actions_reorder_and_wrap_the_active_tab() { - let mut state = state_with_workspaces(&["test"]); - { - let ws = &mut state.workspaces[0]; - ws.tabs[0].set_custom_name("a".into()); - ws.test_add_tab(Some("b")); - ws.test_add_tab(Some("c")); - ws.switch_tab(1); - } - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - assert_eq!(tab_labels(&state), vec!["a", "c", "b"]); - assert_eq!(state.workspaces[0].active_tab, 2); - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - assert_eq!(tab_labels(&state), vec!["b", "a", "c"]); - assert_eq!(state.workspaces[0].active_tab, 0); - - execute_navigate_action(&mut state, NavigateAction::MoveTabPrevious); - assert_eq!(tab_labels(&state), vec!["a", "c", "b"]); - assert_eq!(state.workspaces[0].active_tab, 2); - state.workspaces[0].assert_invariants_for_test(); - } - - #[test] - fn move_tab_is_a_noop_with_a_single_tab() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].tabs[0].set_custom_name("only".into()); - - execute_navigate_action(&mut state, NavigateAction::MoveTabNext); - - assert_eq!(tab_labels(&state), vec!["only"]); - assert_eq!(state.workspaces[0].active_tab, 0); - } - - #[test] - fn move_tab_with_a_single_tab_still_exits_navigate_mode() { - let event_hub = crate::api::EventHub::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = crate::app::App::new( - &crate::config::Config::default(), - true, - None, - api_rx, - event_hub, - ); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("solo")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::MoveTabNext, ActionContext::Navigate); - - assert_eq!(app.state.workspaces[0].tabs.len(), 1); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.last_pane = crate::config::ActionKeybinds::direct("alt+l"); - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT), - ); - - assert_eq!(action, Some(NavigateAction::LastPane)); - } - - #[test] - fn generated_character_prefix_binding_falls_back_after_exact_chord() { - let generated_key = crate::input::parse_terminal_key_sequence("\x1b[119;3;124u").unwrap(); - assert_eq!(generated_key.code, KeyCode::Char('w')); - assert_eq!(generated_key.modifiers, KeyModifiers::ALT); - assert_eq!(generated_key.generated_text.as_deref(), Some("|")); - - let generated_only: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = generated_only.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Action(NavigateAction::SplitVertical)) - )); - let multi_character_key = - crate::input::parse_terminal_key_sequence("\x1b[119;3;124:120u").unwrap(); - assert!(prefix_binding_for_key(&state, &multi_character_key).is_none()); - - let exact_and_generated: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -split_horizontal = "prefix+alt+w" -"#, - ) - .unwrap(); - state.keybinds = exact_and_generated.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Action(NavigateAction::SplitHorizontal)) - )); - - let exact_command: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" - -[[keys.command]] -key = "prefix+alt+w" -command = "echo exact" -"#, - ) - .unwrap(); - state.keybinds = exact_command.keybinds(); - assert!(matches!( - prefix_binding_for_key(&state, &generated_key), - Some(PrefixBindingMatch::Command(binding)) if binding.command == "echo exact" - )); - } - - #[test] - fn shifted_backslash_layout_prefers_horizontal_split_binding() { - let config: Config = toml::from_str( - r#" -[keys] -split_vertical = "prefix+|" -split_horizontal = 'prefix+\' -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - let key = crate::input::parse_terminal_key_sequence("\x1b[124:92;2:1u").unwrap(); - assert_eq!(key.code, KeyCode::Char('|')); - assert_eq!(key.modifiers, KeyModifiers::SHIFT); - assert_eq!(key.shifted_codepoint, Some('\\' as u32)); - assert!(state.keybinds.split_horizontal.matches_prefix_key(&key)); - assert!(!state.keybinds.split_vertical.matches_prefix_key(&key)); - - assert_eq!( - action_for_key(&state, key, BindingDispatch::Prefix), - Some(NavigateAction::SplitHorizontal) - ); - assert_eq!( - action_for_key( - &state, - TerminalKey::new(KeyCode::Char('|'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ), - Some(NavigateAction::SplitVertical) - ); - } - - #[test] - fn prefix_tab_override_can_map_to_last_pane() { - let config: Config = toml::from_str( - r#" -[keys] -last_pane = "prefix+tab" -"#, - ) - .unwrap(); - let mut state = state_with_workspaces(&["test"]); - state.keybinds = config.keybinds(); - - let pane_action = action_for_key( - &state, - TerminalKey::new(KeyCode::Tab, KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(pane_action, Some(NavigateAction::LastPane)); - } - - #[test] - fn terminal_direct_indexed_tab_shortcut_maps_to_navigation_action() { - let mut state = state_with_workspaces(&["test"]); - let config: Config = toml::from_str("[keys]\nswitch_tab = \"ctrl+3\"\n").unwrap(); - state.keybinds.switch_tab = config.keybinds().switch_tab; - - let action = terminal_direct_navigation_action( - &state, - TerminalKey::new(KeyCode::Char('3'), KeyModifiers::CONTROL), - ); - - assert_eq!(action, Some(NavigateAction::SwitchTab(2))); - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_maps_legacy_us_symbol_key() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - state.keybinds.switch_workspace = config.keybinds().switch_workspace; - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('@'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::SwitchWorkspace(1))); - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_maps_non_us_number_rows() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - state.keybinds.switch_workspace = config.keybinds().switch_workspace; - - for key in [ - TerminalKey::new(KeyCode::Char('2'), KeyModifiers::SHIFT) - .with_shifted_codepoint('"' as u32), - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - ] { - assert_eq!( - action_for_key(&state, key, BindingDispatch::Prefix), - Some(NavigateAction::SwitchWorkspace(1)) - ); - } - } - - #[test] - fn prefix_shift_indexed_workspace_shortcut_survives_modifier_press() { - let mut app = app_with_test_workspaces(&["one", "two"]); - let config: Config = - toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); - app.state.keybinds.switch_workspace = config.keybinds().switch_workspace; - app.state.mode = Mode::Prefix; - - app.handle_prefix_key(TerminalKey::new( - KeyCode::Modifier(ModifierKeyCode::LeftShift), - KeyModifiers::SHIFT, - )); - - assert_eq!(app.state.mode, Mode::Prefix); - - app.handle_prefix_key( - TerminalKey::new(KeyCode::Char('2'), KeyModifiers::SHIFT) - .with_shifted_codepoint('"' as u32), - ); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn prefix_unshifted_indexed_shortcut_maps_shifted_french_number_row() { - let mut state = state_with_workspaces(&["one"]); - let config: Config = toml::from_str("[keys]\nswitch_tab = \"prefix+1..9\"\n").unwrap(); - state.keybinds.switch_tab = config.keybinds().switch_tab; - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::SwitchTab(1))); - } - - #[test] - fn literal_symbol_binding_takes_precedence_over_shifted_indexed_alias() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -help = "prefix+!" -switch_workspace = "prefix+shift+1..9" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - let action = action_for_key( - &state, - TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()), - BindingDispatch::Prefix, - ); - - assert_eq!(action, Some(NavigateAction::Help)); - } - - #[test] - fn literal_symbol_custom_command_is_visible_before_shifted_indexed_alias() { - let mut state = state_with_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -switch_workspace = "prefix+shift+1..9" - -[[keys.command]] -key = "prefix+!" -command = "echo literal" -"#, - ) - .unwrap(); - state.keybinds = config.keybinds(); - - let key = TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()); - assert!(command_for_key(&state, &key, BindingDispatch::Prefix).is_some()); - assert_eq!( - indexed_navigation_action(&state, &key, BindingDispatch::Prefix), - Some(NavigateAction::SwitchWorkspace(0)) - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn literal_symbol_custom_command_runs_before_shifted_indexed_alias() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; - app.state.active = Some(1); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("literal-symbol-custom-command"); - let config: Config = toml::from_str(&format!( - r#" -[keys] -switch_workspace = "prefix+shift+1..9" - -[[keys.command]] -key = "prefix+!" -command = "printf literal > '{}'" -"#, - output_path.display() - )) - .unwrap(); - app.state.keybinds = config.keybinds(); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty())) - .await; - - assert_eq!(wait_for_file(&output_path), "literal"); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - } - - #[tokio::test] - async fn navigate_mode_runs_prefix_action_rhs_without_pressing_prefix_again() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::SHIFT)); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigate_mode_matches_legacy_uppercase_shifted_letter() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty())); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_workspace_binding_over_unshifted() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('W'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - } - - #[tokio::test] - async fn kitty_shifted_alternate_without_modifier_prefers_reload_over_resize() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Prefix; - - let mut events = parse_raw_input_bytes_sync(b"\x1b[114:82;1u"); - assert_eq!(events.len(), 1); - let RawInputEvent::Key(key) = events.remove(0) else { - panic!("expected key event"); - }; - assert_eq!( - action_for_key(&app.state, key.clone(), BindingDispatch::Prefix), - Some(NavigateAction::ReloadConfig) - ); - app.handle_prefix_key(key); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_reload_binding_over_unshifted() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('R'), KeyModifiers::empty())); - - assert!(!app.state.request_reload_config); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn legacy_uppercase_prefers_shifted_pane_binding_over_unshifted() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('P'), KeyModifiers::empty())); - - assert_eq!(app.state.mode, Mode::RenamePane); - } - - #[test] - fn app_navigate_mode_workspace_down_moves_selection() { - let mut app = app_with_test_workspaces(&["one", "two"]); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Down, KeyModifiers::empty())); - - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn app_navigate_mode_maps_french_number_row_to_workspace() { - let mut app = app_with_test_workspaces(&["one", "two"]); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key( - TerminalKey::new(KeyCode::Char('é'), KeyModifiers::SHIFT) - .with_shifted_codepoint('2' as u32), - ); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn app_navigate_mode_workspace_keys_are_configurable() { - let mut app = app_with_test_workspaces(&["one", "two"]); - let config: Config = toml::from_str( - r#" -[keys] -navigate_workspace_down = "j" -navigate_pane_down = "ctrl+j" -"#, - ) - .unwrap(); - app.state.keybinds = config.keybinds(); - app.state.mode = Mode::Navigate; - - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())); - - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[tokio::test] - async fn prefix_focus_pane_is_one_shot_and_returns_to_terminal() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let root = app.state.workspaces[0].tabs[0].root_pane; - let right = app.state.workspaces[0].test_split(Direction::Horizontal); - app.state.workspaces[0].layout.focus_pane(right); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigate_focus_pane_keeps_navigate_mode_active() { - let mut app = app_with_test_workspaces(&["test"]); - let root = app.state.workspaces[0].tabs[0].root_pane; - let below = app.state.workspaces[0].test_split(Direction::Vertical); - app.state.workspaces[0].layout.focus_pane(below); - app.state.view.pane_infos = app.state.workspaces[0] - .active_tab() - .unwrap() - .layout - .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); - app.state.mode = Mode::Navigate; - - app.handle_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[tokio::test] - async fn no_op_prefix_action_exits_prefix_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('o'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn unmatched_prefix_rhs_exits_prefix_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::F(12), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn prefix_help_matches_enhanced_shifted_question_mark() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key( - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) - .with_shifted_codepoint('?' as u32), - ) - .await; - - assert_eq!(app.state.mode, Mode::KeybindHelp); - } - - #[test] - fn navigate_mode_help_is_binding_driven() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.help = crate::config::ActionKeybinds::prefix("f"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), - ); - assert_eq!(state.mode, Mode::Navigate); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), - ); - assert_eq!(state.mode, Mode::KeybindHelp); - } - - #[test] - fn modified_navigate_local_key_can_be_bound_as_prefix_rhs() { - let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+u"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT), - ); - - assert!(state.sidebar_collapsed); - } - - #[test] - fn empty_state_new_tab_is_no_op() { - let mut state = crate::app::state::AppState::test_new(); - let mut terminal_runtimes = TerminalRuntimeRegistry::new(); - state.mode = Mode::Prefix; - - execute_navigate_action_in_context( - &mut state, - &mut terminal_runtimes, - NavigateAction::NewTab, - ActionContext::Prefix, - ); - - assert_eq!(state.mode, Mode::Navigate); - assert!(!state.creating_new_tab); - assert!(!state.request_new_tab); - assert!(state.workspaces.is_empty()); - } - - #[test] - fn closing_linked_worktree_closes_workspace_without_removing_checkout() { - let mut state = state_with_workspaces(&["main", "issue"]); - state.selected = 1; - state.active = Some(1); - state.mode = Mode::Navigate; - state.confirm_close = false; - state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-issue".into(), - is_linked_worktree: true, - }); - - execute_navigate_action(&mut state, NavigateAction::CloseWorkspace); - - assert_eq!(state.request_remove_linked_worktree, None); - assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].display_name(), "main"); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn tui_close_parent_group_closes_immediately_when_confirmation_disabled() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - app.state.confirm_close = false; - - app.execute_tui_navigate_action(NavigateAction::CloseWorkspace, ActionContext::Navigate); - - assert!(app.state.workspaces.is_empty()); - assert_eq!(app.state.mode, Mode::Navigate); - assert_eq!(app.event_hub.events_after(0).len(), 2); - } - - #[test] - fn prefix_close_pane_last_parent_group_pane_opens_confirmation() { - let mut state = state_with_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut state, 0, "repo-key"); - mark_worktree_space_member(&mut state, 1, "repo-key"); - state.selected = 1; - state.active = Some(0); - state.mode = Mode::Navigate; - - execute_navigate_action(&mut state, NavigateAction::ClosePane); - - assert_eq!(state.selected, 0); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.workspaces.len(), 2); - } - - #[test] - fn tui_close_tab_last_parent_group_workspace_opens_confirmation_via_api() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::CloseTab, ActionContext::Navigate); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - } - - #[test] - fn tui_close_pane_last_parent_group_pane_opens_confirmation_via_api() { - let mut app = app_with_test_workspaces(&["main", "issue"]); - mark_worktree_space_member(&mut app.state, 0, "repo-key"); - mark_worktree_space_member(&mut app.state, 1, "repo-key"); - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::Navigate; - - app.execute_tui_navigate_action(NavigateAction::ClosePane, ActionContext::Navigate); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - } - - #[cfg(unix)] - #[tokio::test] - async fn custom_command_runs_from_prefix_key_in_navigate_mode() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("custom-command-keybind"); - let release_path = unique_temp_path("custom-command-release"); - let command = format!( - "printf '%s\\n%s\\n%s\\n%s\\n' \"$$\" \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'; i=0; while [ ! -e '{}' ] && [ \"$i\" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done", - output_path.display(), - release_path.display(), - ); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("m"), - label: "prefix+m".into(), - command, - action: crate::config::CustomCommandAction::Shell, - description: None, - width: None, - height: None, - }]; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - assert_eq!(app.state.mode, Mode::Prefix); - - let launch_started = std::time::Instant::now(); - app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) - .await; - assert!(launch_started.elapsed() < Duration::from_secs(2)); - - let content = wait_for_file(&output_path); - let lines: Vec<&str> = content.lines().collect(); - assert_eq!(lines.len(), 4); - let pid = lines[0] - .parse::<u32>() - .expect("command should report its pid"); - assert!(crate::platform::process_exists(pid)); - assert_eq!(lines[1], app.state.workspaces[0].id); - assert_eq!(lines[2], format!("{}:t1", app.state.workspaces[0].id)); - assert_eq!(lines[3], format!("{}:p1", app.state.workspaces[0].id)); - assert_eq!(app.state.mode, Mode::Terminal); - - std::fs::write(&release_path, b"release").expect("release command"); - let reaped_by_runtime = wait_for_detached_process_reap(&mut app, pid).await; - if !reaped_by_runtime { - if let Some(child) = app - .detached_process_children - .iter_mut() - .find(|child| child.id() == pid) - { - let _ = child.kill(); - let _ = child.wait(); - } - } - assert!( - reaped_by_runtime, - "detached command child {pid} was not reaped" - ); - - let _ = std::fs::remove_file(output_path); - let _ = std::fs::remove_file(release_path); - } - - #[cfg(unix)] - #[tokio::test] - async fn pane_overlay_command_opens_and_closes_after_exit() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - let (workspace, terminal, runtime) = Workspace::new( - std::env::current_dir().unwrap_or_else(|_| "/".into()), - 24, - 80, - app.state.pane_scrollback_limit_bytes, - app.state.host_terminal_theme, - app.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), - app.event_tx.clone(), - app.render_notify.clone(), - app.render_dirty.clone(), - ) - .expect("workspace should spawn"); - let root_pane = workspace.tabs[0].root_pane; - app.state.workspaces = vec![workspace]; - app.terminal_runtimes.insert(terminal.id.clone(), runtime); - app.state.terminals.insert(terminal.id.clone(), terminal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("custom-pane-command"); - let command = format!("printf done > '{}'", output_path.display()); - app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - bindings: crate::config::ActionKeybinds::prefix("m"), - label: "prefix+m".into(), - command, - action: crate::config::CustomCommandAction::Pane, - description: None, - width: None, - height: None, - }]; - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty())) - .await; - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); - assert_eq!(app.terminal_runtimes.len(), 2); - assert!(app.state.workspaces[0].tabs[0].zoomed); - let overlay_pane = app.state.workspaces[0].focused_pane_id().unwrap(); - assert_ne!(overlay_pane, root_pane); - - app.state.last_pane(); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root_pane)); - - app.state.last_pane(); - - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(overlay_pane) - ); - - let _ = wait_for_file(&output_path); - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if app.drain_internal_events() - && app.state.workspaces[0].tabs[0].layout.pane_count() == 1 - { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(!app.state.workspaces[0].tabs[0].zoomed); - assert_eq!(app.state.mode, Mode::Terminal); - let _ = std::fs::remove_file(output_path); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - } - - #[cfg(unix)] - #[tokio::test] - async fn edit_scrollback_key_preserves_logical_lines_in_editor_pane() { - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - true, - None, - api_rx, - crate::api::EventHub::default(), - ); - let mut workspace = Workspace::test_new("test"); - let root_pane = workspace.tabs[0].root_pane; - workspace.tabs[0].runtimes.insert( - root_pane, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - 5, - 5, - 4096, - b"ABCDEFGHIJ\r\nKLMNO", - ), - ); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let output_path = unique_temp_path("edit-scrollback"); - let previous_editor = std::env::var_os("EDITOR"); - std::env::set_var( - "EDITOR", - format!("sh -c 'cp \"$1\" {}' sh", output_path.display()), - ); - app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::prefix("g"); - - app.handle_key(TerminalKey::new( - app.state.prefix_code, - app.state.prefix_mods, - )) - .await; - app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) - .await; - - match previous_editor { - Some(value) => std::env::set_var("EDITOR", value), - None => std::env::remove_var("EDITOR"), - } - - let content = wait_for_file(&output_path); - assert_eq!(content, "ABCDEFGHIJ\nKLMNO"); - assert_eq!(app.state.mode, Mode::Terminal); - assert!( - app.state.terminals.values().any(|terminal| terminal - .launch_argv - .as_ref() - .is_some_and(|argv| argv.first().is_some_and(|program| program == "/bin/sh"))), - "scrollback editor should launch through argv overlay path" - ); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn zoom_action_exits_navigate_mode() { - let mut state = state_with_workspaces(&["test"]); - state.workspaces[0].test_split(Direction::Horizontal); - state.keybinds.zoom = crate::config::ActionKeybinds::prefix("g"); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), - ); - - assert!(state.workspaces[0].zoomed); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn focus_pane_action_keeps_zoomed_when_changing_focus() { - let mut state = state_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - state.workspaces[0].layout.focus_pane(root); - state.workspaces[0].zoomed = true; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); - - execute_navigate_action(&mut state, NavigateAction::FocusPaneRight); - - assert!(state.workspaces[0].zoomed); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - } - - #[test] - fn question_mark_opens_keybind_help_from_navigate() { - let mut state = state_with_workspaces(&["test"]); - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), - ); - - assert_eq!(state.mode, Mode::KeybindHelp); - } - - #[test] - fn new_tab_action_opens_dialog_without_creating_tab() { - let mut state = state_with_workspaces(&["test"]); - - execute_navigate_action(&mut state, NavigateAction::NewTab); - - assert_eq!(state.mode, Mode::RenameTab); - assert!(state.creating_new_tab); - assert_eq!(state.name_input, "2"); - assert!(state.name_input_replace_on_type); - assert!(!state.request_new_tab); - assert_eq!(state.workspaces[0].tabs.len(), 1); - } - - #[test] - fn new_tab_action_can_skip_rename_dialog() { - let mut state = state_with_workspaces(&["test"]); - state.prompt_new_tab_name = false; - - execute_navigate_action(&mut state, NavigateAction::NewTab); - - assert_eq!(state.mode, Mode::Terminal); - assert!(!state.creating_new_tab); - assert!(state.request_new_tab); - assert!(state.requested_new_tab_name.is_none()); - } - - #[test] - fn navigate_q_detaches_in_persistence_mode() { - let mut state = crate::app::state::AppState::test_new(); - state.detach_exits = false; - - handle_navigate_key( - &mut state, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()), - ); - - assert!(state.detach_requested); - assert!(!state.should_quit); - } -} diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs deleted file mode 100644 index 58f2ef71..00000000 --- a/src/app/input/overlays.rs +++ /dev/null @@ -1,831 +0,0 @@ -use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::{ - layout::Rect, - widgets::{Block, Borders}, -}; - -use crate::app::{ - state::{AppState, DragState, DragTarget, Mode, NavigatorTarget}, - App, -}; - -use super::{ - modal::{keybind_help_back, leave_modal, modal_action_from_buttons, ModalAction}, - ScrollbarClickTarget, -}; - -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height -} - -impl App { - pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool { - if self.state.mode == Mode::ReleaseNotes { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .release_notes_close_button_at(mouse.column, mouse.row) => - { - self.dismiss_release_notes(); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .release_notes_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.state - .set_release_notes_offset_from_bottom(offset_from_bottom); - } - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::ReleaseNotesScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_release_notes_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.scroll_release_notes(-3), - MouseEventKind::ScrollDown => self.scroll_release_notes(3), - _ => {} - } - return true; - } - - if self.state.mode == Mode::ProductAnnouncement { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .product_announcement_close_button_at(mouse.column, mouse.row) => - { - self.dismiss_product_announcement(); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .product_announcement_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::ProductAnnouncementScrollbar { - grab_row_offset, - }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => self - .state - .set_product_announcement_offset_from_bottom(offset_from_bottom), - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::ProductAnnouncementScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .product_announcement_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_product_announcement_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.scroll_product_announcement(-3), - MouseEventKind::ScrollDown => self.scroll_product_announcement(3), - _ => {} - } - return true; - } - - if self.state.mode == Mode::Navigator { - match mouse.kind { - MouseEventKind::Moved => { - if let Some(idx) = self.state.navigator_row_index_at_from( - &self.terminal_runtimes, - mouse.column, - mouse.row, - ) { - self.state.navigator.selected = idx; - self.state - .ensure_navigator_selection_visible_from(&self.terminal_runtimes); - } - } - MouseEventKind::Down(MouseButton::Left) => { - if self - .state - .navigator_search_contains(mouse.column, mouse.row) - { - self.state.navigator.search_focused = true; - } else if let Some(idx) = self.state.navigator_row_index_at_from( - &self.terminal_runtimes, - mouse.column, - mouse.row, - ) { - self.state.navigator.selected = idx; - let target = self - .state - .navigator_rows_from(&self.terminal_runtimes) - .get(idx) - .map(|row| (row.target.clone(), row.is_workspace)); - if let Some((NavigatorTarget::Workspace { .. }, true)) = target { - if self.state.navigator_row_caret_at(mouse.column) { - self.state.toggle_selected_navigator_workspace_from( - &self.terminal_runtimes, - ); - } else { - self.state - .accept_navigator_selection_from(&self.terminal_runtimes); - } - } else { - self.state - .accept_navigator_selection_from(&self.terminal_runtimes); - } - } else if !self.state.navigator_popup_contains(mouse.column, mouse.row) { - leave_modal(&mut self.state); - } - } - MouseEventKind::ScrollUp => { - self.state.navigator.scroll = self.state.navigator.scroll.saturating_sub(3); - self.state - .align_navigator_selection_to_scroll_from(&self.terminal_runtimes); - } - MouseEventKind::ScrollDown => { - let viewport = self.state.navigator_body_rect().height as usize; - let max = self - .state - .navigator_max_scroll_from(&self.terminal_runtimes, viewport); - self.state.navigator.scroll = - self.state.navigator.scroll.saturating_add(3).min(max); - self.state - .align_navigator_selection_to_scroll_from(&self.terminal_runtimes); - } - _ => {} - } - return true; - } - - if self.state.mode == Mode::KeybindHelp { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) - if self - .state - .keybind_help_close_button_at(mouse.column, mouse.row) => - { - keybind_help_back(&mut self.state); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = self - .state - .keybind_help_scrollbar_target_at(mouse.column, mouse.row) - { - match target { - ScrollbarClickTarget::Thumb { grab_row_offset } => { - self.state.drag = Some(DragState { - target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, - }); - } - ScrollbarClickTarget::Track { offset_from_bottom } => { - self.state - .set_keybind_help_offset_from_bottom(offset_from_bottom); - } - } - } else { - let rect = self.state.keybind_help_popup_rect(); - let inside = mouse.column >= rect.x - && mouse.column < rect.x + rect.width - && mouse.row >= rect.y - && mouse.row < rect.y + rect.height; - if !inside { - leave_modal(&mut self.state); - } - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(DragState { - target: DragTarget::KeybindHelpScrollbar { grab_row_offset }, - }) = &self.state.drag - { - if let Some(offset_from_bottom) = self - .state - .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset) - { - self.state - .set_keybind_help_offset_from_bottom(offset_from_bottom); - } - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.state.drag = None; - } - MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3), - MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3), - _ => {} - } - return true; - } - - false - } -} - -impl AppState { - pub(super) fn onboarding_full_area(&self) -> Rect { - self.view.sidebar_rect.union(self.view.terminal_area) - } - - pub(crate) fn navigator_popup_rect(&self) -> Rect { - let area = self.onboarding_full_area(); - let margin_x = (area.width / 16).max(2); - let margin_y = (area.height / 10).max(1); - let width = area.width.saturating_sub(margin_x.saturating_mul(2)); - let height = area.height.saturating_sub(margin_y.saturating_mul(2)); - Rect::new( - area.x + margin_x, - area.y + margin_y, - width.max(4), - height.max(4), - ) - } - - pub(crate) fn navigator_inner_rect(&self) -> Rect { - Block::default() - .borders(Borders::ALL) - .inner(self.navigator_popup_rect()) - } - - pub(crate) fn navigator_search_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new(inner.x, inner.y, inner.width, inner.height.min(1)) - } - - pub(crate) fn navigator_body_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - if inner.height <= 4 { - return Rect::default(); - } - Rect::new( - inner.x, - inner.y + 2, - inner.width, - inner.height.saturating_sub(4), - ) - } - - pub(crate) fn navigator_detail_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(2), - inner.width, - inner.height.min(1), - ) - } - - pub(crate) fn navigator_footer_rect(&self) -> Rect { - let inner = self.navigator_inner_rect(); - Rect::new( - inner.x, - inner.y + inner.height.saturating_sub(1), - inner.width, - inner.height.min(1), - ) - } - - pub(crate) fn navigator_popup_contains(&self, col: u16, row: u16) -> bool { - rect_contains(self.navigator_popup_rect(), col, row) - } - - pub(crate) fn navigator_search_contains(&self, col: u16, row: u16) -> bool { - rect_contains(self.navigator_search_rect(), col, row) - } - - pub(crate) fn navigator_row_index_at_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - col: u16, - row: u16, - ) -> Option<usize> { - let body = self.navigator_body_rect(); - if !rect_contains(body, col, row) { - return None; - } - let line_idx = self - .navigator - .scroll - .saturating_add(row.saturating_sub(body.y) as usize); - let lines = crate::app::state::navigator_display_lines( - &self.navigator_rows_from(terminal_runtimes), - ); - match lines.get(line_idx) { - Some(crate::app::state::NavigatorDisplayLine::Row(idx)) => Some(*idx), - _ => None, - } - } - - pub(crate) fn navigator_row_caret_at(&self, col: u16) -> bool { - let body = self.navigator_body_rect(); - col <= body.x.saturating_add(3) - } - - pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option<Rect> { - let area = self.onboarding_full_area(); - let popup_w = popup_w.min(area.width.saturating_sub(4)); - let popup_h = popup_h.min(area.height.saturating_sub(2)); - if popup_w < 4 || popup_h < 4 { - return None; - } - let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; - let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); - Some(Block::default().borders(Borders::ALL).inner(popup)) - } - - fn release_notes_modal_inner(&self) -> Option<Rect> { - self.onboarding_modal_inner( - crate::ui::RELEASE_NOTES_MODAL_SIZE.0, - crate::ui::RELEASE_NOTES_MODAL_SIZE.1, - ) - } - - fn product_announcement_modal_inner(&self) -> Option<Rect> { - self.onboarding_modal_inner( - crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0, - crate::ui::PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1, - ) - } - - fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.release_notes_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - pub(super) fn rename_modal_inner(&self) -> Option<Rect> { - self.onboarding_modal_inner(56, 7) - } - - fn release_notes_body_rect(&self) -> Option<Rect> { - let inner = self.release_notes_modal_inner()?; - if inner.height < 8 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { - let notes = self.release_notes.as_ref()?; - let body = self.release_notes_body_rect()?; - let viewport_rows = body.height.max(1) as usize; - let lines = crate::ui::release_notes_display_lines( - notes, - &self.update_install_command, - &self.palette, - ); - - let rows_for_width = |wrap_width: u16| { - crate::ui::release_notes_wrapped_line_count(&lines, wrap_width.max(1)) - }; - - let full_width = body.width.max(1); - let mut total_rows = rows_for_width(full_width); - let wrap_width = if total_rows > viewport_rows && full_width > 1 { - body.width.saturating_sub(1).max(1) - } else { - full_width - }; - total_rows = rows_for_width(wrap_width); - - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - Some(crate::pane::ScrollMetrics { - offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize), - max_offset_from_bottom, - viewport_rows, - }) - } - - pub(crate) fn release_notes_max_scroll(&self) -> u16 { - self.release_notes_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn release_notes_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option<ScrollbarClickTarget> { - let body = self.release_notes_body_rect()?; - let metrics = self.release_notes_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> { - let body = self.release_notes_body_rect()?; - let metrics = self.release_notes_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.release_notes_max_scroll() as usize; - if let Some(notes) = &mut self.release_notes { - notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - } - - fn product_announcement_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.product_announcement_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - fn product_announcement_body_rect(&self) -> Option<Rect> { - let inner = self.product_announcement_modal_inner()?; - if inner.height < 8 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn product_announcement_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { - let announcement = self.product_announcement.as_ref()?; - let body = self.product_announcement_body_rect()?; - let viewport_rows = body.height.max(1) as usize; - let lines = crate::ui::product_announcement_display_lines(announcement, &self.palette); - - let rows_for_width = |wrap_width: u16| { - crate::ui::release_notes_wrapped_line_count(&lines, wrap_width.max(1)) - }; - - let full_width = body.width.max(1); - let mut total_rows = rows_for_width(full_width); - let wrap_width = if total_rows > viewport_rows && full_width > 1 { - body.width.saturating_sub(1).max(1) - } else { - full_width - }; - total_rows = rows_for_width(wrap_width); - - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - Some(crate::pane::ScrollMetrics { - offset_from_bottom: max_offset_from_bottom.saturating_sub(announcement.scroll as usize), - max_offset_from_bottom, - viewport_rows, - }) - } - - pub(crate) fn product_announcement_max_scroll(&self) -> u16 { - self.product_announcement_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn product_announcement_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option<ScrollbarClickTarget> { - let body = self.product_announcement_body_rect()?; - let metrics = self.product_announcement_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn product_announcement_offset_for_drag_row( - &self, - row: u16, - grab_row_offset: u16, - ) -> Option<usize> { - let body = self.product_announcement_body_rect()?; - let metrics = self.product_announcement_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - fn set_product_announcement_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.product_announcement_max_scroll() as usize; - if let Some(announcement) = &mut self.product_announcement { - announcement.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - } - - pub(super) fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) { - if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return; - } - - let Some(inner) = self.onboarding_modal_inner(64, 16) else { - return; - }; - let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) - .actions - .unwrap_or_default(); - let button = crate::ui::onboarding_welcome_continue_rect(actions); - if modal_action_from_buttons(mouse.column, mouse.row, &[(button, ModalAction::Continue)]) - == Some(ModalAction::Continue) - { - self.request_complete_onboarding = true; - } - } - - pub(super) fn keybind_help_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() - } - - fn keybind_help_modal_inner(&self) -> Option<Rect> { - self.onboarding_modal_inner(76, 22) - } - - fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool { - let Some(inner) = self.keybind_help_modal_inner() else { - return false; - }; - if inner.height < 4 || inner.width < 12 { - return false; - } - let button = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - col >= button.x - && col < button.x + button.width - && row >= button.y - && row < button.y + button.height - } - - fn keybind_help_body_rect(&self) -> Option<Rect> { - let inner = self.keybind_help_modal_inner()?; - if inner.height < 6 || inner.width < 4 { - return None; - } - Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content) - } - - fn keybind_help_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> { - let body = self.keybind_help_body_rect()?; - let viewport_rows = body.height.max(1) as usize; - let wrap_width = body.width.max(1) as usize; - let total_rows = crate::ui::keybind_help_lines(self) - .into_iter() - .map(|(width, _)| width.max(1).div_ceil(wrap_width)) - .sum::<usize>(); - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - Some(crate::pane::ScrollMetrics { - offset_from_bottom: max_offset_from_bottom - .saturating_sub(self.keybind_help.scroll as usize), - max_offset_from_bottom, - viewport_rows, - }) - } - - fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option<ScrollbarClickTarget> { - let body = self.keybind_help_body_rect()?; - let metrics = self.keybind_help_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - if !(col >= track.x - && col < track.x + track.width - && row >= track.y - && row < track.y + track.height) - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> { - let body = self.keybind_help_body_rect()?; - let metrics = self.keybind_help_scroll_metrics()?; - let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - pub(crate) fn keybind_help_max_scroll(&self) -> u16 { - self.keybind_help_scroll_metrics() - .map(|metrics| metrics.max_offset_from_bottom as u16) - .unwrap_or(0) - } - - fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let max_scroll = self.keybind_help_max_scroll() as usize; - self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16; - } - - pub(super) fn scroll_keybind_help(&mut self, delta: i16) { - let max_scroll = self.keybind_help_max_scroll(); - let current = self.keybind_help.scroll as i16; - self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16; - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - use super::super::{app_for_mouse_test, mouse}; - use super::*; - - #[test] - fn clicking_keybind_help_close_button_closes_overlay() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::KeybindHelp; - - let rect = app.state.keybind_help_popup_rect(); - let inner = Rect::new( - rect.x + 1, - rect.y + 1, - rect.width.saturating_sub(2), - rect.height.saturating_sub(2), - ); - let close = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - close.x, - close.y, - )); - - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn clicking_keybind_help_back_button_leaves_help_open() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::KeybindHelp; - app.state.keybind_help.search_focused = true; - app.state.keybind_help.query = "work".into(); - - let rect = app.state.keybind_help_popup_rect(); - let inner = Rect::new( - rect.x + 1, - rect.y + 1, - rect.width.saturating_sub(2), - rect.height.saturating_sub(2), - ); - let back = - crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1)); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - back.x, - back.y, - )); - - assert_eq!(app.state.mode, Mode::KeybindHelp); - assert!(!app.state.keybind_help.search_focused); - assert!(app.state.keybind_help.query.is_empty()); - } - - #[test] - fn onboarding_hover_does_not_change_selection() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::Onboarding; - - let inner = app.state.onboarding_modal_inner(64, 16).unwrap(); - let content = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1).content; - app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y)); - - assert!(!app.state.request_complete_onboarding); - } - - #[test] - fn onboarding_click_continue_requests_completion() { - let mut app = app_for_mouse_test(); - app.state.mode = Mode::Onboarding; - - let inner = app.state.onboarding_modal_inner(64, 16).unwrap(); - let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) - .actions - .unwrap(); - let continue_rect = crate::ui::onboarding_welcome_continue_rect(actions); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - continue_rect.x, - continue_rect.y, - )); - - assert!(app.state.request_complete_onboarding); - } - - #[test] - fn release_notes_preview_scrollbar_uses_full_content_body() { - let mut app = app_for_mouse_test(); - app.state.view.sidebar_rect = Rect::new(0, 0, 24, 16); - app.state.view.terminal_area = Rect::new(24, 0, 96, 16); - app.state.release_notes = Some(crate::app::state::ReleaseNotesState { - version: "9.9.9".into(), - body: "### Added\n- Custom command keybindings now accept an optional description field.\n\n### Fixed\n- Sidebar Git status refresh now deduplicates workspaces.\n- Large restored sessions no longer leave panes without shells after startup.\n- Pane shutdown no longer warns after the direct child has already exited.\n- Closing the last pane or tab in a parent worktree workspace now shows the existing confirmation before closing the whole worktree group.\n- Update prompts, toasts, and docs now distinguish installing a new binary from stopping or reattaching a running Herdr session to use it." - .into(), - scroll: 0, - preview: true, - }); - app.state.update_install_command = "brew update && brew upgrade herdr".into(); - - let inner = app.state.release_notes_modal_inner().unwrap(); - let expected_body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content; - let body = app.state.release_notes_body_rect().unwrap(); - - assert_eq!(body, expected_body); - - let metrics = app.state.release_notes_scroll_metrics().unwrap(); - assert_eq!(metrics.viewport_rows, body.height as usize); - assert!(metrics.max_offset_from_bottom > 0); - - let track = crate::ui::release_notes_scrollbar_rect(body, metrics).unwrap(); - assert_eq!(track.y, body.y); - assert!(matches!( - app.state - .release_notes_scrollbar_target_at(track.x, track.y), - Some(ScrollbarClickTarget::Thumb { .. } | ScrollbarClickTarget::Track { .. }) - )); - } -} diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs deleted file mode 100644 index a66509f0..00000000 --- a/src/app/input/selection.rs +++ /dev/null @@ -1,306 +0,0 @@ -use crossterm::event::{MouseEvent, MouseEventKind}; - -use crate::{ - app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection}, - terminal::TerminalRuntimeRegistry, -}; - -impl AppState { - pub(crate) fn update_selection_cursor( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - screen_col: u16, - screen_row: u16, - ) { - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - if let Some(selection) = self.selection.as_mut() { - selection.drag(screen_col, screen_row, info.inner_rect, metrics); - } - } - - fn selection_edge_scroll_lines(distance: u16) -> usize { - usize::from(distance).saturating_mul(3).clamp(3, 15) - } - - pub(super) fn update_selection_drag( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - screen_col: u16, - screen_row: u16, - ) { - let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else { - return; - }; - let Some(info) = self.pane_info_by_id(pane_id).cloned() else { - return; - }; - - let top = info.inner_rect.y; - let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1); - - // Only activate autoscroll when the user is actively dragging. - // An anchored click in the hot zone should not start the timer. - // Check before advancing the cursor: if already Dragging from a prior - // event, it stays true. If Anchored, the mouse must have moved away - // from the anchor cell for this to count as a real drag. - let was_dragging = self.selection.as_ref().is_some_and(|s| s.is_dragging()); - let anchor_differs_from_mouse = self.selection.as_ref().is_some_and(|s| { - // Convert anchor to screen coords for comparison. - // Anchor is stored in absolute row; for a simple screen - // comparison, check whether the mouse is on a different - // cell than the anchor's screen position. - let (ar, ac) = s.anchor_screen_pos( - info.inner_rect, - self.pane_scroll_metrics(terminal_runtimes, s.pane_id), - ); - ar != screen_row || ac != screen_col - }); - let is_dragging = was_dragging || anchor_differs_from_mouse; - - // Advance the selection cursor. - self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); - - // If the mouse is on a different cell than the anchor but drag() - // didn't transition (cursor clamped to edge == anchor), force - // Dragging so the selection becomes visible and autoscroll can run. - if is_dragging { - if let Some(sel) = self.selection.as_mut() { - if sel.is_just_click() { - sel.force_dragging(); - } - } - } - - if screen_row < top { - // Cursor above pane — immediate scroll + set autoscroll state - if is_dragging { - self.scroll_pane_up( - terminal_runtimes, - pane_id, - Self::selection_edge_scroll_lines(top - screen_row), - ); - // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Up, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } - } else if screen_row > bottom { - // Cursor below pane — immediate scroll + set autoscroll state - if is_dragging { - self.scroll_pane_down( - terminal_runtimes, - pane_id, - Self::selection_edge_scroll_lines(screen_row - bottom), - ); - // Re-advance cursor after scroll so it reflects the new viewport position - self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row); - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Down, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } - } else if screen_row == top { - // Hot zone: top edge row — no immediate scroll, set autoscroll state - if is_dragging { - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Up, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } else { - self.selection_autoscroll = None; - } - } else if screen_row == bottom { - // Hot zone: bottom edge row — no immediate scroll, set autoscroll state - if is_dragging { - self.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Down, - last_mouse_screen_col: screen_col, - last_mouse_screen_row: screen_row, - inner_rect: info.inner_rect, - }); - } else { - self.selection_autoscroll = None; - } - } else { - // Safe zone: inside pane, not on edge rows — clear autoscroll - self.selection_autoscroll = None; - } - } - - pub(super) fn scroll_selection_with_wheel( - &mut self, - terminal_runtimes: &TerminalRuntimeRegistry, - mouse: MouseEvent, - ) -> bool { - let lines_per_notch = self.mouse_scroll_lines; - - let Some(selection) = self.selection.as_ref() else { - return false; - }; - if !selection.is_in_progress() { - return false; - } - let pane_id = selection.pane_id; - self.focus_pane(pane_id); - match mouse.kind { - MouseEventKind::ScrollUp => { - self.scroll_pane_up(terminal_runtimes, pane_id, lines_per_notch) - } - MouseEventKind::ScrollDown => { - self.scroll_pane_down(terminal_runtimes, pane_id, lines_per_notch) - } - _ => return false, - } - self.update_selection_cursor(terminal_runtimes, pane_id, mouse.column, mouse.row); - true - } -} - -#[cfg(test)] -mod autoscroll_tests { - use super::*; - use crate::layout::PaneInfo; - use crate::terminal::TerminalRuntimeRegistry; - use crate::workspace::Workspace; - use ratatui::layout::Rect; - - /// Build an AppState with one workspace/pane and pane_infos populated - /// so pane_info_by_id works. Returns (state, pane_id). - fn make_state_with_pane() -> (AppState, crate::layout::PaneId) { - let mut state = AppState::test_new(); - let ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - state.workspaces.push(ws); - state.active = Some(0); - state.view.pane_infos.push(PaneInfo { - id: pane_id, - rect: Rect::new(0, 0, 80, 24), - inner_rect: Rect::new(0, 0, 80, 24), - scrollbar_rect: None, - borders: ratatui::widgets::Borders::NONE, - is_focused: true, - }); - (state, pane_id) - } - - #[test] - fn above_pane_sets_autoscroll_up() { - // Build state with pane starting at row 5 so we can drag above it - let mut state = AppState::test_new(); - let ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - state.workspaces.push(ws); - state.active = Some(0); - state.view.pane_infos.push(PaneInfo { - id: pane_id, - rect: Rect::new(0, 5, 80, 24), - inner_rect: Rect::new(0, 5, 80, 24), - scrollbar_rect: None, - borders: ratatui::widgets::Borders::NONE, - is_focused: true, - }); - // Anchor at (5, 10), drag to different cell above pane - let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); - sel.drag(4, 5, Rect::new(0, 5, 80, 24), None); - state.selection = Some(sel); - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 5, 4); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); - } - - #[test] - fn top_hot_zone_sets_autoscroll_up_on_drag() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (5, 10), drag to top edge row (row 0) — different cell - let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None); - sel.drag(0, 0, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 0); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up); - } - - #[test] - fn top_hot_zone_clears_autoscroll_on_click() { - // An anchored click on the top edge row should NOT start autoscroll. - let (mut state, pane_id) = make_state_with_pane(); - state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); - // Same-cell drag on top edge row — still anchored - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 0); - assert!(state.selection_autoscroll.is_none()); - } - - #[test] - fn bottom_hot_zone_sets_autoscroll_down_on_drag() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (0, 0), drag to bottom edge row (row 23) — different cell - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(23, 0, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 23); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); - } - - #[test] - fn bottom_hot_zone_clears_autoscroll_on_click() { - // An anchored click on the bottom edge row should NOT start autoscroll. - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at bottom edge row - state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None)); - // Same-cell drag — still anchored - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 23); - assert!(state.selection_autoscroll.is_none()); - } - - #[test] - fn below_pane_sets_autoscroll_down_on_drag() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (0, 0), drag to different cell below pane - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - // Drag cursor one row below the pane bottom - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 0, 24); - let autoscroll = state.selection_autoscroll.as_ref().unwrap(); - assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down); - } - - #[test] - fn safe_zone_clears_autoscroll() { - let (mut state, pane_id) = make_state_with_pane(); - // Anchor at (0, 0), drag to (5, 5) so it's truly dragging - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(5, 5, Rect::new(0, 0, 80, 24), None); - state.selection = Some(sel); - // Set autoscroll first - state.selection_autoscroll = Some(SelectionAutoscroll { - direction: SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 5, - last_mouse_screen_row: 23, - inner_rect: Rect::new(0, 0, 80, 24), - }); - // Move cursor into safe zone (middle of pane, not on edge rows) - let terminal_runtimes = TerminalRuntimeRegistry::new(); - state.update_selection_drag(&terminal_runtimes, 5, 12); - assert!(state.selection_autoscroll.is_none()); - } -} diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs deleted file mode 100644 index fb1a7d75..00000000 --- a/src/app/input/settings.rs +++ /dev/null @@ -1,693 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; -use ratatui::layout::Rect; - -use crate::{ - app::{ - state::{AppState, SettingsSection, THEME_NAMES}, - App, Mode, - }, - config::{StatusIndicatorStyle, ToastDelivery}, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -// The shared `Save` verb is semantic: these actions persist settings. -#[allow(clippy::enum_variant_names)] -pub(super) enum SettingsAction { - SaveTheme(String), - SaveStatusIndicators(StatusIndicatorStyle), - SaveSound(bool), - SaveToastDelivery(ToastDelivery), - SaveAgentBorderLabels(bool), - InstallRecommendedIntegrations, -} - -impl App { - pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) { - let previous_section = self.state.settings.section; - if let Some(action) = update_settings_state(&mut self.state, key) { - 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() - } - } - } - if previous_section != SettingsSection::Integrations - && self.state.settings.section == SettingsSection::Integrations - { - self.refresh_integration_recommendations(); - } - } -} - -fn normalize_theme_name(name: &str) -> String { - name.to_lowercase().replace([' ', '_'], "-") -} - -fn current_theme_index(theme_name: &str) -> usize { - let normalized = normalize_theme_name(theme_name); - THEME_NAMES - .iter() - .position(|name| normalize_theme_name(name) == normalized) - .unwrap_or(0) -} - -fn status_indicator_index(style: StatusIndicatorStyle) -> usize { - match style { - StatusIndicatorStyle::Dots => 0, - StatusIndicatorStyle::Symbols => 1, - } -} - -fn status_indicator_for_index(idx: usize) -> StatusIndicatorStyle { - if idx == 0 { - StatusIndicatorStyle::Dots - } else { - StatusIndicatorStyle::Symbols - } -} - -fn toast_delivery_index(delivery: ToastDelivery) -> usize { - match delivery { - ToastDelivery::Off => 0, - ToastDelivery::Herdr => 1, - ToastDelivery::Terminal => 2, - ToastDelivery::System => 3, - } -} - -fn toast_delivery_for_index(idx: usize) -> ToastDelivery { - match idx { - 0 => ToastDelivery::Off, - 1 => ToastDelivery::Herdr, - 2 => ToastDelivery::Terminal, - _ => ToastDelivery::System, - } -} - -fn preview_selected_theme(state: &mut AppState) { - use crate::app::state::Palette; - - let name = THEME_NAMES[state.settings.list.selected]; - if let Some(mut palette) = Palette::from_name(name) { - if let Some(custom) = &state.theme_runtime.custom { - palette = palette.with_overrides(custom); - } - if let Some(accent) = &state.theme_runtime.legacy_accent { - palette.accent = crate::config::parse_color(accent); - } - state.palette = palette; - state.theme_name = name.to_string(); - } -} - -fn cancel_settings(state: &mut AppState) { - if let Some(palette) = state.settings.original_palette.take() { - state.palette = palette; - } - if let Some(theme_name) = state.settings.original_theme.take() { - state.theme_name = theme_name; - } - super::modal::leave_modal(state); -} - -fn integrations_need_install(state: &AppState) -> bool { - state - .integration_recommendations - .iter() - .any(crate::integration::IntegrationRecommendation::needs_install) -} - -fn apply_settings(state: &mut AppState) -> Option<SettingsAction> { - match state.settings.section { - SettingsSection::Theme => { - let theme_name = state.theme_name.clone(); - state.settings.original_palette = None; - state.settings.original_theme = None; - super::modal::leave_modal(state); - Some(SettingsAction::SaveTheme(theme_name)) - } - SettingsSection::Integrations if integrations_need_install(state) => { - Some(SettingsAction::InstallRecommendedIntegrations) - } - SettingsSection::Integrations => None, - _ => { - super::modal::leave_modal(state); - None - } - } -} - -pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<SettingsAction> { - match state.settings.section { - SettingsSection::Theme => match key.code { - KeyCode::Up | KeyCode::Char('k') => { - let previous = state.settings.list.selected; - state.settings.list.move_prev(); - if state.settings.list.selected != previous { - preview_selected_theme(state); - } - } - KeyCode::Down | KeyCode::Char('j') => { - let previous = state.settings.list.selected; - state.settings.list.move_next(THEME_NAMES.len()); - if state.settings.list.selected != previous { - preview_selected_theme(state); - } - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Indicators; - state.settings.list.selected = status_indicator_index(state.status_indicators); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Integrations; - state.settings.list.selected = 0; - } - _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { - Some(super::modal::ModalAction::Apply) => return apply_settings(state), - Some(super::modal::ModalAction::Close) => cancel_settings(state), - _ => {} - }, - }, - SettingsSection::Indicators => match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let style = status_indicator_for_index(state.settings.list.selected); - return Some(SettingsAction::SaveStatusIndicators(style)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Sound; - state.settings.list.selected = usize::from(!state.sound_enabled()); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Sound => match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let enabled = state.settings.list.selected == 0; - return Some(SettingsAction::SaveSound(enabled)); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Toast; - state.settings.list.selected = toast_delivery_index(state.toast_delivery()); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Indicators; - state.settings.list.selected = status_indicator_index(state.status_indicators); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Toast => match key.code { - KeyCode::Up | KeyCode::Char('k') => state.settings.list.move_prev(), - KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(4), - KeyCode::Enter | KeyCode::Char(' ') => { - let delivery = toast_delivery_for_index(state.settings.list.selected); - return Some(SettingsAction::SaveToastDelivery(delivery)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Sound; - state.settings.list.selected = usize::from(!state.sound_enabled()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::PaneLabels; - state.settings.list.selected = usize::from(!state.agent_border_labels_enabled()); - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::PaneLabels => match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let enabled = state.settings.list.selected == 0; - return Some(SettingsAction::SaveAgentBorderLabels(enabled)); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Toast; - state.settings.list.selected = toast_delivery_index(state.toast_delivery()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Integrations; - state.settings.list.selected = 0; - } - _ => { - if let Some(super::modal::ModalAction::Close) = - super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) - { - cancel_settings(state); - } - } - }, - SettingsSection::Integrations => match key.code { - KeyCode::Enter | KeyCode::Char(' ') if integrations_need_install(state) => { - return Some(SettingsAction::InstallRecommendedIntegrations); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::PaneLabels; - state.settings.list.selected = usize::from(!state.agent_border_labels_enabled()); - } - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); - } - _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { - Some(super::modal::ModalAction::Apply) => return apply_settings(state), - Some(super::modal::ModalAction::Close) => cancel_settings(state), - _ => {} - }, - }, - } - - None -} - -pub(crate) fn open_settings(state: &mut AppState) { - open_settings_at(state, SettingsSection::Theme); -} - -pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) { - state.integration_install_messages.clear(); - state.settings.original_palette = Some(state.palette.clone()); - state.settings.original_theme = Some(state.theme_name.clone()); - state.settings.section = section; - state.settings.list.selected = match section { - SettingsSection::Theme => current_theme_index(&state.theme_name), - SettingsSection::Indicators => status_indicator_index(state.status_indicators), - SettingsSection::Sound => usize::from(!state.sound_enabled()), - SettingsSection::Toast => toast_delivery_index(state.toast_delivery()), - SettingsSection::PaneLabels => usize::from(!state.agent_border_labels_enabled()), - SettingsSection::Integrations => 0, - }; - state.mode = Mode::Settings; -} - -impl AppState { - fn settings_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect( - self.screen_rect(), - crate::ui::SETTINGS_POPUP_WIDTH, - crate::ui::settings_popup_height(self), - ) - .unwrap_or_default() - } - - fn settings_inner_rect(&self) -> Rect { - let popup = self.settings_popup_rect(); - Rect::new( - popup.x + 1, - popup.y + 1, - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ) - } - - fn settings_tab_at(&self, col: u16, row: u16) -> Option<SettingsSection> { - let inner = self.settings_inner_rect(); - let tab_y = inner.y + 1; - if row != tab_y { - return None; - } - let mut x = inner.x; - for section in SettingsSection::ALL { - let badge_width = if self.settings_section_has_badge(*section) { - 2 - } else { - 0 - }; - let width = section.label().len() as u16 + 2 + badge_width; - if col >= x && col < x + width { - return Some(*section); - } - x += width + 1; - } - None - } - - pub(crate) fn settings_content_rect(&self) -> Rect { - let inner = self.settings_inner_rect(); - crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content - } - - fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> { - let area = self.settings_content_rect(); - if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width - { - return None; - } - - match self.settings.section { - SettingsSection::Theme => { - let max_visible = area.height as usize; - let scroll = if self.settings.list.selected >= max_visible { - self.settings.list.selected - max_visible + 1 - } else { - 0 - }; - let idx = scroll + (row - area.y) as usize; - (idx < THEME_NAMES.len()).then_some(idx) - } - SettingsSection::Indicators | SettingsSection::Sound => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 2 { - Some((row - list_y) as usize) - } else { - None - } - } - SettingsSection::Toast => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 8 { - Some(((row - list_y) / 2) as usize) - } else { - None - } - } - SettingsSection::PaneLabels => { - let list_y = area.y + 3; - if row >= list_y && row < list_y + 2 { - Some((row - list_y) as usize) - } else { - None - } - } - SettingsSection::Integrations => None, - } - } - - pub(super) fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { - self.settings.section = section; - self.settings.list.select(match section { - SettingsSection::Theme => current_theme_index(&self.theme_name), - SettingsSection::Indicators => { - status_indicator_index(self.status_indicators) - } - SettingsSection::Sound => usize::from(!self.sound_enabled()), - SettingsSection::Toast => toast_delivery_index(self.toast_delivery()), - SettingsSection::PaneLabels => { - usize::from(!self.agent_border_labels_enabled()) - } - SettingsSection::Integrations => 0, - }); - return None; - } - if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) { - self.settings.list.select(idx); - return match self.settings.section { - SettingsSection::Theme => { - preview_selected_theme(self); - None - } - SettingsSection::Indicators => Some(SettingsAction::SaveStatusIndicators( - status_indicator_for_index(idx), - )), - SettingsSection::Sound => { - let enabled = idx == 0; - Some(SettingsAction::SaveSound(enabled)) - } - SettingsSection::Toast => { - let delivery = toast_delivery_for_index(idx); - Some(SettingsAction::SaveToastDelivery(delivery)) - } - SettingsSection::PaneLabels => { - let enabled = idx == 0; - Some(SettingsAction::SaveAgentBorderLabels(enabled)) - } - SettingsSection::Integrations => None, - }; - } - - let inner = self.settings_inner_rect(); - let show_primary = crate::ui::settings_show_primary_action(self); - let (apply, close) = - crate::ui::settings_button_rects(inner, self.settings.section, show_primary); - let mut buttons = vec![(close, super::modal::ModalAction::Close)]; - if let Some(apply) = apply { - buttons.insert(0, (apply, super::modal::ModalAction::Apply)); - } - match super::modal::modal_action_from_buttons(mouse.column, mouse.row, &buttons) { - Some(super::modal::ModalAction::Apply) => apply_settings(self), - Some(super::modal::ModalAction::Close) => { - cancel_settings(self); - None - } - _ => { - cancel_settings(self); - None - } - } - } - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; - - use super::super::{app_for_mouse_test, mouse, state_with_workspaces}; - use super::*; - - #[test] - fn settings_cancel_restores_previewed_theme_from_other_sections() { - let mut state = state_with_workspaces(&["test"]); - let original_palette = state.palette.clone(); - let original_theme = state.theme_name.clone(); - - open_settings(&mut state); - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - ); - assert_ne!(state.theme_name, original_theme); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!( - state.settings.section, - crate::app::state::SettingsSection::Indicators - ); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - ); - - assert_eq!(state.mode, Mode::Terminal); - assert_eq!(state.theme_name, original_theme); - assert_eq!(state.palette.accent, original_palette.accent); - assert_eq!(state.palette.panel_bg, original_palette.panel_bg); - } - - #[test] - fn settings_indicator_choice_returns_save_action() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::Indicators); - state.settings.list.selected = 1; - - let action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!( - action, - Some(SettingsAction::SaveStatusIndicators( - StatusIndicatorStyle::Symbols - )) - ); - assert_eq!(state.status_indicators, StatusIndicatorStyle::Dots); - assert_eq!(state.mode, Mode::Settings); - } - - #[test] - fn settings_sound_toggle_returns_save_action() { - let mut state = state_with_workspaces(&["test"]); - open_settings(&mut state); - state.settings.section = crate::app::state::SettingsSection::Sound; - state.settings.list.selected = 0; - - let action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - - assert_eq!(action, Some(SettingsAction::SaveSound(true))); - assert!(!state.sound.enabled); - assert_eq!(state.mode, Mode::Settings); - } - - #[test] - fn settings_tab_cycle_wraps_after_integrations() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::PaneLabels); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Integrations); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Theme); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::Integrations); - - update_settings_state( - &mut state, - KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()), - ); - assert_eq!(state.settings.section, SettingsSection::PaneLabels); - } - - #[test] - fn integrations_enter_does_nothing_when_nothing_needs_install() { - let mut state = state_with_workspaces(&["test"]); - open_settings_at(&mut state, SettingsSection::Integrations); - - let enter_action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - ); - assert_eq!(enter_action, None); - - let space_action = update_settings_state( - &mut state, - KeyEvent::new(KeyCode::Char(' '), KeyModifiers::empty()), - ); - assert_eq!(space_action, None); - } - - #[test] - fn settings_hover_does_not_change_selection() { - let mut app = app_for_mouse_test(); - open_settings(&mut app.state); - app.state.settings.list.select(0); - - let area = app.state.settings_content_rect(); - app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2)); - - assert_eq!(app.state.settings.list.selected, 0); - } - - #[test] - fn integration_update_badge_only_tracks_outdated_recommendations() { - let mut state = state_with_workspaces(&["test"]); - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::NotInstalled, - true, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::NotInstalled, - false, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Current, - true, - )]; - assert!(!state.integration_updates_available()); - - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Outdated, - true, - )]; - assert!(state.integration_updates_available()); - } - - #[test] - fn settings_tab_hit_area_includes_integration_update_badge() { - let mut state = state_with_workspaces(&["test"]); - state.integration_recommendations = vec![integration_recommendation( - crate::integration::IntegrationStatusKind::Outdated, - true, - )]; - open_settings(&mut state); - - let inner = state.settings_inner_rect(); - let tab_y = inner.y + 1; - let integrations_idx = SettingsSection::ALL - .iter() - .position(|section| *section == SettingsSection::Integrations) - .expect("integrations section should be present"); - let integrations_x = inner.x - + SettingsSection::ALL[..integrations_idx] - .iter() - .map(|section| { - let badge_width = if state.settings_section_has_badge(*section) { - 2 - } else { - 0 - }; - section.label().len() as u16 + 3 + badge_width - }) - .sum::<u16>(); - let dotted_width = SettingsSection::Integrations.label().len() as u16 + 4; - - assert_eq!( - state.settings_tab_at(integrations_x + dotted_width - 1, tab_y), - Some(SettingsSection::Integrations) - ); - } - - fn integration_recommendation( - state: crate::integration::IntegrationStatusKind, - available: bool, - ) -> crate::integration::IntegrationRecommendation { - crate::integration::IntegrationRecommendation { - target: crate::api::schema::IntegrationTarget::Claude, - label: "claude", - command: "claude", - available, - path: std::path::PathBuf::from("/tmp/herdr-test-integration"), - state, - } - } -} diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs deleted file mode 100644 index 0923eef6..00000000 --- a/src/app/input/sidebar.rs +++ /dev/null @@ -1,1936 +0,0 @@ -use ratatui::layout::Rect; - -use crate::app::state::{AppState, ViewLayout}; - -use super::ScrollbarClickTarget; - -impl AppState { - pub(super) fn workspace_list_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { - return Rect::default(); - } - crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split) - } - - pub(super) fn agent_panel_rect(&self) -> Rect { - let sidebar = self.view.sidebar_rect; - if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { - return Rect::default(); - } - let (_, detail_area) = - crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split); - detail_area - } - - pub(super) fn workspace_list_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option<ScrollbarClickTarget> { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; - if col < track.x - || col >= track.x + track.width - || row < track.y - || row >= track.y + track.height - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - pub(super) fn workspace_list_offset_for_drag_row( - &self, - row: u16, - grab_row_offset: u16, - ) -> Option<usize> { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - let track = crate::ui::workspace_list_scrollbar_rect(self, area)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - pub(super) fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - self.workspace_scroll = metrics - .max_offset_from_bottom - .saturating_sub(offset_from_bottom); - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - } - - pub(super) fn scroll_workspace_list(&mut self, delta: i16) { - if delta.is_negative() { - self.workspace_scroll = self - .workspace_scroll - .saturating_sub(delta.unsigned_abs() as usize); - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - return; - } - - let area = self.workspace_list_rect(); - let metrics = crate::ui::workspace_list_scroll_metrics(self, area); - self.workspace_scroll = self - .workspace_scroll - .saturating_add(delta as usize) - .min(metrics.max_offset_from_bottom); - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - } - - pub(super) fn agent_panel_scrollbar_target_at( - &self, - col: u16, - row: u16, - ) -> Option<ScrollbarClickTarget> { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; - if col < track.x - || col >= track.x + track.width - || row < track.y - || row >= track.y + track.height - { - return None; - } - if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { - Some(ScrollbarClickTarget::Thumb { grab_row_offset }) - } else { - Some(ScrollbarClickTarget::Track { - offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), - }) - } - } - - pub(super) fn agent_panel_offset_for_drag_row( - &self, - row: u16, - grab_row_offset: u16, - ) -> Option<usize> { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; - Some(crate::ui::scrollbar_offset_from_drag_row( - metrics, - track, - row, - grab_row_offset, - )) - } - - pub(super) fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) { - let area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, area); - self.agent_panel_scroll = metrics - .max_offset_from_bottom - .saturating_sub(offset_from_bottom); - } - - pub(super) fn scroll_agent_panel(&mut self, delta: i16) { - let area = self.agent_panel_rect(); - let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom; - if delta.is_negative() { - self.agent_panel_scroll = self - .agent_panel_scroll - .saturating_sub(delta.unsigned_abs() as usize); - } else { - self.agent_panel_scroll = self - .agent_panel_scroll - .saturating_add(delta as usize) - .min(max_scroll); - } - } - - pub(crate) fn sidebar_footer_rect(&self) -> Rect { - let ws_area = self.workspace_list_rect(); - if ws_area == Rect::default() { - return Rect::default(); - } - let y = ws_area.y + ws_area.height.saturating_sub(1); - Rect::new(ws_area.x, y, ws_area.width, 1) - } - - pub(crate) fn sidebar_new_button_rect(&self) -> Rect { - let footer = self.sidebar_footer_rect(); - let width = 5u16.min(footer.width.max(1)); - Rect::new(footer.x, footer.y, width, footer.height) - } - - pub(crate) fn global_launcher_rect(&self) -> Rect { - if self.view.layout == ViewLayout::Mobile { - return self.view.mobile_menu_hit_area; - } - - let footer = self.sidebar_footer_rect(); - let width = if self.global_menu_attention_badge_visible() { - 8 - } else { - 6 - } - .min(footer.width.max(1)); - let x = footer.x + footer.width.saturating_sub(width); - Rect::new(x, footer.y, width, footer.height) - } - - pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> { - let mut labels = vec!["settings", "keybinds", "reload config"]; - if self.update_available.is_some() { - labels.push("update ready"); - } else if self.latest_release_notes_available { - labels.push("what's new"); - } - labels.push("detach"); - labels - } - - pub(crate) fn global_menu_rect(&self) -> Rect { - let screen = self.screen_rect(); - let launcher = self.global_launcher_rect(); - let labels = self.global_menu_labels(); - let content_width = labels - .iter() - .map(|label| { - let badge_width = if self.global_menu_item_has_badge(label) { - 2 - } else { - 0 - }; - label.chars().count() as u16 + badge_width - }) - .max() - .unwrap_or(8) - .saturating_add(2); - let menu_w = content_width.saturating_add(2).min(screen.width.max(1)); - let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1)); - let max_x = screen.x + screen.width.saturating_sub(menu_w); - let desired_x = launcher.x + launcher.width.saturating_sub(menu_w); - let x = desired_x.min(max_x); - let y = launcher.y.saturating_sub(menu_h); - Rect::new(x, y, menu_w, menu_h) - } - - pub(super) fn on_sidebar_divider(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed { - return false; - } - let sidebar = self.view.sidebar_rect; - let toggle = crate::ui::expanded_sidebar_toggle_rect(sidebar); - let on_toggle = toggle.width > 0 - && col >= toggle.x - && col < toggle.x + toggle.width - && row >= toggle.y - && row < toggle.y + toggle.height; - sidebar.width > 0 - && !on_toggle - && col == sidebar.x + sidebar.width.saturating_sub(1) - && row >= sidebar.y - && row < sidebar.y + sidebar.height - } - - pub(super) fn on_sidebar_toggle(&self, col: u16, row: u16) -> bool { - let rect = if self.sidebar_collapsed { - crate::ui::collapsed_sidebar_toggle_rect(self.view.sidebar_rect) - } else { - crate::ui::expanded_sidebar_toggle_rect(self.view.sidebar_rect) - }; - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - pub(super) fn set_manual_sidebar_width(&mut self, divider_col: u16) { - let sidebar = self.view.sidebar_rect; - let width = divider_col.saturating_sub(sidebar.x).saturating_add(1); - self.sidebar_width = width.clamp(self.sidebar_min_width, self.sidebar_max_width); - self.sidebar_width_source = crate::app::state::SidebarWidthSource::Manual; - self.mark_session_dirty(); - } - - pub(super) fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed { - return false; - } - let rect = crate::ui::sidebar_section_divider_rect( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - pub(super) fn set_sidebar_section_split(&mut self, row: u16) { - let sidebar = self.view.sidebar_rect; - let content_height = sidebar.height; - if content_height < 6 { - return; - } - let relative_y = row.saturating_sub(sidebar.y); - let ratio = (relative_y as f32) / (content_height as f32); - self.sidebar_section_split = ratio.clamp(0.1, 0.9); - self.mark_session_dirty(); - } - - pub(super) fn workspace_at_row(&self, row: u16) -> Option<usize> { - let footer = self.sidebar_footer_rect(); - if footer == Rect::default() { - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - - cards.iter().find_map(|card| { - (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx) - }) - } - - pub(super) fn collapsed_workspace_at_row(&self, row: u16) -> Option<usize> { - if !self.sidebar_collapsed { - return None; - } - - let (ws_area, _, _) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); - if ws_area == Rect::default() || row < ws_area.y || row >= ws_area.y + ws_area.height { - return None; - } - - let idx = (row - ws_area.y) as usize; - (idx < self.workspaces.len()).then_some(idx) - } - - pub(super) fn collapsed_agent_detail_target_at( - &self, - row: u16, - ) -> Option<(usize, usize, crate::layout::PaneId)> { - if !self.sidebar_collapsed { - return None; - } - - let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect); - let detail_content_area = Rect::new( - detail_area.x, - detail_area.y, - detail_area.width, - detail_area.height.saturating_sub(1), - ); - if detail_content_area == Rect::default() - || row < detail_content_area.y - || row >= detail_content_area.y + detail_content_area.height - { - return None; - } - - let detail_idx = (row - detail_content_area.y) as usize; - let details = crate::ui::agent_panel_entries(self); - let detail = details.get(detail_idx)?; - Some((detail.ws_idx, detail.tab_idx, detail.pane_id)) - } - - pub(super) fn workspace_drop_target_at_row( - &self, - row: u16, - ) -> Option<crate::app::state::WorkspaceDropTarget> { - let area = self.workspace_list_rect(); - let footer = self.sidebar_footer_rect(); - if area == Rect::default() || row < area.y || row >= footer.y { - return None; - } - - let cards = if self.view.workspace_card_areas.is_empty() { - crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) - } else { - self.view.workspace_card_areas.clone() - }; - crate::ui::workspace_drop_slots(self, &cards, area) - .into_iter() - .enumerate() - .min_by_key(|(slot_idx, (_, slot_row))| (row.abs_diff(*slot_row), *slot_idx)) - .map(|(_, (target, _))| target) - } - - pub(super) fn workspace_move_block_params( - &self, - source_ws_idx: usize, - drop_target: crate::app::state::WorkspaceDropTarget, - ) -> Option<crate::api::schema::WorkspaceMoveBlockParams> { - let source = self.workspaces.get(source_ws_idx)?; - if source - .worktree_space() - .is_some_and(|space| space.is_linked_worktree) - { - return None; - } - - let roots = crate::ui::workspace_list_entries_expanded(self) - .into_iter() - .filter_map(|entry| match entry { - crate::ui::WorkspaceListEntry::Workspace { - ws_idx, - indented: false, - } => Some(ws_idx), - crate::ui::WorkspaceListEntry::Workspace { .. } => None, - }) - .collect::<Vec<_>>(); - let source_pos = roots.iter().position(|ws_idx| *ws_idx == source_ws_idx)?; - let remaining_roots = roots - .iter() - .copied() - .filter(|ws_idx| *ws_idx != source_ws_idx) - .collect::<Vec<_>>(); - let insert_pos = match drop_target { - crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => remaining_roots - .iter() - .position(|ws_idx| *ws_idx == target_ws_idx)?, - crate::app::state::WorkspaceDropTarget::End => remaining_roots.len(), - }; - if insert_pos == source_pos { - return None; - } - - let workspace_ids = match source.worktree_space() { - Some(source_space) => { - let mut ids = vec![source.id.clone()]; - ids.extend( - self.workspaces - .iter() - .filter(|workspace| workspace.id != source.id) - .filter(|workspace| { - workspace - .worktree_space() - .is_some_and(|space| space.key == source_space.key) - }) - .map(|workspace| workspace.id.clone()), - ); - ids - } - None => vec![source.id.clone()], - }; - let before_workspace_id = match drop_target { - crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => { - let target = self.workspaces.get(target_ws_idx)?; - let anchor = match crate::ui::workspace_parent_group_state(self, target_ws_idx) - .and_then(|_| target.worktree_space()) - { - Some(target_space) => self - .workspaces - .iter() - .find(|workspace| { - workspace - .worktree_space() - .is_some_and(|space| space.key == target_space.key) - }) - .unwrap_or(target), - None => target, - }; - Some(anchor.id.clone()) - } - crate::app::state::WorkspaceDropTarget::End => None, - }; - - Some(crate::api::schema::WorkspaceMoveBlockParams { - workspace_ids, - before_workspace_id, - }) - } - - pub(super) fn on_agent_panel_sort_toggle(&self, col: u16, row: u16) -> bool { - if self.sidebar_collapsed || self.agent_view_override.is_some() { - return false; - } - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_sort); - rect.width > 0 - && col >= rect.x - && col < rect.x + rect.width - && row >= rect.y - && row < rect.y + rect.height - } - - pub(super) fn agent_detail_target_at( - &self, - row: u16, - ) -> Option<(usize, usize, crate::layout::PaneId)> { - if self.sidebar_collapsed { - return None; - } - - let detail_area = self.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area); - let body = crate::ui::agent_panel_body_rect( - detail_area, - crate::ui::should_show_scrollbar(metrics), - ); - if body.height == 0 || row < body.y || row >= body.y + body.height { - return None; - } - - let mut row_y = body.y; - let body_bottom = body.y + body.height; - let entries = crate::ui::agent_panel_entries(self); - let scroll = self.agent_panel_scroll.min(metrics.max_offset_from_bottom); - for (index, detail) in entries.iter().enumerate().skip(scroll) { - let height = crate::ui::agent_entry_height_in_body(self, detail, body.height); - if row_y.saturating_add(height) > body_bottom { - break; - } - if row >= row_y && row < row_y.saturating_add(height) { - return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); - } - row_y = row_y - .saturating_add(height) - .saturating_add(crate::ui::agent_entry_gap(self, index, entries.len())) - .min(body_bottom); - } - None - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use crossterm::event::{MouseButton, MouseEventKind}; - use ratatui::layout::Rect; - - use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path}; - use crate::{ - app::state::{AgentPanelSort, DragTarget, Mode}, - config::SidebarCollapsedModeConfig, - detect::{Agent, AgentState}, - workspace::Workspace, - }; - - #[test] - fn clicking_launcher_opens_global_menu() { - let mut app = app_for_mouse_test(); - let rect = app.state.global_launcher_rect(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - rect.x + rect.width.saturating_sub(1), - rect.y, - )); - - assert_eq!(app.state.mode, Mode::GlobalMenu); - } - - #[test] - fn hovering_global_menu_updates_highlight() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); - - assert_eq!(app.state.global_menu.highlighted, 1); - } - - #[test] - fn clicking_keybinds_menu_item_opens_help() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 2, - )); - - assert_eq!(app.state.mode, Mode::KeybindHelp); - } - - #[test] - fn clicking_settings_menu_item_opens_settings() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1, - )); - - assert_eq!(app.state.mode, Mode::Settings); - } - - #[test] - fn clicking_reload_config_menu_item_requests_reload() { - let mut app = app_for_mouse_test(); - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 3, - )); - - assert!(app.state.request_reload_config); - assert_eq!(app.state.mode, Mode::Navigate); - } - - #[test] - fn update_pending_menu_surfaces_update_ready_entry() { - let mut app = app_for_mouse_test(); - app.state.update_available = Some("0.3.2".into()); - app.state.latest_release_notes_available = true; - - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - assert_eq!( - app.state.global_menu_labels(), - vec![ - "settings", - "keybinds", - "reload config", - "update ready", - "detach" - ] - ); - assert!(!app.state.should_quit); - } - - #[test] - fn persistence_mode_menu_surfaces_detach_action() { - let mut app = app_for_mouse_test(); - app.state.detach_exits = false; - - let launcher = app.state.global_launcher_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - launcher.x, - launcher.y, - )); - - assert_eq!( - app.state.global_menu_labels(), - vec!["settings", "keybinds", "reload config", "detach"] - ); - - let menu = app.state.global_menu_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 4, - )); - - assert!(app.state.detach_requested); - assert!(!app.state.should_quit); - assert_ne!(app.state.mode, Mode::GlobalMenu); - } - - #[test] - fn whats_new_remains_in_menu_for_latest_installed_release_notes() { - let mut app = app_for_mouse_test(); - app.state.latest_release_notes_available = true; - - assert_eq!( - app.state.global_menu_labels(), - vec![ - "settings", - "keybinds", - "reload config", - "what's new", - "detach" - ] - ); - } - - #[test] - fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.tabs[0].set_custom_name("main".into()); - let first_pane = ws.tabs[0].root_pane; - let first_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[first_tab].root_pane; - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[0].tabs[first_tab].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 16)); - - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert_eq!( - app.state.workspaces[0].tabs[1].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.workspaces[0].active_tab, first_tab); - assert_eq!( - snapshot.workspaces[0].tabs[first_tab].focused, - Some(second_pane.raw()) - ); - } - - #[test] - fn per_agent_row_heights_preserve_card_gaps_and_trailing_mouse_targets() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - for (ws_idx, pane_id, agent) in - [(0, first_pane, Agent::Pi), (1, second_pane, Agent::Claude)] - { - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(agent); - } - app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; - app.state.sidebar_agents.rows_by_agent.insert( - "claude".into(), - vec![ - vec![crate::config::AgentSidebarToken::Agent], - vec![crate::config::AgentSidebarToken::Workspace], - ], - ); - app.state.sidebar_agents.row_gap = 1; - let detail_area = app.state.agent_panel_rect(); - let metrics = crate::ui::agent_panel_scroll_metrics(&app.state, detail_area); - let body = crate::ui::agent_panel_body_rect( - detail_area, - crate::ui::should_show_scrollbar(metrics), - ); - - assert_eq!( - app.state.agent_detail_target_at(body.y), - Some((0, 0, first_pane)) - ); - assert_eq!(app.state.agent_detail_target_at(body.y + 1), None); - assert_eq!( - app.state.agent_detail_target_at(body.y + 3), - Some((1, 0, second_pane)) - ); - - app.state.sidebar_agents.row_gap = 0; - assert_eq!( - app.state.agent_detail_target_at(body.y + 1), - Some((1, 0, second_pane)) - ); - } - - #[test] - fn agent_hit_testing_clamps_scroll_after_dynamic_filter_shrink() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - for (ws_idx, pane_id) in [(0, first_pane), (1, second_pane)] { - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - } - app.state.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::WorkspaceId, - ), - value: crate::api::schema::AgentViewValue::Context { - context: crate::api::schema::AgentViewContext::CurrentWorkspaceId, - }, - }), - sort: Vec::new(), - }); - app.state.agent_panel_scroll = 10; - let detail_area = app.state.agent_panel_rect(); - let body = crate::ui::agent_panel_body_rect(detail_area, false); - - assert_eq!( - app.state.agent_detail_target_at(body.y), - Some((0, 0, first_pane)) - ); - } - - #[test] - fn clicking_agent_panel_toggle_switches_sort() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.agent_panel_scroll = 3; - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_sort); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert_eq!(app.state.agent_panel_sort, AgentPanelSort::Priority); - assert_eq!(app.state.agent_panel_scroll, 0); - } - - #[test] - fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x + 2, - detail_area.y + 6, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert_eq!(app.state.workspaces[1].active_tab, 0); - assert_eq!( - app.state.workspaces[1].tabs[0].layout.focused(), - second_pane - ); - } - - #[test] - fn scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - - let mut tabs = Vec::new(); - for (tab_name, agent) in [ - ("logs", Agent::Claude), - ("review", Agent::Codex), - ("ops", Agent::Gemini), - ] { - let tab_idx = ws.test_add_tab(Some(tab_name)); - let pane_id = ws.tabs[tab_idx].root_pane; - tabs.push((tab_idx, pane_id, agent)); - } - - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - for (tab_idx, pane_id, agent) in tabs { - let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(agent); - } - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let detail_area = app.state.agent_panel_rect(); - assert!(crate::ui::should_show_scrollbar( - crate::ui::agent_panel_scroll_metrics(&app.state, detail_area) - )); - - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - detail_area.x + 1, - detail_area.y + 4, - )); - - assert_eq!(app.state.agent_panel_scroll, 1); - assert_eq!(app.state.selected, 0); - } - - #[test] - fn clicking_scrolled_agent_detail_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - let mut extra_tabs = Vec::new(); - for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] { - let tab_idx = ws.test_add_tab(Some(tab_name)); - let pane_id = ws.tabs[tab_idx].root_pane; - extra_tabs.push((tab_idx, pane_id, agent)); - } - - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - for (tab_idx, pane_id, agent) in extra_tabs { - let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&terminal_id) - .unwrap() - .detected_agent = Some(agent); - } - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; - app.state.sidebar_agents.rows_by_agent.insert( - "claude".into(), - vec![ - vec![crate::config::AgentSidebarToken::Agent], - vec![crate::config::AgentSidebarToken::Workspace], - ], - ); - app.state.agent_panel_scroll = 1; - - let detail_area = app.state.agent_panel_rect(); - let body = crate::ui::agent_panel_body_rect(detail_area, true); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - body.x + 1, - body.y + 1, - )); - - assert_eq!(app.state.workspaces[0].active_tab, second_tab); - assert_eq!( - app.state.workspaces[0].tabs[second_tab].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let first_pane = ws.tabs[0].root_pane; - let second_tab = ws.test_add_tab(Some("logs")); - let second_pane = ws.tabs[second_tab].root_pane; - app.state.workspaces = vec![ws]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&first_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Pi); - let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .detected_agent = Some(Agent::Claude); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.sidebar_collapsed = true; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let (_, _, detail_area) = - crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x, - detail_area.y + 1, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert_eq!( - app.state.workspaces[0].tabs[1].layout.focused(), - second_pane - ); - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn clicking_collapsed_priority_agent_row_switches_to_matching_workspace() { - let mut app = app_for_mouse_test(); - let first = Workspace::test_new("one"); - let first_pane = first.tabs[0].root_pane; - let second = Workspace::test_new("two"); - let second_pane = second.tabs[0].root_pane; - - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.sidebar_collapsed = true; - app.state.agent_panel_sort = AgentPanelSort::Priority; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let set_state = |app: &mut crate::app::App, ws_idx: usize, pane_id, state| { - let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(Agent::Claude); - terminal.state = state; - }; - set_state(&mut app, 0, first_pane, AgentState::Working); - set_state(&mut app, 1, second_pane, AgentState::Blocked); - - let (_, _, detail_area) = - crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - detail_area.x, - detail_area.y, - )); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert_eq!( - app.state.workspaces[1].tabs[0].layout.focused(), - second_pane - ); - } - - #[test] - fn clicking_collapsed_sidebar_toggle_expands_sidebar() { - let mut app = app_for_mouse_test(); - app.state.sidebar_collapsed = true; - app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); - app.state.view.terminal_area = Rect::new(4, 0, 80, 20); - - let toggle = crate::ui::collapsed_sidebar_toggle_rect(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert!(!app.state.sidebar_collapsed); - } - - #[test] - fn hidden_collapsed_sidebar_has_no_mouse_expand_hotspot() { - let mut app = app_for_mouse_test(); - app.state.sidebar_collapsed = true; - app.state.sidebar_collapsed_mode = SidebarCollapsedModeConfig::Hidden; - app.state.view.sidebar_rect = Rect::new(0, 0, 0, 20); - app.state.view.terminal_area = Rect::new(0, 0, 80, 20); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 19)); - - assert!(app.state.sidebar_collapsed); - } - - #[test] - fn clicking_expanded_sidebar_toggle_collapses_sidebar() { - let mut app = app_for_mouse_test(); - app.state.sidebar_collapsed = false; - app.state.view.sidebar_rect = Rect::new(0, 0, 26, 20); - app.state.view.terminal_area = Rect::new(26, 0, 80, 20); - - let toggle = crate::ui::expanded_sidebar_toggle_rect(app.state.view.sidebar_rect); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - toggle.x, - toggle.y, - )); - - assert!(app.state.sidebar_collapsed); - assert!(app.state.drag.is_none()); - } - - #[test] - fn clicking_workspace_switches_on_mouse_up() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let target_row = app.state.view.workspace_card_areas[1].rect.y; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - 2, - target_row, - )); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspace_presses.len(), 1); - - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert!(app.state.workspace_presses.is_empty()); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.active, Some(1)); - assert_eq!(snapshot.selected, 1); - } - - #[test] - fn clicking_worktree_parent_row_focuses_workspace_without_toggling() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("main"), Workspace::test_new("issue")]; - for (idx, checkout_path) in ["/repo/herdr", "/repo/herdr-issue"].into_iter().enumerate() { - app.state.workspaces[idx].worktree_space = - Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout_path.into(), - is_linked_worktree: idx > 0, - }); - } - app.state.active = None; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let parent = app.state.view.workspace_card_areas[0].rect; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - parent.x + 2, - parent.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - parent.x + 2, - parent.y, - )); - - assert_eq!(app.state.active, Some(0)); - assert!(!app.state.collapsed_space_keys.contains("repo-key")); - } - - #[test] - fn clicking_worktree_parent_chevron_toggles_group_only() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("main"), Workspace::test_new("issue")]; - for (idx, checkout_path) in ["/repo/herdr", "/repo/herdr-issue"].into_iter().enumerate() { - app.state.workspaces[idx].worktree_space = - Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout_path.into(), - is_linked_worktree: idx > 0, - }); - } - app.state.active = None; - app.state.mode = Mode::Terminal; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let parent = app.state.view.workspace_card_areas[0]; - let chevron = crate::ui::workspace_group_chevron_rect(&parent); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - chevron.x, - chevron.y, - )); - - assert_eq!(app.state.active, None); - assert!(app.state.workspace_presses.is_empty()); - assert!(app.state.collapsed_space_keys.contains("repo-key")); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - chevron.x, - chevron.y, - )); - - assert!(!app.state.collapsed_space_keys.contains("repo-key")); - } - - #[test] - fn wheel_workspace_selection_follows_grouped_visual_order_without_scrollbar() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("main"), - Workspace::test_new("normal"), - Workspace::test_new("issue"), - ]; - for (idx, checkout_path) in [(0, "/repo/herdr"), (2, "/repo/herdr-issue")] { - app.state.workspaces[idx].worktree_space = - Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout_path.into(), - is_linked_worktree: idx != 0, - }); - } - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Navigate; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 30)); - let list = app.state.workspace_list_rect(); - assert!(!crate::ui::should_show_scrollbar( - crate::ui::workspace_list_scroll_metrics(&app.state, list) - )); - - app.handle_mouse(mouse(MouseEventKind::ScrollDown, list.x + 1, list.y + 1)); - - assert_eq!(app.state.selected, 2); - } - - #[test] - fn dragging_workspace_reorders_without_changing_identity() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - app.state.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; - app.state.sidebar_spaces.row_gap = 0; - let active_id = app.state.workspaces[1].id.clone(); - let selected_id = app.state.workspaces[2].id.clone(); - app.state.active = Some(1); - app.state.selected = 2; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let packed_boundary_row = app.state.view.workspace_card_areas[1].rect.y; - assert_eq!( - app.state.workspace_drop_target_at_row(packed_boundary_row), - Some(crate::app::state::WorkspaceDropTarget::Before(2)) - ); - - let source_row = app.state.view.workspace_card_areas[1].rect.y; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::Before(0), - ) - .unwrap(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - 2, - source_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::WorkspaceReorder { - source_ws_idx: 1, - drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(0)), - .. - }) - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - let names: Vec<_> = app - .state - .workspaces - .iter() - .map(|ws| ws.display_name()) - .collect(); - assert_eq!(names, vec!["b", "a", "c"]); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.selected, 2); - assert_eq!(app.state.workspaces[0].id, active_id); - assert_eq!(app.state.workspaces[2].id, selected_id); - let events = app.event_hub.events_after(0); - assert!(events.iter().any(|(_, event)| matches!( - event.data, - crate::api::schema::EventData::WorkspaceMoved { .. } - ))); - assert!(!events.iter().any(|(_, event)| matches!( - event.data, - crate::api::schema::EventData::WorkspaceReordered { .. } - ))); - let snapshot = capture_snapshot(&app.state); - let captured_names: Vec<_> = snapshot - .workspaces - .iter() - .map(|ws| ws.custom_name.clone().unwrap()) - .collect(); - assert_eq!(captured_names, vec!["b", "a", "c"]); - } - - #[test] - fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("logs")); - ws.test_add_tab(Some("review")); - ws.test_add_tab(Some("ops")); - ws.test_add_tab(Some("notes")); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); - - let right = app.state.view.tab_scroll_right_hit_area; - assert!(right.width > 0); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - right.x + 1, - right.y, - )); - - assert_eq!(app.state.tab_scroll, 1); - assert!(!app.state.tab_scroll_follow_active); - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!(app.state.view.tab_hit_areas[0].width, 0); - assert!(app.state.workspaces[0].tabs[0].custom_name.is_none()); - assert_eq!( - app.state.workspaces[0].tabs[1].custom_name.as_deref(), - Some("logs") - ); - } - - #[test] - fn clicking_last_visible_tab_at_right_edge_does_not_overscroll() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - for name in [ - "one", "two", "three", "four", "five", "six", "seven", "eight", - ] { - ws.test_add_tab(Some(name)); - } - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.tab_scroll = usize::MAX; - app.state.tab_scroll_follow_active = false; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); - - let last_idx = app.state.workspaces[0].tabs.len() - 1; - let target = app.state.view.tab_hit_areas[last_idx]; - let clamped_scroll = app.state.tab_scroll; - assert!(target.width > 0, "last tab should already be visible"); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - target.x + 1, - target.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - target.x + 1, - target.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, last_idx); - assert_eq!(app.state.tab_scroll, clamped_scroll); - assert!(app.state.view.tab_hit_areas[last_idx].width > 0); - } - - #[test] - fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("foo")); - ws.test_add_tab(None); - let moved_root = ws.tabs[0].root_pane; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let source = app.state.view.tab_hit_areas[0]; - let last = app.state.view.tab_hit_areas[2]; - let drop_col = last.x + last.width; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - source.x + 1, - source.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - drop_col, - source.y, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::TabReorder { - ws_idx: 0, - source_tab_idx: 0, - insert_idx: Some(3), - .. - }) - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - drop_col, - source.y, - )); - - let labels: Vec<_> = app.state.workspaces[0] - .tabs - .iter() - .enumerate() - .map(|(tab_idx, _)| app.state.workspaces[0].tab_display_name(tab_idx).unwrap()) - .collect(); - assert_eq!(labels, vec!["foo", "2", "3"]); - assert_eq!( - app.state.workspaces[0].tabs[0].custom_name.as_deref(), - Some("foo") - ); - assert!(app.state.workspaces[0].tabs[1].custom_name.is_none()); - assert!(app.state.workspaces[0].tabs[2].custom_name.is_none()); - assert_eq!(app.state.workspaces[0].tabs[0].number, 2); - assert_eq!(app.state.workspaces[0].tabs[1].number, 3); - assert_eq!(app.state.workspaces[0].tabs[2].number, 1); - assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root); - assert_eq!(app.state.workspaces[0].active_tab, 2); - } - - fn temp_git_repo(branch: &str) -> std::path::PathBuf { - let repo = unique_temp_path("sidebar-drop-slot-repo"); - fs::create_dir_all(repo.join(".git")).unwrap(); - fs::write( - repo.join(".git/HEAD"), - format!("ref: refs/heads/{branch}\n"), - ) - .unwrap(); - repo - } - - fn workspace_with_space(name: &str, key: &str) -> Workspace { - let mut ws = Workspace::test_new(name); - ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/{name}").into(), - is_linked_worktree: name != "main", - }); - ws - } - - #[test] - fn top_drop_slot_is_distinct_from_gap_below_first_workspace() { - let mut app = app_for_mouse_test(); - let first_repo = temp_git_repo("main"); - let second_repo = temp_git_repo("main"); - - let mut first = Workspace::test_new("a"); - let first_root = first.tabs[0].root_pane; - first.identity_cwd = first_repo.clone(); - first.refresh_git_ahead_behind(); - - let mut second = Workspace::test_new("b"); - let second_root = second.tabs[0].root_pane; - second.identity_cwd = second_repo.clone(); - second.refresh_git_ahead_behind(); - - app.state.workspaces = vec![first, second]; - app.state.ensure_test_terminals(); - let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_root] - .attached_terminal_id - .clone(); - app.state.terminals.get_mut(&first_terminal_id).unwrap().cwd = first_repo.clone(); - let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_root] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&second_terminal_id) - .unwrap() - .cwd = second_repo.clone(); - app.state.sidebar_spaces.row_gap = 1; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - assert_eq!( - app.state.workspace_drop_target_at_row(0), - Some(crate::app::state::WorkspaceDropTarget::Before(0)) - ); - assert_eq!( - app.state.workspace_drop_target_at_row(1), - Some(crate::app::state::WorkspaceDropTarget::Before(0)) - ); - assert_eq!( - app.state.workspace_drop_target_at_row(2), - Some(crate::app::state::WorkspaceDropTarget::Before(0)) - ); - assert_eq!( - app.state.workspace_drop_target_at_row(3), - Some(crate::app::state::WorkspaceDropTarget::Before(1)) - ); - - let _ = fs::remove_dir_all(first_repo); - let _ = fs::remove_dir_all(second_repo); - } - - #[test] - fn bottom_drop_slot_stays_below_last_workspace_not_footer() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("a"), - Workspace::test_new("b"), - Workspace::test_new("c"), - ]; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 24)); - - let cards = &app.state.view.workspace_card_areas; - let bottom_slot = crate::ui::workspace_drop_indicator_row( - &app.state, - cards, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ) - .unwrap(); - - let last = cards.last().unwrap().rect; - assert_eq!(bottom_slot, last.y + last.height); - assert!(bottom_slot < app.state.sidebar_footer_rect().y.saturating_sub(1)); - } - - #[test] - fn grouped_sidebar_drop_slots_do_not_land_inside_compact_group() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("main", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("issue", "repo-key"), - ]; - app.state.active = Some(1); - app.state.selected = 1; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - - let cards = &app.state.view.workspace_card_areas; - let order = cards.iter().map(|card| card.ws_idx).collect::<Vec<_>>(); - assert_eq!(order, vec![0, 2, 1]); - let issue = cards.iter().find(|card| card.ws_idx == 2).unwrap(); - let normal = cards.iter().find(|card| card.ws_idx == 1).unwrap(); - - assert_eq!( - app.state.workspace_drop_target_at_row(issue.rect.y), - Some(crate::app::state::WorkspaceDropTarget::Before(1)) - ); - assert_eq!( - crate::ui::workspace_drop_indicator_row( - &app.state, - cards, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ), - Some(normal.rect.y + normal.rect.height) - ); - } - - #[test] - fn plain_drag_anchors_to_the_selected_parentless_linked_workspace() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("one", "repo-key"), - workspace_with_space("two", "repo-key"), - Workspace::test_new("normal"), - ]; - let target_id = app.state.workspaces[1].id.clone(); - - let params = app - .state - .workspace_move_block_params(2, crate::app::state::WorkspaceDropTarget::Before(1)) - .unwrap(); - - assert_eq!(params.workspace_ids, [app.state.workspaces[2].id.clone()]); - assert_eq!( - params.before_workspace_id.as_deref(), - Some(target_id.as_str()) - ); - } - - #[test] - fn dragging_worktree_parent_reorders_the_complete_group() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("main", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("issue", "repo-key"), - ]; - app.state.active = Some(2); - app.state.selected = 1; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - - let parent = app - .state - .view - .workspace_card_areas - .iter() - .find(|card| card.ws_idx == 0) - .unwrap() - .rect; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ) - .unwrap(); - let active_id = app.state.workspaces[2].id.clone(); - let selected_id = app.state.workspaces[1].id.clone(); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::WorkspaceReorder { - source_ws_idx: 0, - drop_target: Some(crate::app::state::WorkspaceDropTarget::End), - .. - }) - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - assert_eq!( - app.state - .workspaces - .iter() - .map(|workspace| workspace.display_name()) - .collect::<Vec<_>>(), - ["normal", "main", "issue"] - ); - assert_eq!( - app.state.workspaces[app.state.active.unwrap()].id, - active_id - ); - assert_eq!(app.state.workspaces[app.state.selected].id, selected_id); - } - - #[test] - fn dragging_collapsed_worktree_parent_still_moves_hidden_children() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("issue", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("main", "repo-key"), - workspace_with_space("review", "repo-key"), - ]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.collapsed_space_keys.insert("repo-key".into()); - let active_id = app.state.workspaces[0].id.clone(); - let selected_id = app.state.workspaces[1].id.clone(); - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - assert_eq!(app.state.view.workspace_card_areas.len(), 3); - - let parent = app.state.view.workspace_card_areas[0].rect; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::End, - ) - .unwrap(); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - assert_eq!( - app.state - .workspaces - .iter() - .map(|workspace| workspace.display_name()) - .collect::<Vec<_>>(), - ["normal", "main", "issue", "review"] - ); - assert_eq!( - app.state.workspaces[app.state.active.unwrap()].id, - active_id - ); - assert_eq!(app.state.workspaces[app.state.selected].id, selected_id); - } - - #[test] - fn dragging_worktree_space_member_does_not_reorder_workspaces() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - workspace_with_space("main", "repo-key"), - Workspace::test_new("normal"), - workspace_with_space("issue", "repo-key"), - ]; - app.state.active = Some(0); - app.state.selected = 0; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); - - let source = app - .state - .view - .workspace_card_areas - .iter() - .find(|card| card.ws_idx == 2) - .unwrap() - .rect; - let target_row = crate::ui::workspace_drop_indicator_row( - &app.state, - &app.state.view.workspace_card_areas, - app.state.workspace_list_rect(), - crate::app::state::WorkspaceDropTarget::Before(0), - ) - .unwrap(); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, source.y)); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - 2, - target_row, - )); - assert!(app.state.drag.is_none()); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); - - let names = app - .state - .workspaces - .iter() - .map(|ws| ws.display_name()) - .collect::<Vec<_>>(); - assert_eq!(names, vec!["main", "normal", "issue"]); - } - - #[test] - fn dragging_sidebar_divider_sets_manual_width() { - let mut app = app_for_mouse_test(); - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5)); - - assert_eq!(app.state.sidebar_width, 31); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.sidebar_width, Some(31)); - } - - #[test] - fn dragging_sidebar_bottom_divider_still_sets_manual_width() { - let mut app = app_for_mouse_test(); - let divider_col = app.state.view.sidebar_rect.x + app.state.view.sidebar_rect.width - 1; - let bottom_row = app.state.view.sidebar_rect.y + app.state.view.sidebar_rect.height - 1; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - divider_col, - bottom_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - divider_col + 5, - bottom_row, - )); - - assert_eq!(app.state.sidebar_width, 31); - } - - #[test] - fn dragging_past_max_clamps_to_configured_max() { - let mut app = app_for_mouse_test(); - app.state.sidebar_max_width = 30; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 50, 5)); - - assert_eq!(app.state.sidebar_width, 30); - } - - #[test] - fn dragging_below_min_clamps_to_configured_min() { - let mut app = app_for_mouse_test(); - app.state.sidebar_min_width = 22; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 5, 5)); - - assert_eq!(app.state.sidebar_width, 22); - } - - #[test] - fn dragging_sidebar_section_divider_sets_split_ratio() { - let mut app = app_for_mouse_test(); - let divider = crate::ui::sidebar_section_divider_rect( - app.state.view.sidebar_rect, - app.state.sidebar_section_split, - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - divider.x + 1, - divider.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - divider.x + 1, - divider.y + 4, - )); - - assert!(app.state.sidebar_section_split > 0.5); - let snapshot = capture_snapshot(&app.state); - assert_eq!( - snapshot.sidebar_section_split, - Some(app.state.sidebar_section_split) - ); - } - - #[test] - fn double_clicking_sidebar_divider_resets_default_width() { - let mut app = app_for_mouse_test(); - app.state.default_sidebar_width = 26; - app.state.sidebar_width = 30; - - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5)); - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5)); - - assert_eq!(app.state.sidebar_width, 26); - assert!(app.state.drag.is_none()); - let snapshot = capture_snapshot(&app.state); - assert_eq!(snapshot.sidebar_width, Some(26)); - } -} 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 - ); - } -} |
