aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/clipboard.rs370
-rw-r--r--src/app/input/copy_mode.rs2153
-rw-r--r--src/app/input/lease.rs465
-rw-r--r--src/app/input/mod.rs1051
-rw-r--r--src/app/input/modal.rs2421
-rw-r--r--src/app/input/mouse.rs4778
-rw-r--r--src/app/input/navigate.rs3898
-rw-r--r--src/app/input/overlays.rs831
-rw-r--r--src/app/input/selection.rs306
-rw-r--r--src/app/input/settings.rs693
-rw-r--r--src/app/input/sidebar.rs1936
-rw-r--r--src/app/input/terminal.rs2181
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
- );
- }
-}