diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/app | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/app')
48 files changed, 2444 insertions, 32565 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 07f7278b..85839322 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -3,7 +3,7 @@ use std::time::Instant; -use tracing::{info, warn}; +use tracing::warn; use crate::detect::{Agent, AgentState}; use crate::events::AppEvent; @@ -16,10 +16,8 @@ use crate::workspace::WorkspaceGitStatus; use super::api_helpers::pane_agent_status; use super::state::{ - navigator_display_index_of_row, navigator_display_lines, navigator_first_row_at_or_after, - text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow, - NavigatorStateFilter, NavigatorTarget, PaneFocusTarget, PendingAgentNotification, ToastKind, - ToastNotification, ToastTarget, ViewLayout, + AgentNotificationDelivery, AppState, Mode, PaneFocusTarget, PendingAgentNotification, + ToastKind, ToastNotification, ToastTarget, }; fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool { @@ -256,7 +254,7 @@ pub struct PaneStateUpdate { } // --------------------------------------------------------------------------- -// Navigator operations +// Focus tracking // --------------------------------------------------------------------------- impl AppState { @@ -270,18 +268,6 @@ impl AppState { }) } - pub(crate) fn pane_focus_target_indices( - &self, - target: &PaneFocusTarget, - ) -> Option<(usize, usize)> { - let ws_idx = self - .workspaces - .iter() - .position(|ws| ws.id == target.workspace_id)?; - let tab_idx = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id)?; - Some((ws_idx, tab_idx)) - } - pub(crate) fn record_pane_focus_change( &mut self, previous: Option<PaneFocusTarget>, @@ -307,14 +293,6 @@ impl AppState { } } - fn sync_selection_after_focus_navigation(&mut self) { - if self.copy_mode.is_some() { - self.sync_copy_mode_with_focus(); - } else { - self.clear_selection(); - } - } - pub(crate) fn focus_pane_in_workspace(&mut self, ws_idx: usize, pane_id: PaneId) -> bool { let Some(ws) = self.workspaces.get(ws_idx) else { return false; @@ -331,9 +309,6 @@ impl AppState { return false; } - if self.copy_mode.is_some() { - self.clear_copy_mode_selection(); - } self.switch_workspace_tab(ws_idx, tab_idx); if let Some(tab) = self .workspaces @@ -343,649 +318,10 @@ impl AppState { tab.layout.focus_pane(pane_id); self.previous_pane_focus = previous; self.mark_session_dirty(); - self.sync_copy_mode_with_focus(); return true; } false } - - #[cfg(test)] - pub(crate) fn open_navigator(&mut self) { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - self.open_navigator_from(&terminal_runtimes); - } - - pub(crate) fn open_navigator_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - self.navigator.query.clear(); - self.navigator.search_focused = false; - self.navigator.state_filter = None; - self.navigator.scroll = 0; - self.navigator.expanded_workspaces.clear(); - - for ws in &self.workspaces { - self.navigator.expanded_workspaces.insert(ws.id.clone()); - } - - self.mode = Mode::Navigator; - self.navigator.selected = self - .current_navigator_row_index_from(terminal_runtimes) - .unwrap_or(0); - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - #[cfg(test)] - pub(crate) fn navigator_rows(&self) -> Vec<NavigatorRow> { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - self.navigator_rows_from(&terminal_runtimes) - } - - pub(crate) fn navigator_rows_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> Vec<NavigatorRow> { - let query = self.navigator.query.trim().to_lowercase(); - let query_kind = navigator_query_kind(&query, self.navigator.state_filter); - let mut rows = Vec::new(); - for (ws_idx, ws) in self.workspaces.iter().enumerate() { - let workspace_label = ws.display_name_from(&self.terminals, terminal_runtimes); - let activity = workspace_activity_summary(ws, &self.terminals); - let workspace_search_text = format!("{workspace_label} {activity}").to_lowercase(); - let workspace_matches = match query_kind { - NavigatorQueryKind::Empty => true, - NavigatorQueryKind::State(filter) => { - let (state, seen) = ws.aggregate_state(&self.terminals); - navigator_state_filter_matches(filter, state, seen) - } - NavigatorQueryKind::Text => navigator_matches(&query, &workspace_search_text), - }; - - let child_rows = - self.navigator_child_rows(ws_idx, query_kind, &query, workspace_matches); - if !workspace_matches && child_rows.is_empty() { - continue; - } - - let expanded = !matches!(query_kind, NavigatorQueryKind::Empty) - || self.navigator.expanded_workspaces.contains(&ws.id); - let (state, seen) = ws.aggregate_state(&self.terminals); - let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::<usize>(); - rows.push(NavigatorRow { - target: NavigatorTarget::Workspace { ws_idx }, - depth: 0, - label: format!("{workspace_label} ({pane_count})"), - meta: activity, - status: state, - seen, - is_current: self.active == Some(ws_idx), - is_workspace: true, - is_tab: false, - expanded, - search_text: workspace_search_text, - matched: workspace_matches, - }); - if expanded { - rows.extend(child_rows); - } - } - rows - } - - fn navigator_child_rows( - &self, - ws_idx: usize, - query_kind: NavigatorQueryKind, - query: &str, - workspace_matches: bool, - ) -> Vec<NavigatorRow> { - let Some(ws) = self.workspaces.get(ws_idx) else { - return Vec::new(); - }; - let multi_tab = ws.tabs.len() > 1; - let mut rows = Vec::new(); - for tab_idx in 0..ws.tabs.len() { - let mut tab_row = self.navigator_tab_row(ws_idx, tab_idx); - let tab_matches = match query_kind { - NavigatorQueryKind::Empty => true, - NavigatorQueryKind::State(filter) => { - navigator_state_filter_matches(filter, tab_row.status, tab_row.seen) - } - NavigatorQueryKind::Text => navigator_matches( - query, - if multi_tab { - &tab_row.search_text - } else { - &tab_row.label - }, - ), - }; - tab_row.matched = tab_matches; - let show_tab_row = - multi_tab || (matches!(query_kind, NavigatorQueryKind::Text) && tab_matches); - let mut pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, show_tab_row); - let filtered_panes = match query_kind { - NavigatorQueryKind::Empty => pane_rows, - NavigatorQueryKind::State(filter) => pane_rows - .into_iter() - .filter(|row| navigator_state_filter_matches(filter, row.status, row.seen)) - .collect::<Vec<_>>(), - // A matching workspace or tab shows its whole subtree; panes - // keep their own match flag so context rows can be dimmed. - NavigatorQueryKind::Text if workspace_matches || tab_matches => { - for row in pane_rows.iter_mut() { - row.matched = navigator_matches(query, &row.search_text); - } - pane_rows - } - NavigatorQueryKind::Text => pane_rows - .into_iter() - .filter(|row| navigator_matches(query, &row.search_text)) - .collect::<Vec<_>>(), - }; - - if show_tab_row && (tab_matches || !filtered_panes.is_empty()) { - rows.push(tab_row); - } - rows.extend(filtered_panes); - } - rows - } - - fn navigator_tab_row(&self, ws_idx: usize, tab_idx: usize) -> NavigatorRow { - let ws = &self.workspaces[ws_idx]; - let tab = &ws.tabs[tab_idx]; - let label = ws - .tab_display_name(tab_idx) - .unwrap_or_else(|| (tab_idx + 1).to_string()); - let (status, seen) = tab_aggregate_state(tab, &self.terminals); - let activity = tab_activity_summary(tab, &self.terminals); - let pane_count = tab.panes.len(); - let meta = if activity.is_empty() { - format!("{pane_count} panes") - } else { - format!("{pane_count} panes · {activity}") - }; - let search_text = format!("{label} {meta}").to_lowercase(); - NavigatorRow { - target: NavigatorTarget::Tab { ws_idx, tab_idx }, - depth: 1, - label, - meta, - status, - seen, - is_current: false, - is_workspace: false, - is_tab: true, - expanded: true, - search_text, - matched: true, - } - } - - fn navigator_pane_rows_for_tab( - &self, - ws_idx: usize, - tab_idx: usize, - show_tab_row: bool, - ) -> Vec<NavigatorRow> { - let Some(ws) = self.workspaces.get(ws_idx) else { - return Vec::new(); - }; - let Some(tab) = ws.tabs.get(tab_idx) else { - return Vec::new(); - }; - let mut rows = Vec::new(); - for pane_id in tab.layout.pane_ids() { - let Some(pane) = tab.panes.get(&pane_id) else { - continue; - }; - let terminal = self.terminals.get(&pane.attached_terminal_id); - let pane_number = ws.public_pane_number(pane_id).unwrap_or(0); - let label = terminal - .and_then(|terminal| terminal.effective_title()) - .or_else(|| { - terminal - .and_then(|terminal| terminal.manual_label.as_deref().map(str::to_string)) - }) - .or_else(|| { - terminal.and_then(|terminal| terminal.agent_name.as_deref().map(str::to_string)) - }) - .or_else(|| { - terminal - .and_then(|terminal| terminal.effective_agent_label().map(str::to_string)) - }) - .or_else(|| { - launch_label(terminal.and_then(|terminal| terminal.launch_argv.as_ref())) - }) - .unwrap_or_else(|| format!("pane {pane_number}")); - let display_agent = terminal.and_then(|terminal| terminal.effective_display_agent()); - let agent_label = display_agent.as_deref().or_else(|| { - terminal - .and_then(|terminal| terminal.agent_name.as_deref()) - .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label())) - }); - let state = terminal - .map(|terminal| terminal.state) - .unwrap_or(AgentState::Unknown); - let status_label = terminal - .map(|terminal| terminal.effective_presentation().state_labels) - .and_then(|labels| labels.get(state_label_text(state, pane.seen)).cloned()); - let status = status_label - .or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string())); - let meta = match (agent_label, status.as_deref()) { - (Some(agent_label), Some(status)) => format!("{agent_label} · {status}"), - (Some(agent_label), None) => agent_label.to_string(), - (None, _) => "shell".to_string(), - }; - let is_current = self.is_active_pane(ws_idx, tab_idx, pane_id); - let search_text = format!("{label} {meta}").to_lowercase(); - rows.push(NavigatorRow { - target: NavigatorTarget::Pane { - ws_idx, - tab_idx, - pane_id, - }, - depth: if show_tab_row { 2 } else { 1 }, - label, - meta, - status: state, - seen: pane.seen, - is_current, - is_workspace: false, - is_tab: false, - expanded: false, - search_text, - matched: true, - }); - } - rows - } - - fn current_navigator_row_index_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> Option<usize> { - let rows = self.navigator_rows_from(terminal_runtimes); - rows.iter() - .position(|row| matches!(row.target, NavigatorTarget::Pane { .. }) && row.is_current) - .or_else(|| rows.iter().position(|row| row.is_current)) - } - - pub(crate) fn ensure_navigator_selection_visible_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let body = self.navigator_body_rect(); - let viewport = body.height as usize; - if viewport == 0 { - self.navigator.scroll = 0; - return; - } - let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)); - let max_scroll = lines.len().saturating_sub(viewport); - let selected_line = - navigator_display_index_of_row(&lines, self.navigator.selected).unwrap_or(0); - if selected_line < self.navigator.scroll { - self.navigator.scroll = selected_line; - } else if selected_line >= self.navigator.scroll.saturating_add(viewport) { - self.navigator.scroll = selected_line.saturating_add(1).saturating_sub(viewport); - } - self.navigator.scroll = self.navigator.scroll.min(max_scroll); - } - - pub(crate) fn navigator_max_scroll_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - viewport: usize, - ) -> usize { - if viewport == 0 { - return 0; - } - navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)) - .len() - .saturating_sub(viewport) - } - - /// After a mouse-wheel scroll, snap the selection to the first selectable - /// row at or below the top of the viewport. - pub(crate) fn align_navigator_selection_to_scroll_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes)); - if let Some(row_idx) = navigator_first_row_at_or_after(&lines, self.navigator.scroll) { - self.navigator.selected = row_idx; - } - self.clamp_navigator_selection_from(terminal_runtimes); - } - - pub(crate) fn move_navigator_selection_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - delta: isize, - ) { - let count = self.navigator_rows_from(terminal_runtimes).len(); - if count == 0 { - self.navigator.selected = 0; - self.navigator.scroll = 0; - return; - } - let current = self.navigator.selected.min(count - 1) as isize; - self.navigator.selected = (current + delta).clamp(0, count as isize - 1) as usize; - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - /// Move the selection by a distance measured in display lines (used for - /// half-page jumps), landing on the nearest selectable row in the move - /// direction. - pub(crate) fn move_navigator_selection_by_lines_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - delta_lines: isize, - ) { - let rows = self.navigator_rows_from(terminal_runtimes); - if rows.is_empty() { - self.navigator.selected = 0; - self.navigator.scroll = 0; - return; - } - let lines = navigator_display_lines(&rows); - let current_line = - navigator_display_index_of_row(&lines, self.navigator.selected.min(rows.len() - 1)) - .unwrap_or(0); - let target_line = - (current_line as isize + delta_lines).clamp(0, lines.len() as isize - 1) as usize; - let row_idx = if delta_lines >= 0 { - navigator_first_row_at_or_after(&lines, target_line) - } else { - lines[..=target_line] - .iter() - .rev() - .find_map(|line| match line { - super::state::NavigatorDisplayLine::Row(idx) => Some(*idx), - super::state::NavigatorDisplayLine::Spacer => None, - }) - }; - if let Some(row_idx) = row_idx { - self.navigator.selected = row_idx; - } - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - /// After the query or state filter changes, select the first row that - /// itself matched the filter, so enter immediately accepts the best match. - /// State filters prefer pane matches over aggregate workspace/tab matches. - pub(crate) fn select_first_navigator_match_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let query = self.navigator.query.trim().to_lowercase(); - let query_kind = navigator_query_kind(&query, self.navigator.state_filter); - if !matches!(query_kind, NavigatorQueryKind::Empty) { - let rows = self.navigator_rows_from(terminal_runtimes); - let idx = if matches!(query_kind, NavigatorQueryKind::State(_)) { - rows.iter() - .position(|row| { - row.matched && matches!(row.target, NavigatorTarget::Pane { .. }) - }) - .or_else(|| rows.iter().position(|row| row.matched)) - } else { - rows.iter().position(|row| row.matched) - }; - if let Some(idx) = idx { - self.navigator.selected = idx; - } - } - self.clamp_navigator_selection_from(terminal_runtimes); - } - - pub(crate) fn clamp_navigator_selection_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let count = self.navigator_rows_from(terminal_runtimes).len(); - self.navigator.selected = self.navigator.selected.min(count.saturating_sub(1)); - self.ensure_navigator_selection_visible_from(terminal_runtimes); - } - - pub(crate) fn toggle_selected_navigator_workspace_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) { - let Some(row) = self - .navigator_rows_from(terminal_runtimes) - .get(self.navigator.selected) - .cloned() - else { - return; - }; - let NavigatorTarget::Workspace { ws_idx } = row.target else { - return; - }; - let Some(workspace_id) = self.workspaces.get(ws_idx).map(|ws| ws.id.clone()) else { - return; - }; - if self.navigator.expanded_workspaces.contains(&workspace_id) { - self.navigator.expanded_workspaces.remove(&workspace_id); - } else { - self.navigator.expanded_workspaces.insert(workspace_id); - } - self.clamp_navigator_selection_from(terminal_runtimes); - } - - #[cfg(test)] - pub(crate) fn accept_navigator_selection(&mut self) -> bool { - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - self.accept_navigator_selection_from(&terminal_runtimes) - } - - pub(crate) fn accept_navigator_selection_from( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> bool { - let Some(row) = self - .navigator_rows_from(terminal_runtimes) - .get(self.navigator.selected) - .cloned() - else { - return false; - }; - self.focus_navigator_target(row.target) - } - - pub(crate) fn focus_navigator_target(&mut self, target: NavigatorTarget) -> bool { - match target { - NavigatorTarget::Workspace { ws_idx } => { - if ws_idx >= self.workspaces.len() { - return false; - } - self.switch_workspace(ws_idx); - self.mode = Mode::Terminal; - true - } - NavigatorTarget::Tab { ws_idx, tab_idx } => { - if ws_idx >= self.workspaces.len() { - return false; - } - let tab_exists = self - .workspaces - .get(ws_idx) - .is_some_and(|ws| tab_idx < ws.tabs.len()); - if !tab_exists { - return false; - } - self.switch_workspace_tab(ws_idx, tab_idx); - self.mode = Mode::Terminal; - true - } - NavigatorTarget::Pane { - ws_idx, - tab_idx, - pane_id, - } => { - if ws_idx >= self.workspaces.len() { - return false; - } - if self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.tabs.get(tab_idx)) - .is_some_and(|tab| tab.panes.contains_key(&pane_id)) - { - self.focus_pane_in_workspace(ws_idx, pane_id); - self.mode = Mode::Terminal; - return true; - } - false - } - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum NavigatorQueryKind { - Empty, - Text, - State(NavigatorStateFilter), -} - -fn navigator_query_kind( - query: &str, - state_filter: Option<NavigatorStateFilter>, -) -> NavigatorQueryKind { - if let Some(filter) = state_filter { - return NavigatorQueryKind::State(filter); - } - if query.is_empty() { - NavigatorQueryKind::Empty - } else { - NavigatorQueryKind::Text - } -} - -fn navigator_state_filter_matches( - filter: NavigatorStateFilter, - state: AgentState, - seen: bool, -) -> bool { - match filter { - NavigatorStateFilter::Blocked => state == AgentState::Blocked, - NavigatorStateFilter::Working => state == AgentState::Working, - NavigatorStateFilter::Idle => state == AgentState::Idle && seen, - NavigatorStateFilter::Done => state == AgentState::Idle && !seen, - } -} - -fn navigator_matches(query: &str, text: &str) -> bool { - text_matches_query(query, text) -} - -fn launch_label(argv: Option<&Vec<String>>) -> Option<String> { - let argv = argv?; - let command = argv.first()?; - std::path::Path::new(command) - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_string) - .or_else(|| Some(command.clone())) -} - -fn state_label_text(state: AgentState, seen: bool) -> &'static str { - match (state, seen) { - (AgentState::Blocked, _) => "blocked", - (AgentState::Working, _) => "working", - (AgentState::Idle, false) => "done", - (AgentState::Idle, true) => "idle", - (AgentState::Unknown, _) => "unknown", - } -} - -fn tab_aggregate_state( - tab: &crate::workspace::Tab, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> (AgentState, bool) { - let mut aggregate = AgentState::Unknown; - let mut seen = true; - for pane in tab.panes.values() { - let Some(terminal) = terminals.get(&pane.attached_terminal_id) else { - continue; - }; - if state_priority(terminal.state, pane.seen) > state_priority(aggregate, seen) { - aggregate = terminal.state; - seen = pane.seen; - } - } - (aggregate, seen) -} - -fn state_priority(state: AgentState, seen: bool) -> u8 { - match (state, seen) { - (AgentState::Blocked, _) => 5, - (AgentState::Working, _) => 4, - (AgentState::Idle, false) => 3, - (AgentState::Idle, true) => 2, - (AgentState::Unknown, _) => 1, - } -} - -fn tab_activity_summary( - tab: &crate::workspace::Tab, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> String { - activity_summary_for_panes(tab.panes.values(), terminals) -} - -fn workspace_activity_summary( - ws: &crate::workspace::Workspace, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> String { - activity_summary_for_panes(ws.tabs.iter().flat_map(|tab| tab.panes.values()), terminals) -} - -fn activity_summary_for_panes<'a>( - panes: impl Iterator<Item = &'a crate::pane::PaneState>, - terminals: &std::collections::HashMap< - crate::terminal::TerminalId, - crate::terminal::TerminalState, - >, -) -> String { - let mut blocked = 0usize; - let mut working = 0usize; - let mut done = 0usize; - for pane in panes { - let Some(terminal) = terminals.get(&pane.attached_terminal_id) else { - continue; - }; - match (terminal.state, pane.seen) { - (AgentState::Blocked, _) => blocked += 1, - (AgentState::Working, _) => working += 1, - (AgentState::Idle, false) => done += 1, - _ => {} - } - } - - let mut parts = Vec::new(); - if blocked > 0 { - parts.push(format!("{blocked} blocked")); - } - if working > 0 { - parts.push(format!("{working} working")); - } - if done > 0 { - parts.push(format!("{done} done")); - } - parts.join(" · ") } // --------------------------------------------------------------------------- @@ -1126,7 +462,6 @@ impl AppState { let workspace_id = self.workspaces[idx].id.clone(); crate::logging::workspace_focused(&workspace_id); self.mark_session_dirty(); - self.ensure_workspace_visible(idx); if let Some(ws) = self.workspaces.get_mut(idx) { let active_tab = ws.active_tab; ws.switch_tab(active_tab); @@ -1134,10 +469,7 @@ impl AppState { public_tab_id_for_index(ws, active_tab).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); } - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); - self.sync_selection_after_focus_navigation(); } } @@ -1162,99 +494,16 @@ impl AppState { crate::logging::workspace_focused(&workspace_id); } self.mark_session_dirty(); - self.ensure_workspace_visible(ws_idx); if let Some(ws) = self.workspaces.get_mut(ws_idx) { ws.switch_tab(tab_idx); let tab_id = public_tab_id_for_index(ws, tab_idx).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); } - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); - self.sync_selection_after_focus_navigation(); true } - pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) { - if idx >= self.workspaces.len() { - return; - } - - if self.view.layout == ViewLayout::Mobile && self.mode == Mode::Navigate { - self.ensure_mobile_workspace_visible(idx); - return; - } - - if self.sidebar_collapsed { - return; - } - - let entries = crate::ui::workspace_list_entries(self); - let Some(target_entry_idx) = entries.iter().position(|entry| { - matches!( - entry, - crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == idx - ) - }) else { - return; - }; - - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - let mut cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); - if cards.iter().any(|card| card.ws_idx == idx) { - return; - } - - if target_entry_idx < self.workspace_scroll { - self.workspace_scroll = target_entry_idx; - return; - } - - while !cards.iter().any(|card| card.ws_idx == idx) { - let previous_scroll = self.workspace_scroll; - self.workspace_scroll = self.workspace_scroll.saturating_add(1); - if self.workspace_scroll == previous_scroll { - break; - } - self.workspace_scroll = crate::ui::normalized_workspace_scroll( - self, - self.view.sidebar_rect, - self.workspace_scroll, - ); - if self.workspace_scroll == previous_scroll { - break; - } - cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect); - if cards.is_empty() { - break; - } - } - } - - fn ensure_mobile_workspace_visible(&mut self, idx: usize) { - let viewport = crate::ui::mobile_switcher_areas(self).viewport; - if viewport.height == 0 { - return; - } - - let row_range = crate::ui::mobile_switcher_workspace_doc_range(self, idx); - let visible_start = self.mobile_switcher_scroll; - let visible_end = visible_start.saturating_add(viewport.height as usize); - if row_range.start < visible_start { - self.mobile_switcher_scroll = row_range.start; - } else if row_range.end > visible_end { - self.mobile_switcher_scroll = row_range.end.saturating_sub(viewport.height as usize); - } - self.mobile_switcher_scroll = self - .mobile_switcher_scroll - .min(crate::ui::mobile_switcher_max_scroll(self)); - } - #[cfg(test)] pub fn switch_tab(&mut self, idx: usize) { if let Some(ws_idx) = self.active { @@ -1267,10 +516,7 @@ impl AppState { let tab_id = public_tab_id_for_index(ws, idx).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); self.mark_session_dirty(); - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); self.record_pane_focus_after_navigation(previous_focus); - self.sync_selection_after_focus_navigation(); } } @@ -1296,77 +542,6 @@ impl AppState { changed } - pub(crate) fn visible_workspace_order(&self) -> Vec<usize> { - // Mobile always shows the worktree tree expanded, so its visible order - // must ignore collapse state to match what the switcher renders. - let entries = if self.view.layout == ViewLayout::Mobile { - crate::ui::workspace_list_entries_expanded(self) - } else { - crate::ui::workspace_list_entries(self) - }; - let order = entries - .into_iter() - .map(|entry| match entry { - crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } => ws_idx, - }) - .collect::<Vec<_>>(); - if order.is_empty() { - (0..self.workspaces.len()).collect() - } else { - order - } - } - - pub(crate) fn workspace_at_visible_position(&self, position: usize) -> Option<usize> { - self.visible_workspace_order().get(position).copied() - } - - pub(crate) fn move_selected_workspace_by_visible_delta(&mut self, delta: isize) { - if self.workspaces.is_empty() { - return; - } - let order = self.visible_workspace_order(); - let current_pos = order - .iter() - .position(|idx| *idx == self.selected) - .unwrap_or(0); - let target_pos = current_pos - .saturating_add_signed(delta) - .min(order.len().saturating_sub(1)); - if let Some(ws_idx) = order.get(target_pos).copied() { - self.selected = ws_idx; - self.ensure_workspace_visible(ws_idx); - } - } - - #[cfg(test)] - pub fn next_workspace(&mut self) { - if self.workspaces.is_empty() { - return; - } - let current = self.active.unwrap_or(self.selected); - let order = self.visible_workspace_order(); - let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); - let next = order[(current_pos + 1) % order.len()]; - self.switch_workspace(next); - } - - #[cfg(test)] - pub fn previous_workspace(&mut self) { - if self.workspaces.is_empty() { - return; - } - let current = self.active.unwrap_or(self.selected); - let order = self.visible_workspace_order(); - let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); - let prev = if current_pos == 0 { - order[order.len() - 1] - } else { - order[current_pos - 1] - }; - self.switch_workspace(prev); - } - pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) -> bool { if source_idx >= self.workspaces.len() || insert_idx > self.workspaces.len() { return false; @@ -1396,7 +571,6 @@ impl AppState { self.selected = selected_id .and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)) .unwrap_or(0); - self.ensure_workspace_visible(self.selected); true } @@ -1463,119 +637,9 @@ impl AppState { self.selected = selected_id .and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)) .unwrap_or(0); - self.ensure_workspace_visible(self.selected); true } - pub fn scroll_tabs_left(&mut self) { - self.tab_scroll_follow_active = false; - self.tab_scroll = self.tab_scroll.saturating_sub(1); - self.refresh_tab_bar_view(); - } - - pub fn scroll_tabs_right(&mut self) { - self.tab_scroll_follow_active = false; - self.tab_scroll = self.tab_scroll.saturating_add(1); - self.refresh_tab_bar_view(); - } - - #[cfg(test)] - pub fn next_tab(&mut self) { - 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(); - self.switch_tab(next); - } - } - } - - #[cfg(test)] - pub fn previous_tab(&mut self) { - 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 - }; - self.switch_tab(prev); - } - } - } - - #[cfg(test)] - pub fn next_agent(&mut self) { - self.cycle_agent_entry(true); - } - - #[cfg(test)] - pub fn previous_agent(&mut self) { - self.cycle_agent_entry(false); - } - - #[cfg(test)] - pub fn focus_agent_entry(&mut self, idx: usize) -> bool { - let entries = crate::ui::agent_panel_entries(self); - let Some(target) = entries.get(idx) else { - return false; - }; - let ws_idx = target.ws_idx; - let pane_id = target.pane_id; - - if self.active == Some(ws_idx) && self.workspaces[ws_idx].focused_pane_id() == Some(pane_id) - { - self.ensure_agent_panel_entry_visible(idx); - return true; - } - - if self.focus_pane_in_workspace(ws_idx, pane_id) { - self.ensure_agent_panel_entry_visible(idx); - return true; - } - false - } - - #[cfg(test)] - fn cycle_agent_entry(&mut self, forward: bool) { - let entries = crate::ui::agent_panel_entries(self); - if entries.is_empty() { - return; - } - - let focused = self - .active - .and_then(|idx| self.workspaces.get(idx)) - .and_then(crate::workspace::Workspace::focused_pane_id); - let current_idx = - focused.and_then(|pane_id| entries.iter().position(|entry| entry.pane_id == pane_id)); - let target_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, - }; - - self.focus_agent_entry(target_idx); - } - - pub(crate) fn ensure_agent_panel_entry_visible(&mut self, idx: usize) { - if self.sidebar_collapsed { - return; - } - - let (_, detail_area) = crate::ui::expanded_sidebar_sections( - self.view.sidebar_rect, - self.sidebar_section_split, - ); - self.agent_panel_scroll = crate::ui::agent_panel_scroll_for_target( - self, - detail_area, - self.agent_panel_scroll, - idx, - ); - } - pub(crate) fn terminal_ids_for_workspace( &self, ws_idx: usize, @@ -1657,7 +721,6 @@ impl AppState { pane_ids: impl IntoIterator<Item = PaneId>, ) { let pane_ids = pane_ids.into_iter().collect::<Vec<_>>(); - self.clear_copy_mode_for_removed_panes(pane_ids.iter().copied()); if self .previous_pane_focus .as_ref() @@ -1674,8 +737,6 @@ impl AppState { if self.workspaces.is_empty() { return; } - self.selection = None; - self.selection_autoscroll = None; self.mark_session_dirty(); let close_indices = self.workspace_close_indices(self.selected); @@ -1700,9 +761,6 @@ impl AppState { if self.workspaces.is_empty() { self.active = None; self.selected = 0; - self.workspace_scroll = 0; - self.tab_scroll = 0; - self.tab_scroll_follow_active = true; } else { // Keep focus on the previously focused workspace if let Some(id) = active_workspace_id { @@ -1714,39 +772,8 @@ impl AppState { self.selected = self.workspaces.len() - 1; } self.active = Some(self.selected); - self.workspace_scroll = self - .workspace_scroll - .min(self.workspaces.len().saturating_sub(1)); - self.ensure_workspace_visible(self.selected); - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); } } - - pub(crate) fn refresh_tab_bar_view(&mut self) { - let area = self.view.tab_bar_rect; - let Some(ws) = self.active.and_then(|idx| self.workspaces.get(idx)) else { - self.tab_scroll = 0; - self.view.tab_hit_areas.clear(); - self.view.tab_scroll_left_hit_area = ratatui::layout::Rect::default(); - self.view.tab_scroll_right_hit_area = ratatui::layout::Rect::default(); - self.view.new_tab_hit_area = ratatui::layout::Rect::default(); - return; - }; - - let layout = crate::ui::compute_tab_bar_view( - ws, - crate::ui::tab_bar_content_area(self, area), - self.tab_scroll, - self.tab_scroll_follow_active, - self.mouse_capture, - ); - self.tab_scroll = layout.scroll; - self.view.tab_hit_areas = layout.tab_hit_areas; - self.view.tab_scroll_left_hit_area = layout.scroll_left_hit_area; - self.view.tab_scroll_right_hit_area = layout.scroll_right_hit_area; - self.view.new_tab_hit_area = layout.new_tab_hit_area; - } } // --------------------------------------------------------------------------- @@ -1852,52 +879,6 @@ impl AppState { } } - #[cfg(test)] - pub fn cycle_pane(&mut self, reverse: bool) { - let Some(ws_idx) = self.active else { - return; - }; - let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else { - return; - }; - let ids = tab.layout.pane_ids(); - if let Some(pos) = ids.iter().position(|id| *id == tab.layout.focused()) { - let target = if reverse { - ids[(pos + ids.len() - 1) % ids.len()] - } else { - ids[(pos + 1) % ids.len()] - }; - self.focus_pane_in_workspace(ws_idx, target); - } - } - - #[cfg(test)] - pub fn last_pane(&mut self) { - let Some(target) = self.previous_pane_focus.clone() else { - return; - }; - let Some((ws_idx, tab_idx)) = self.pane_focus_target_indices(&target) else { - self.previous_pane_focus = None; - return; - }; - let current = self.current_pane_focus_target(); - if current.as_ref() == Some(&target) { - self.previous_pane_focus = None; - return; - } - - self.switch_workspace_tab(ws_idx, tab_idx); - if let Some(tab) = self - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - { - tab.layout.focus_pane(target.pane_id); - self.previous_pane_focus = current; - self.mark_session_dirty(); - } - } - pub(crate) fn apply_pane_zoom( &mut self, ws_idx: usize, @@ -1991,31 +972,8 @@ impl AppState { self.workspace_close_indices(ws_idx).len() >= 2 } - pub(crate) fn begin_workspace_close_confirmation(&mut self, ws_idx: usize) -> bool { - let Some(workspace_id) = self - .workspaces - .get(ws_idx) - .map(|workspace| workspace.id.clone()) - else { - return false; - }; - self.selected = ws_idx; - self.confirm_close_workspace_id = Some(workspace_id); - self.mode = Mode::ConfirmClose; - true - } - - pub(crate) fn take_confirmed_workspace_close_index(&mut self) -> Option<usize> { - let workspace_id = self.confirm_close_workspace_id.take()?; - self.workspaces - .iter() - .position(|workspace| workspace.id == workspace_id) - } - - pub(crate) fn confirm_implicit_worktree_group_close(&mut self, ws_idx: usize) -> bool { - self.confirm_close - && self.workspace_close_would_close_worktree_group(ws_idx) - && self.begin_workspace_close_confirmation(ws_idx) + pub(crate) fn confirm_implicit_worktree_group_close(&self, ws_idx: usize) -> bool { + self.confirm_close && self.workspace_close_would_close_worktree_group(ws_idx) } #[cfg(test)] @@ -2052,8 +1010,6 @@ impl AppState { } } - self.selection = None; - self.selection_autoscroll = None; self.mark_session_dirty(); let terminal_ids = active .and_then(|i| { @@ -2099,8 +1055,6 @@ impl AppState { } } - self.selection = None; - self.selection_autoscroll = None; self.mark_session_dirty(); let should_close_workspace = self .active @@ -2134,173 +1088,70 @@ impl AppState { self.remove_plugin_pane_records(pane_ids); self.remove_unattached_terminal_ids(terminal_ids); crate::logging::tab_closed(&workspace_id, &closing_tab_id); - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); } false } } -// --------------------------------------------------------------------------- -// Selection -// --------------------------------------------------------------------------- - impl AppState { - pub fn clear_selection(&mut self) { - self.selection = None; - self.selection_autoscroll = None; - } - - pub(crate) fn stop_selection_autoscroll_state(&mut self) { - self.selection_autoscroll = None; - } - - pub(crate) fn select_word_at_pane_cell( - &mut self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - viewport_row: u16, - col: u16, - ) -> bool { - // Resolve the active pane cell the double-click landed on. - let Some(ws_idx) = self - .active - .filter(|idx| self.workspaces.get(*idx).is_some()) - else { - return false; - }; - - let Some(info) = self.pane_info_by_id(pane_id) else { - return false; - }; - if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width { - return false; - } - - // Leave mouse input to terminal apps that requested it. - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - else { - return false; - }; - if rt.mouse_reporting_enabled() { - return false; - } - - // Read the visible row and identify the clicked token bounds. - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - let row_selection = Selection::range( - pane_id, - viewport_row, - 0, - info.inner_rect.width.saturating_sub(1), - metrics, - ); - let Some(row_text) = rt.extract_selection(&row_selection) else { - return false; - }; - let Some((start_col, end_col)) = word_bounds_at_column(&row_text, col) else { - return false; - }; - - let mut selection = Selection::range(pane_id, viewport_row, start_col, end_col, metrics); - if !selection.finish() { - return false; - } - - let text = if self.copy_on_select { - let Some(text) = rt - .extract_selection(&selection) - .filter(|text| !text.is_empty()) - else { - self.clear_selection(); - return false; - }; - Some(text) - } else { - None - }; - - self.selection = Some(selection); - self.selection_autoscroll = None; - if let Some(text) = text { - self.request_clipboard_write = Some(text.into_bytes()); - info!("copied double-clicked token to clipboard"); - } - true - } - - pub(crate) fn url_at_pane_cell( + pub(crate) fn url_at_pane_surface_cell( &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + ws_idx: usize, pane_id: crate::layout::PaneId, viewport_row: u16, col: u16, ) -> Option<String> { - let ws_idx = self - .active - .filter(|idx| self.workspaces.get(*idx).is_some())?; - let info = self.pane_info_by_id(pane_id)?; - if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width { - return None; - } - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; - let screen_col = info.inner_rect.x.saturating_add(col); - let screen_row = info.inner_rect.y.saturating_add(viewport_row); - if let Some((_, _, uri)) = rt - .visible_hyperlinks(info.inner_rect) - .into_iter() - .find(|((x, y), _, _)| *x == screen_col && *y == screen_row) - { - return Some(uri); - } - - let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - let visible_selection = Selection::line_range( + let (height, width) = rt.current_size(); + url_at_runtime_cell( + rt, pane_id, - Selection::absolute_row_for_viewport(0, metrics), - Selection::absolute_row_for_viewport(info.inner_rect.height.saturating_sub(1), metrics), - info.inner_rect.width.saturating_sub(1), - ); - let visible_text = rt.extract_selection(&visible_selection)?; - let logical_cell = - logical_cell_for_visible_cell(&visible_text, info.inner_rect.width, viewport_row, col)?; - let line_start = visible_text[..logical_cell.byte_index] - .rfind('\n') - .map_or(0, |idx| idx + 1); - let line_end = visible_text[logical_cell.byte_index..] - .find('\n') - .map_or(visible_text.len(), |idx| logical_cell.byte_index + idx); - let line = visible_text.get(line_start..line_end)?; - url_at_column(line, logical_cell.logical_col).map(str::to_owned) - } - - pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) { - let mut sel = match self.selection.take() { - Some(sel) => sel, - None => return, - }; - if !sel.is_finalized() && !sel.finish() { - return; - } - - let ws_idx = match self.active { - Some(ws_idx) if self.workspaces.get(ws_idx).is_some() => ws_idx, - _ => return, - }; - - let text = self - .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id) - .and_then(|rt| rt.extract_selection(&sel)); - if let Some(text) = text { - if !text.is_empty() { - self.request_clipboard_write = Some(text.into_bytes()); - info!("copied selection to clipboard"); - } - } + ratatui::layout::Rect::new(0, 0, width, height), + viewport_row, + col, + rt.scroll_metrics(), + ) + } +} - self.clear_selection(); +fn url_at_runtime_cell( + runtime: &crate::terminal::TerminalRuntime, + pane_id: crate::layout::PaneId, + area: ratatui::layout::Rect, + viewport_row: u16, + col: u16, + metrics: Option<crate::pane::ScrollMetrics>, +) -> Option<String> { + if viewport_row >= area.height || col >= area.width { + return None; } + let screen_col = area.x.saturating_add(col); + let screen_row = area.y.saturating_add(viewport_row); + if let Some((_, _, uri)) = runtime + .visible_hyperlinks(area) + .into_iter() + .find(|((x, y), _, _)| *x == screen_col && *y == screen_row) + { + return Some(uri); + } + + let visible_selection = Selection::line_range( + pane_id, + crate::selection::absolute_row_for_viewport(0, metrics), + crate::selection::absolute_row_for_viewport(area.height.saturating_sub(1), metrics), + area.width.saturating_sub(1), + ); + let visible_text = runtime.extract_selection(&visible_selection)?; + let logical_cell = logical_cell_for_visible_cell(&visible_text, area.width, viewport_row, col)?; + let line_start = visible_text[..logical_cell.byte_index] + .rfind('\n') + .map_or(0, |idx| idx + 1); + let line_end = visible_text[logical_cell.byte_index..] + .find('\n') + .map_or(visible_text.len(), |idx| logical_cell.byte_index + idx); + let line = visible_text.get(line_start..line_end)?; + url_at_column(line, logical_cell.logical_col).map(str::to_owned) } pub(crate) fn safe_web_url(url: &str) -> Option<&str> { @@ -2336,7 +1187,7 @@ impl CellSpan { /// zero-width marks use display columns, then prefers structured spans that /// users expect to copy whole (URLs and quoted paths), and finally falls back /// to a separator-delimited token. -fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> { +pub(crate) fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> { // Map the row into display cells before doing any word-boundary work. let cells = text_cells(row); let clicked_idx = cell_index_at_column(&cells, col)?; @@ -2921,12 +1772,10 @@ impl AppState { .collect() } } - // Intercepted before this dispatch — in App::handle_internal_event (monolithic) - // or via HeadlessServer forwarding to the foreground client (server); never touch - // AppState. Kept for AppEvent exhaustiveness. + // Host-local effects are intercepted by HeadlessServer and forwarded to the + // foreground client; they never touch AppState. Kept for AppEvent exhaustiveness. AppEvent::TerminalBell { .. } => Vec::new(), AppEvent::ClipboardWrite { .. } => Vec::new(), - AppEvent::PrefixInputSource { .. } => Vec::new(), AppEvent::TerminalCwdReported { pane_id, cwd } => { if !cwd.is_absolute() || !cwd.is_dir() { return Vec::new(); @@ -3051,33 +1900,6 @@ impl AppState { .min() } - pub(crate) fn reconcile_managed_agents_at(&mut self, now: Instant) -> Vec<(usize, PaneId)> { - let mut changed_terminals = std::collections::HashSet::new(); - for (terminal_id, terminal) in &mut self.terminals { - if terminal.reconcile_managed_agent_at(now, false) { - changed_terminals.insert(terminal_id.clone()); - } - } - if changed_terminals.is_empty() { - return Vec::new(); - } - self.mark_session_dirty(); - self.workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, workspace)| { - let changed_terminals = &changed_terminals; - workspace.tabs.iter().flat_map(move |tab| { - tab.panes.iter().filter_map(move |(&pane_id, pane)| { - changed_terminals - .contains(&pane.attached_terminal_id) - .then_some((ws_idx, pane_id)) - }) - }) - }) - .collect() - } - pub(crate) fn publish_pane_process_exit_if_agent( &mut self, pane_id: PaneId, @@ -3274,12 +2096,6 @@ impl AppState { } fn apply_agent_notification_delivery(&mut self, delivery: &AgentNotificationDelivery) { - if self.local_sound_playback { - if let Some(sound) = delivery.sound { - crate::sound::play(sound, &self.sound); - } - } - if matches!( self.toast_config.delivery, crate::config::ToastDelivery::Herdr @@ -3350,15 +2166,6 @@ impl AppState { return; }; - if self - .selection - .as_ref() - .is_some_and(|s| s.pane_id == pane_id) - { - self.selection = None; - self.selection_autoscroll = None; - } - let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id); let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx); self.pane_id_aliases.retain(|_, alias| *alias != pane_id); @@ -3404,10 +2211,6 @@ impl AppState { if self.selected >= self.workspaces.len() { self.selected = self.workspaces.len() - 1; } - self.workspace_scroll = self - .workspace_scroll - .min(self.workspaces.len().saturating_sub(1)); - self.ensure_workspace_visible(self.selected); } } else { self.remove_unattached_terminal_ids(pane_terminal_id); @@ -3667,412 +2470,6 @@ mod tests { } #[test] - fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() { - let mut state = app_with_workspaces(&["single", "multi"]); - state.workspaces[1].test_add_tab(Some("tests")); - state.ensure_test_terminals(); - - state.open_navigator(); - let rows = state.navigator_rows(); - - assert!(!rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. } - ))); - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 1, - tab_idx: 0 - } - ))); - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 1, - tab_idx: 1 - } - ))); - } - - #[test] - fn navigator_search_matches_named_tabs_in_single_tab_workspaces() { - let mut state = app_with_workspaces(&["multi", "single"]); - state.workspaces[0].tabs[0].custom_name = Some("Foo".into()); - state.workspaces[0].test_add_tab(Some("Bar")); - state.workspaces[1].tabs[0].custom_name = Some("Baz".into()); - state.ensure_test_terminals(); - - state.open_navigator(); - state.navigator.query = "foo".into(); - assert!(state.navigator_rows().iter().any(|row| { - row.matched - && matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 0, - tab_idx: 0 - } - ) - })); - - state.navigator.query = "baz".into(); - state.select_first_navigator_match_from(&crate::terminal::TerminalRuntimeRegistry::new()); - let rows = state.navigator_rows(); - assert!(rows - .get(state.navigator.selected) - .is_some_and(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Tab { - ws_idx: 1, - tab_idx: 0 - } - ))); - assert!(!rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Workspace { ws_idx: 0 } - | crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. } - | crate::app::state::NavigatorTarget::Pane { ws_idx: 0, .. } - ))); - - assert!(state.accept_navigator_selection()); - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].active_tab_index(), 0); - assert_eq!(state.mode, Mode::Terminal); - } - - #[tokio::test] - async fn navigator_rows_match_live_root_runtime_cwd_workspace_label() { - let unique = format!( - "herdr-navigator-runtime-cwd-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let root = std::env::temp_dir().join(unique); - let stale_cwd = root.join("issue-264-nix-support"); - let live_cwd = root.join("herdr"); - std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); - std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); - - let mut state = AppState::test_new(); - let mut workspace = Workspace::test_new("stale-name"); - workspace.custom_name = None; - workspace.identity_cwd = stale_cwd.clone(); - let pane = workspace.tabs[0].root_pane; - state.workspaces = vec![workspace]; - state.ensure_test_terminals(); - let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap(); - state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; - - let (events, _) = tokio::sync::mpsc::channel(4); - let runtime = crate::terminal::TerminalRuntime::spawn( - pane, - 24, - 80, - live_cwd.clone(), - 0, - crate::terminal_theme::TerminalTheme::default(), - None, - crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), - &crate::pane::PaneLaunchEnv::default(), - events, - std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(crate::render_signal::RenderSignal::new()), - ) - .unwrap(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - let mut runtime_registry = crate::terminal::TerminalRuntimeRegistry::new(); - runtime_registry.insert(terminal_id, runtime); - state.open_navigator_from(&runtime_registry); - state.navigator.query = "herdr".into(); - let rows = state.navigator_rows_from(&runtime_registry); - - for (_, runtime) in runtime_registry.drain() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(root); - - // The workspace matched by its live cwd label; its subtree cascades in - // as context. - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].label, "herdr (1)"); - assert!(rows[0].matched); - assert!(!rows[1].matched); - } - - #[test] - fn navigator_rows_include_shell_and_agent_panes() { - let mut state = app_with_workspaces(&["one"]); - let shell = state.workspaces[0].tabs[0].root_pane; - let agent = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap(); - let terminal = state.terminals.get_mut(&agent_terminal_id).unwrap(); - terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); - - state.open_navigator(); - let rows = state.navigator_rows(); - - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent - ) && row.meta.contains("claude"))); - } - - #[test] - fn opening_navigator_selects_current_pane_and_expands_attention_workspaces() { - let mut state = app_with_workspaces(&["one", "two"]); - let blocked = state.workspaces[1].tabs[0].root_pane; - let blocked_terminal_id = state.workspaces[1].terminal_id(blocked).cloned().unwrap(); - state - .terminals - .get_mut(&blocked_terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Codex), AgentState::Blocked); - - state.open_navigator(); - let selected = state.navigator_rows()[state.navigator.selected].clone(); - - assert!(selected.is_current); - assert!(state - .navigator - .expanded_workspaces - .contains(&state.workspaces[0].id)); - assert!(state - .navigator - .expanded_workspaces - .contains(&state.workspaces[1].id)); - } - - #[test] - fn accepting_navigator_pane_switches_workspace_tab_and_focus() { - let mut state = app_with_workspaces(&["one", "two"]); - let target = state.workspaces[1].tabs[0].root_pane; - state.open_navigator(); - state - .navigator - .expanded_workspaces - .insert(state.workspaces[1].id.clone()); - state.navigator.selected = state - .navigator_rows() - .iter() - .position(|row| { - matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == target - ) - }) - .unwrap(); - - assert!(state.accept_navigator_selection()); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(target)); - assert_eq!(state.mode, Mode::Terminal); - } - - #[test] - fn navigator_idle_search_matches_idle_agents_not_plain_shells() { - let mut state = app_with_workspaces(&["one"]); - let shell = state.workspaces[0].tabs[0].root_pane; - let agent = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap(); - state - .terminals - .get_mut(&agent_terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Claude), AgentState::Idle); - - state.open_navigator(); - state.navigator.query = "idle".into(); - let rows = state.navigator_rows(); - - assert!(rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent - ))); - assert!(!rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - } - - #[test] - fn navigator_search_only_matches_visible_row_text() { - let mut state = app_with_workspaces(&["one"]); - state.workspaces[0].identity_cwd = "/tmp/herdr-worktrees/issue-work".into(); - - state.open_navigator(); - state.navigator.query = "work".into(); - - assert!(state.navigator_rows().is_empty()); - } - - #[test] - fn navigator_state_filter_is_separate_from_text_search() { - let mut state = app_with_workspaces(&["one"]); - let shell = state.workspaces[0].tabs[0].root_pane; - let working = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - let shell_terminal_id = state.workspaces[0].terminal_id(shell).cloned().unwrap(); - state - .terminals - .get_mut(&shell_terminal_id) - .unwrap() - .set_manual_label("wheel notes".into()); - let working_terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap(); - state - .terminals - .get_mut(&working_terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Codex), AgentState::Working); - - state.open_navigator(); - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - let state_rows = state.navigator_rows(); - - assert!(state_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working - ))); - assert!(!state_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - - state.navigator.state_filter = None; - state.navigator.query = "w".into(); - let text_rows = state.navigator_rows(); - - assert!(text_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell - ))); - assert!( - text_rows.iter().any(|row| matches!( - row.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working - )), - "literal one-letter search may still match visible state text" - ); - } - - #[test] - fn navigator_search_filters_panes_but_keeps_workspace_context() { - let mut state = app_with_workspaces(&["one"]); - let root = state.workspaces[0].tabs[0].root_pane; - let terminal_id = state.workspaces[0].terminal_id(root).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_manual_label("weekly review".into()); - state.open_navigator(); - state.navigator.query = "weekly".into(); - - let rows = state.navigator_rows(); - - assert!(rows.iter().any(|row| row.is_workspace)); - assert!(rows - .iter() - .any(|row| !row.is_workspace && row.label.contains("weekly"))); - } - - #[test] - fn navigator_workspace_match_cascades_full_subtree() { - let mut state = app_with_workspaces(&["one", "two"]); - let root = state.workspaces[0].tabs[0].root_pane; - let extra = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - for pane in [root, extra] { - let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_manual_label("unrelated".into()); - } - - state.open_navigator(); - state.navigator.query = "one".into(); - let rows = state.navigator_rows(); - - // Both panes cascade in even though only the workspace label matched, - // and only the workspace carries the matched flag. - let pane_rows: Vec<_> = rows.iter().filter(|row| !row.is_workspace).collect(); - assert_eq!(pane_rows.len(), 2); - assert!(pane_rows.iter().all(|row| !row.matched)); - assert!(rows.iter().any(|row| row.is_workspace && row.matched)); - assert!(!rows.iter().any(|row| row.label.starts_with("two"))); - } - - #[test] - fn navigator_search_selects_first_self_match() { - let mut state = app_with_workspaces(&["one", "two"]); - let pane = state.workspaces[1].tabs[0].root_pane; - state.ensure_test_terminals(); - let terminal_id = state.workspaces[1].terminal_id(pane).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_manual_label("pi ui build".into()); - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.open_navigator_from(&terminal_runtimes); - state.navigator.query = "ui".into(); - state.select_first_navigator_match_from(&terminal_runtimes); - - let rows = state.navigator_rows_from(&terminal_runtimes); - let selected = &rows[state.navigator.selected]; - assert!(matches!( - selected.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == pane - )); - } - - #[test] - fn navigator_state_filter_selects_matching_pane_over_workspace() { - let mut state = app_with_workspaces(&["one"]); - let working = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - let terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap(); - state - .terminals - .get_mut(&terminal_id) - .unwrap() - .set_detected_state(Some(Agent::Codex), AgentState::Working); - - let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - state.open_navigator_from(&terminal_runtimes); - state.navigator.state_filter = Some(NavigatorStateFilter::Working); - state.select_first_navigator_match_from(&terminal_runtimes); - - let rows = state.navigator_rows_from(&terminal_runtimes); - let selected = &rows[state.navigator.selected]; - assert!(matches!( - selected.target, - crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working - )); - } - - #[test] fn apply_workspace_git_statuses_updates_matching_workspace() { let mut state = app_with_workspaces(&["one", "two"]); let first_id = state.workspaces[0].id.clone(); @@ -4219,196 +2616,6 @@ mod tests { assert_eq!(state.workspaces[0].worktree_space().cloned(), membership); } - fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) { - set_agent_state(state, ws_idx, tab_idx, pane_id, AgentState::Idle); - } - - fn set_agent_state( - state: &mut AppState, - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - agent_state: AgentState, - ) { - state.ensure_test_terminals(); - let terminal_id = state.workspaces[ws_idx].tabs[tab_idx] - .panes - .get(&pane_id) - .unwrap() - .attached_terminal_id - .clone(); - if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.set_detected_state(Some(Agent::Pi), agent_state); - } - } - - fn transition_agent_state(state: &mut AppState, pane_id: PaneId, agent_state: AgentState) { - state - .update_terminal_state(pane_id, |terminal| { - Some(terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Pi), - agent_state, - matches!(agent_state, AgentState::Blocked), - false, - false, - false, - std::time::Instant::now(), - )) - }) - .expect("agent state transition should update pane state"); - } - - #[test] - fn next_agent_cycles_agent_panel_entries() { - let mut first = Workspace::test_new("one"); - let first_root = first.tabs[0].root_pane; - let first_second = first.test_split(Direction::Horizontal); - first.tabs[0].layout.focus_pane(first_root); - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - - let mut state = AppState::test_new(); - state.workspaces = vec![first, second]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - mark_agent(&mut state, 0, 0, first_root); - mark_agent(&mut state, 0, 0, first_second); - mark_agent(&mut state, 1, 0, second_root); - - state.next_agent(); - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second)); - - state.next_agent(); - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - - state.previous_agent(); - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second)); - state.assert_invariants_for_test(); - } - - #[test] - fn focus_agent_entry_uses_agent_panel_order() { - let mut first = Workspace::test_new("one"); - let first_root = first.tabs[0].root_pane; - let first_second = first.test_split(Direction::Horizontal); - first.tabs[0].layout.focus_pane(first_root); - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - - let mut state = AppState::test_new(); - state.workspaces = vec![first, second]; - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - mark_agent(&mut state, 0, 0, first_root); - mark_agent(&mut state, 0, 0, first_second); - mark_agent(&mut state, 1, 0, second_root); - - assert!(state.focus_agent_entry(2)); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - state.assert_invariants_for_test(); - } - - #[test] - fn focus_agent_entry_succeeds_for_already_focused_agent() { - let mut state = app_with_workspaces(&["one"]); - let root = state.workspaces[0].tabs[0].root_pane; - mark_agent(&mut state, 0, 0, root); - - assert!(state.focus_agent_entry(0)); - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - state.assert_invariants_for_test(); - } - - #[test] - fn next_agent_cycles_priority_sorted_agent_panel_entries() { - let mut first = Workspace::test_new("one"); - let first_root = first.tabs[0].root_pane; - let first_second = first.test_split(Direction::Horizontal); - first.tabs[0].layout.focus_pane(first_root); - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - - let mut state = AppState::test_new(); - state.workspaces = vec![first, second]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - set_agent_state(&mut state, 0, 0, first_root, AgentState::Idle); - set_agent_state(&mut state, 0, 0, first_second, AgentState::Working); - set_agent_state(&mut state, 1, 0, second_root, AgentState::Blocked); - - state.next_agent(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - state.assert_invariants_for_test(); - } - - #[test] - fn priority_sort_keeps_recently_changed_idle_agent_above_older_idle_agent() { - let mut workspace = Workspace::test_new("one"); - let first = workspace.tabs[0].root_pane; - let second = workspace.test_split(Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(first); - - let mut state = AppState::test_new(); - state.workspaces = vec![workspace]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; - - transition_agent_state(&mut state, first, AgentState::Idle); - transition_agent_state(&mut state, second, AgentState::Working); - assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second); - - transition_agent_state(&mut state, second, AgentState::Idle); - - assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second); - state.assert_invariants_for_test(); - } - - #[test] - fn previous_agent_keeps_wrapped_target_visible_in_agent_panel() { - let mut workspace = Workspace::test_new("one"); - let root = workspace.tabs[0].root_pane; - for idx in 1..8 { - workspace.test_add_tab(Some(&format!("tab-{idx}"))); - } - - let mut state = AppState::test_new(); - state.workspaces = vec![workspace]; - state.ensure_test_terminals(); - state.active = Some(0); - state.selected = 0; - state.mode = Mode::Terminal; - for tab_idx in 0..state.workspaces[0].tabs.len() { - let pane_id = state.workspaces[0].tabs[tab_idx].root_pane; - mark_agent(&mut state, 0, tab_idx, pane_id); - } - state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); - - state.previous_agent(); - - let last_idx = state.workspaces[0].tabs.len() - 1; - assert_eq!(state.workspaces[0].active_tab, last_idx); - assert!(state.agent_panel_scroll > 0); - state.assert_invariants_for_test(); - } - #[test] fn switch_workspace_updates_active_and_selected() { let mut state = app_with_workspaces(&["a", "b", "c"]); @@ -4418,137 +2625,6 @@ mod tests { } #[test] - fn last_pane_toggles_to_previous_focus_in_active_tab() { - let mut state = app_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - - state.focus_pane_in_workspace(0, root); - state.focus_pane_in_workspace(0, right); - state.last_pane(); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - - state.last_pane(); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); - } - - #[test] - fn removing_background_pane_preserves_last_pane_history() { - let mut state = app_with_workspaces(&["test"]); - let root = state.workspaces[0].tabs[0].root_pane; - let right = state.workspaces[0].test_split(Direction::Horizontal); - let background = state.workspaces[0].test_split(Direction::Horizontal); - - state.focus_pane_in_workspace(0, root); - state.focus_pane_in_workspace(0, right); - state.workspaces[0].remove_pane(background); - state.last_pane(); - - assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); - } - - #[test] - fn last_pane_jumps_across_workspaces_and_tabs() { - let mut state = app_with_workspaces(&["one", "two"]); - let first_root = state.workspaces[0].tabs[0].root_pane; - let second_tab = state.workspaces[1].test_add_tab(Some("logs")); - let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane; - - state.focus_pane_in_workspace(0, first_root); - state.focus_pane_in_workspace(1, second_tab_root); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, 0); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); - - state.last_pane(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].active_tab, second_tab); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root)); - } - - #[test] - fn last_pane_tracks_tab_and_workspace_switches() { - let mut state = app_with_workspaces(&["one", "two"]); - let first_root = state.workspaces[0].tabs[0].root_pane; - let first_second_tab = state.workspaces[0].test_add_tab(Some("logs")); - let first_second_root = state.workspaces[0].tabs[first_second_tab].root_pane; - let second_root = state.workspaces[1].tabs[0].root_pane; - - state.switch_tab(first_second_tab); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, 0); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); - - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, first_second_tab); - assert_eq!( - state.workspaces[0].focused_pane_id(), - Some(first_second_root) - ); - - state.switch_workspace(1); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].active_tab, first_second_tab); - assert_eq!( - state.workspaces[0].focused_pane_id(), - Some(first_second_root) - ); - - state.last_pane(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); - } - - #[test] - fn last_pane_tracks_cross_workspace_tab_selection() { - let mut state = app_with_workspaces(&["one", "two"]); - let first_root = state.workspaces[0].tabs[0].root_pane; - let second_first_root = state.workspaces[1].tabs[0].root_pane; - let second_tab = state.workspaces[1].test_add_tab(Some("logs")); - let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane; - - state.switch_workspace_tab(1, second_tab); - state.last_pane(); - - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); - - state.last_pane(); - - assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].active_tab, second_tab); - assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root)); - assert_ne!(second_first_root, second_tab_root); - } - - #[test] - fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() { - let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); - - state.switch_workspace(7); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14)); - - assert!(state - .view - .workspace_card_areas - .iter() - .any(|card| card.ws_idx == 7)); - } - - #[test] fn switch_workspace_marks_panes_seen() { let mut state = app_with_workspaces(&["a", "b"]); // Mark a pane in workspace 1 as unseen @@ -4738,34 +2814,6 @@ mod tests { } #[test] - fn pane_died_self_closing_earlier_workspace_keeps_focus() { - let names = (0..20).map(|i| format!("ws{i:02}")).collect::<Vec<_>>(); - let name_refs = names.iter().map(String::as_str).collect::<Vec<_>>(); - let mut state = app_with_workspaces(&name_refs); - state.selected = 1; - state.active = Some(1); - // Constrain the sidebar viewport so the focused workspace starts - // off-screen: 20 workspaces cannot all fit in 12 rows, so index 1 is - // hidden at maximum scroll regardless of individual card height. - state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 30, 12); - state.workspace_scroll = - crate::ui::normalized_workspace_scroll(&state, state.view.sidebar_rect, usize::MAX / 2); - let cards = crate::ui::compute_workspace_card_areas(&state, state.view.sidebar_rect); - assert!(cards.iter().all(|card| card.ws_idx != 1)); - - let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); - state.handle_pane_died(pane_id); - - assert_eq!(state.workspaces.len(), 19); - assert_eq!(state.workspaces[0].display_name(), "ws01"); - assert_eq!(state.selected, 0); - assert_eq!(state.active, Some(0)); - let cards = crate::ui::compute_workspace_card_areas(&state, state.view.sidebar_rect); - assert!(cards.iter().any(|card| card.ws_idx == 0)); - state.assert_invariants_for_test(); - } - - #[test] fn pane_died_last_pane_removes_workspace() { let mut state = app_with_workspaces(&["a", "b"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); @@ -4813,55 +2861,6 @@ mod tests { assert_eq!(state.workspaces.len(), 1); state.assert_invariants_for_test(); } - - #[test] - fn pane_died_unrelated_pane_preserves_selection() { - // Two workspaces; user is selecting text in workspace 0. - // A pane in workspace 1 dies — selection must be preserved. - let mut state = app_with_workspaces(&["active", "bg"]); - let active_pane = *state.workspaces[0].panes.keys().next().unwrap(); - let bg_pane = *state.workspaces[1].panes.keys().next().unwrap(); - - state.selection = Some(crate::selection::Selection::anchor(active_pane, 0, 0, None)); - state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { - direction: crate::app::state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - - state.handle_pane_died(bg_pane); - - assert!(state.selection.is_some()); - assert!(state.selection_autoscroll.is_some()); - state.assert_invariants_for_test(); - } - - #[test] - fn pane_died_same_pane_clears_selection() { - let mut state = app_with_workspaces(&["test"]); - let first_id = state.workspaces[0].tabs[0].root_pane; - let second_id = state.workspaces[0].test_split(Direction::Horizontal); - state.ensure_test_terminals(); - - state.selection = Some(crate::selection::Selection::anchor(second_id, 0, 0, None)); - state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll { - direction: crate::app::state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - - state.handle_pane_died(second_id); - - // first_id still alive, workspace stays, but selection was on the dying pane - assert!(state.selection.is_none()); - assert!(state.selection_autoscroll.is_none()); - assert_eq!(state.workspaces[0].panes.len(), 1); - assert_eq!(state.workspaces[0].panes.keys().next().unwrap(), &first_id); - state.assert_invariants_for_test(); - } - #[test] fn state_changed_updates_pane() { let mut state = app_with_workspaces(&["test"]); @@ -5832,13 +3831,21 @@ mod tests { 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)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert_eq!(state.view.pane_infos.len(), 1); assert_eq!(state.view.pane_infos[0].id, root); state.navigate_pane(NavDirection::Right); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert!(state.workspaces[0].zoomed); assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); @@ -5853,7 +3860,11 @@ mod tests { 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); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let before_root_rect = state .view .pane_infos @@ -5870,7 +3881,11 @@ mod tests { .rect; assert!(state.swap_pane(NavDirection::Right)); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); assert_eq!( @@ -5902,10 +3917,18 @@ mod tests { 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)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert!(state.swap_pane(NavDirection::Right)); - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); assert!(state.workspaces[0].zoomed); assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); @@ -5913,7 +3936,11 @@ mod tests { assert_eq!(state.view.pane_infos[0].id, root); state.workspaces[0].zoomed = false; - crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_rect = state .view .pane_infos @@ -6088,8 +4115,7 @@ mod tests { let deferred = state.close_pane(); assert!(deferred); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.selected, 0); + assert_eq!(state.selected, 1); assert_eq!(state.workspaces.len(), 2); } @@ -6102,7 +4128,6 @@ mod tests { state.close_tab(); - assert_eq!(state.request_remove_linked_worktree, None); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); } @@ -6118,8 +4143,7 @@ mod tests { let deferred = state.close_tab(); assert!(deferred); - assert_eq!(state.mode, Mode::ConfirmClose); - assert_eq!(state.selected, 0); + assert_eq!(state.selected, 1); assert_eq!(state.workspaces.len(), 2); } @@ -6132,7 +4156,6 @@ mod tests { state.close_pane(); - assert_eq!(state.request_remove_linked_worktree, None); assert_eq!(state.workspaces.len(), 1); assert_eq!(state.workspaces[0].display_name(), "selected"); } diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 29c2c317..4bc12c24 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -357,7 +357,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/agent_view.rs b/src/app/agent_view.rs index ba50ee3a..58c1d7de 100644 --- a/src/app/agent_view.rs +++ b/src/app/agent_view.rs @@ -7,7 +7,7 @@ use crate::api::schema::{ }; use crate::ui::AgentPanelEntry; -use super::{AppState, Mode}; +use super::AppState; const MAX_FILTER_DEPTH: usize = 8; const MAX_FILTER_NODES: usize = 64; @@ -78,11 +78,7 @@ pub(crate) fn apply_agent_view(app: &AppState, entries: &mut Vec<AgentPanelEntry } pub(crate) fn presented_workspace_idx(app: &AppState) -> Option<usize> { - if app.mode == Mode::Navigate { - app.workspaces.get(app.selected).map(|_| app.selected) - } else { - app.active - } + app.active } fn normalize_source(source: &str) -> Result<String, String> { @@ -449,6 +445,10 @@ mod tests { state } + fn projected_entries(state: &AppState) -> Vec<crate::ui::AgentPanelEntry> { + crate::ui::agent_panel_entries_from(state, &crate::terminal::TerminalRuntimeRegistry::new()) + } + fn current_workspace_view() -> AgentViewSetParams { AgentViewSetParams { source: "example.views".to_string(), @@ -468,16 +468,15 @@ mod tests { let mut state = state_with_agents(); state.agent_view_override = Some(current_workspace_view()); - assert_eq!(crate::ui::agent_panel_entries(&state)[0].ws_idx, 0); + assert_eq!(projected_entries(&state)[0].ws_idx, 0); - state.mode = Mode::Navigate; - state.selected = 1; - let entries = crate::ui::agent_panel_entries(&state); + state.active = Some(1); + let entries = projected_entries(&state); assert_eq!(entries.len(), 1); assert_eq!(entries[0].ws_idx, 1); - state.mode = Mode::Settings; - let entries = crate::ui::agent_panel_entries(&state); + state.active = Some(0); + let entries = projected_entries(&state); assert_eq!(entries.len(), 1); assert_eq!(entries[0].ws_idx, 0); } @@ -513,7 +512,7 @@ mod tests { }], }); - let entries = crate::ui::agent_panel_entries(&state); + let entries = projected_entries(&state); assert_eq!(entries.len(), 2); assert_eq!(entries[0].ws_idx, 1); assert_eq!(entries[1].ws_idx, 0); @@ -547,7 +546,7 @@ mod tests { sort: Vec::new(), }); - let entries = crate::ui::agent_panel_entries(&state); + let entries = projected_entries(&state); assert_eq!(entries.len(), 1); assert_eq!(entries[0].agent_kind_label.as_deref(), Some("custom-agent")); } diff --git a/src/app/agents.rs b/src/app/agents.rs index b2aac01c..f25383fe 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -80,7 +80,7 @@ impl App { self.state .focus_pane_in_workspace(resolved.ws_idx, resolved.pane_id); self.state.mark_active_tab_seen(); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| TerminalTargetError::NotFound { target: target.to_string(), diff --git a/src/app/api.rs b/src/app/api.rs index 5d347cf7..ff5a7dc4 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -8,7 +8,7 @@ mod layouts; mod pane_graphics; mod panes; pub(crate) mod plugins; -mod responses; +pub(super) mod responses; mod session; mod tabs; mod workspaces; @@ -28,36 +28,6 @@ enum RuntimeExitAction { } impl App { - pub(crate) fn dispatch_api_request( - &mut self, - id: &'static str, - method: crate::api::schema::Method, - ) -> String { - self.handle_api_request(crate::api::schema::Request { - id: id.to_string(), - method, - }) - } - - pub(crate) fn dispatch_deferred_api_request( - &mut self, - id: &'static str, - method: crate::api::schema::Method, - ) -> Option<String> { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - if !self.handle_deferred_worktree_api_request( - crate::api::schema::Request { - id: id.to_string(), - method, - }, - respond_to, - ) { - return None; - } - - response_rx.try_recv().ok() - } - pub(crate) fn handle_internal_event_with_render_impact(&mut self, ev: AppEvent) -> bool { match ev { AppEvent::GitStatusRefreshed { @@ -113,37 +83,10 @@ impl App { &mut self, ev: AppEvent, ) -> Vec<crate::app::actions::PaneStateUpdate> { - if let AppEvent::TerminalBell { count, .. } = ev { - if let Err(err) = - crate::terminal_effects::write_terminal_bells(&mut std::io::stdout(), count) - { - tracing::warn!(err = %err, "failed to emit terminal bell"); - } - return Vec::new(); - } - - if let AppEvent::ClipboardWrite { content } = ev { - #[cfg(not(test))] - crate::selection::write_osc52_bytes(&content); - #[cfg(test)] - let _ = content; - self.show_clipboard_feedback(); - return Vec::new(); - } - - if let AppEvent::PrefixInputSource { active } = ev { - // Monolithic path applies the switch here. Server mode forwards it to the foreground - // client instead (see HeadlessServer::handle_internal_event_with_forwarding); should an - // App-internal drain consume the event before the forwarding drain, the flag keeps the - // switch out of the headless server process. - if !self.local_input_source_switch { - return Vec::new(); - } - if active { - self.prefix_input_source.switch_to_ascii(); - } else { - self.prefix_input_source.restore(); - } + if matches!( + &ev, + AppEvent::TerminalBell { .. } | AppEvent::ClipboardWrite { .. } + ) { return Vec::new(); } @@ -198,12 +141,12 @@ impl App { } if let AppEvent::WorktreeAddFinished(result) = ev { - self.handle_worktree_add_finished(*result); + self.handle_api_worktree_add_finished(*result); return Vec::new(); } if let AppEvent::WorktreeRemoveFinished(result) = ev { - self.handle_worktree_remove_finished(*result); + self.handle_api_worktree_remove_finished(*result); return Vec::new(); } @@ -222,7 +165,6 @@ impl App { self.sync_full_lifecycle_authority_detection_pauses(); self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); self.emit_pane_state_update(&update); - self.emit_terminal_or_system_agent_notifications(std::slice::from_ref(&update)); } if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell && self.respawn_shell_for_launch_pane(*pane_id) @@ -308,6 +250,9 @@ impl App { let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. }); let previous_toast = self.state.toast.clone(); let pane_updates = self.state.handle_app_event(ev); + if update_ready.is_some() { + self.state.latest_release_notes = crate::release_notes::load_latest(); + } if let Some(agents) = manifest_update_agents { self.reset_agent_detection_for_agents(&agents); } @@ -353,26 +298,6 @@ impl App { self.emit_layout_updated_event(ws_idx, tab_idx); } - if self.local_terminal_notifications - && matches!( - self.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System - ) - { - let notify = match self.state.toast_config.delivery { - crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification, - crate::config::ToastDelivery::System => crate::platform::show_desktop_notification, - _ => unreachable!("toast delivery was checked above"), - }; - - if let Some((version, install_command)) = update_ready { - let instruction = crate::update::update_install_instruction(&install_command); - let _ = notify(&format!("v{version} available"), Some(&instruction)); - } else if self.state.toast_config.delay_seconds == 0 { - self.emit_terminal_or_system_agent_notifications(&pane_updates); - } - } - self.sync_toast_deadline(previous_toast); self.shutdown_detached_terminal_runtimes(); pane_updates @@ -464,18 +389,6 @@ impl App { } } - pub(crate) fn show_clipboard_feedback(&mut self) { - if !self.state.toast_config.clipboard.enabled { - self.state.copy_feedback = None; - self.copy_feedback_deadline = None; - return; - } - self.state.copy_feedback = Some(crate::app::state::CopyFeedback { - message: "copied to clipboard".to_string(), - }); - self.copy_feedback_deadline = Some(Instant::now() + super::COPY_FEEDBACK_DURATION); - } - fn restore_overlay_after_exit( &mut self, overlay: OverlayPaneState, @@ -660,78 +573,6 @@ impl App { } } - fn emit_terminal_or_system_agent_notifications( - &self, - pane_updates: &[crate::app::actions::PaneStateUpdate], - ) { - if !self.local_terminal_notifications - || self.state.toast_config.delay_seconds != 0 - || !matches!( - self.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System - ) - { - return; - } - - let notify = match self.state.toast_config.delivery { - crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification, - crate::config::ToastDelivery::System => crate::platform::show_desktop_notification, - _ => return, - }; - - for update in pane_updates { - let is_active_tab = self - .state - .pane_is_in_active_tab(update.ws_idx, update.pane_id); - let suppress_active_tab_notifications = - crate::app::actions::active_tab_suppresses_notifications( - is_active_tab, - self.state.outer_terminal_focus, - ); - let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( - suppress_active_tab_notifications, - update, - ) else { - continue; - }; - let Some(ws) = self.state.workspaces.get(update.ws_idx) else { - continue; - }; - let Some(pane) = ws - .tabs - .iter() - .find_map(|tab| tab.panes.get(&update.pane_id)) - else { - continue; - }; - let Some(agent_label) = self - .state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label()) - else { - continue; - }; - let event_text = match kind { - ToastKind::NeedsAttention => "needs attention", - ToastKind::Finished => "finished", - ToastKind::UpdateInstalled => "updated", - }; - let workspace_label = - ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); - let _ = notify( - &format!("{} {}", agent_label, event_text), - Some(&crate::app::actions::notification_context( - ws, - &workspace_label, - update.ws_idx, - update.pane_id, - )), - ); - } - } - pub(crate) fn sync_toast_deadline( &mut self, previous_toast: Option<crate::app::state::ToastNotification>, @@ -748,33 +589,6 @@ impl App { } } - pub(crate) fn emit_delayed_client_local_agent_notifications( - &self, - deliveries: &[crate::app::state::AgentNotificationDelivery], - ) { - if !self.local_terminal_notifications - || !matches!( - self.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System - ) - { - return; - } - - let notify = match self.state.toast_config.delivery { - crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification, - crate::config::ToastDelivery::System => crate::platform::show_desktop_notification, - _ => unreachable!("toast delivery was checked above"), - }; - - for delivery in deliveries { - let Some(toast) = &delivery.client_notification else { - continue; - }; - let _ = notify(&toast.title, Some(&toast.context)); - } - } - pub(crate) fn refresh_agent_notification_delivery_contexts( &mut self, deliveries: &mut [crate::app::state::AgentNotificationDelivery], @@ -843,7 +657,7 @@ impl App { self.sync_focus_events_with_outer_event(None); } - pub(super) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) { + pub(crate) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) { self.sync_focus_events_with_outer_event(Some(event)); } @@ -922,6 +736,7 @@ impl App { runtime.try_send_focus_event(event); } + #[cfg(test)] pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String { self.drain_all_internal_events(); self.handle_api_request_after_internal_events_drained(request) @@ -1000,6 +815,46 @@ impl App { Method::NotificationShow(params) => { return self.handle_notification_show(request.id, params); } + Method::ReleaseNotesDismiss(params) => { + let Some(notes) = self.state.latest_release_notes.as_ref() else { + return responses::encode_error( + request.id, + "stale_release_notes", + "the release notes are no longer current", + ); + }; + if notes.version != params.version { + return responses::encode_error( + request.id, + "stale_release_notes", + "the release notes are no longer current", + ); + } + let preview = notes.preview; + self.mark_release_notes_seen(preview); + return responses::encode_success(request.id, ResponseResult::Ok {}); + } + Method::ProductAnnouncementDismiss(params) => { + let matches_current = + self.state + .product_announcement + .as_ref() + .is_some_and(|announcement| { + announcement.version == params.version && announcement.id == params.id + }); + if !matches_current { + return responses::encode_error( + request.id, + "stale_announcement", + "the product announcement is no longer current", + ); + } + self.dismiss_product_announcement(); + return responses::encode_success(request.id, ResponseResult::Ok {}); + } + Method::CommandInvoke(params) => { + return self.handle_command_invoke(request.id, params); + } Method::ClientWindowTitleSet(_) | Method::ClientWindowTitleClear(_) => { return responses::encode_success( request.id, @@ -1099,11 +954,27 @@ impl App { return self.handle_pane_focus_direction(request.id, params); } Method::PaneResize(params) => return self.handle_pane_resize(request.id, params), + Method::PaneScroll(params) => return self.handle_pane_scroll(request.id, params), + Method::PaneEditScrollback(target) => { + return self.handle_pane_edit_scrollback(request.id, target); + } + Method::PaneSelectionRead(params) => { + return self.handle_pane_selection_read(request.id, params); + } + Method::PaneCopyMotion(params) => { + return self.handle_pane_copy_motion(request.id, params); + } + Method::PaneCopySearch(params) => { + return self.handle_pane_copy_search(request.id, params); + } Method::PaneList(params) => return self.handle_pane_list(request.id, params), Method::PaneCurrent(params) => return self.handle_pane_current(request.id, params), Method::PaneGet(target) => return self.handle_pane_get(request.id, target), Method::PaneFocus(target) => return self.handle_pane_focus(request.id, target), Method::PaneInputSet(params) => return self.handle_pane_input_set(request.id, params), + Method::PaneLinkActivate(params) => { + return self.handle_pane_link_activate(request.id, params); + } Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), Method::PaneRead(params) => return self.handle_pane_read(request.id, params), Method::PaneGraphicsSet(params) => { @@ -1162,6 +1033,9 @@ impl App { }; } Method::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params), + Method::IntegrationList(_) => { + return self.handle_integration_list(request.id); + } Method::IntegrationInstall(params) => { return self.handle_integration_install(request.id, params); } @@ -1220,7 +1094,6 @@ impl App { ) -> String { use crate::api::schema::{NotificationShowReason, ResponseResult}; - let requested_sound = params.sound; let Some(title) = sanitized_notification_text(¶ms.title, 80) else { return responses::encode_error(id, "invalid_params", "notification title is empty"); }; @@ -1247,32 +1120,11 @@ impl App { target: None, }); self.sync_toast_deadline(previous_toast); - self.emit_api_notification_sound(requested_sound); NotificationShowReason::Shown } } crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => { - if self.api_notification_rate_limited(Instant::now()) { - NotificationShowReason::RateLimited - } else { - let notify = match self.state.toast_config.delivery { - crate::config::ToastDelivery::Terminal => { - crate::terminal_notify::show_notification - } - crate::config::ToastDelivery::System => { - crate::platform::show_desktop_notification - } - _ => unreachable!("notification delivery was checked above"), - }; - match notify(&title, body.as_deref()) { - Ok(true) => { - self.mark_api_notification_shown(Instant::now()); - self.emit_api_notification_sound(requested_sound); - NotificationShowReason::Shown - } - Ok(false) | Err(_) => NotificationShowReason::NoForegroundClient, - } - } + NotificationShowReason::NoForegroundClient } }; @@ -1285,15 +1137,6 @@ impl App { ) } - fn emit_api_notification_sound(&self, sound: crate::api::schema::NotificationShowSound) { - if !self.state.local_sound_playback || !self.state.sound.allows(None) { - return; - } - if let Some(sound) = sound.to_sound() { - crate::sound::play(sound, &self.state.sound); - } - } - pub(crate) fn api_notification_rate_limited(&self, now: Instant) -> bool { self.last_api_notification_at .is_some_and(|last| now.duration_since(last) < API_NOTIFICATION_RATE_LIMIT) @@ -1400,7 +1243,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1427,7 +1270,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1461,12 +1304,98 @@ mod tests { .expect("matching agent detection runtime should be reset"); } + #[test] + fn product_announcement_dismiss_requires_current_identity() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.product_announcement = Some(crate::app::state::ProductAnnouncementState { + version: "0.8.2".into(), + id: "client-shell".into(), + title: "Client shell".into(), + body: "announcement".into(), + scroll: 0, + preview: true, + }); + + let stale = app.handle_api_request(crate::api::schema::Request { + id: "stale".into(), + method: crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: "0.8.2".into(), + id: "old".into(), + }, + ), + }); + let stale: serde_json::Value = serde_json::from_str(&stale).unwrap(); + assert_eq!(stale["error"]["code"], "stale_announcement"); + assert!(app.state.product_announcement.is_some()); + + let dismissed = app.handle_api_request(crate::api::schema::Request { + id: "dismiss".into(), + method: crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: "0.8.2".into(), + id: "client-shell".into(), + }, + ), + }); + let dismissed: serde_json::Value = serde_json::from_str(&dismissed).unwrap(); + assert_eq!(dismissed["result"]["type"], "ok"); + assert!(app.state.product_announcement.is_none()); + } + + #[test] + fn release_notes_dismiss_requires_current_version() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.latest_release_notes = Some(crate::release_notes::ReleaseNotes { + version: "99.99.99".into(), + body: "release notes".into(), + preview: true, + }); + + let stale = app.handle_api_request(crate::api::schema::Request { + id: "stale".into(), + method: crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: "0.8.2".into(), + }, + ), + }); + let stale: serde_json::Value = serde_json::from_str(&stale).unwrap(); + assert_eq!(stale["error"]["code"], "stale_release_notes"); + + let dismissed = app.handle_api_request(crate::api::schema::Request { + id: "dismiss".into(), + method: crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: "99.99.99".into(), + }, + ), + }); + let dismissed: serde_json::Value = serde_json::from_str(&dismissed).unwrap(); + assert_eq!(dismissed["result"]["type"], "ok"); + assert!(app.state.latest_release_notes.is_some()); + } + #[tokio::test] async fn server_reload_agent_manifests_resets_detection_runtimes() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1507,7 +1436,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1550,7 +1479,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1595,7 +1524,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1637,7 +1566,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1671,7 +1600,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1707,7 +1636,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1800,7 +1729,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1880,9 +1809,15 @@ mod tests { .state .next_pending_agent_notification_deadline() .expect("pending notification deadline"); - assert!(app.handle_scheduled_tasks(notification_deadline, false)); + let mut deliveries = app + .state + .drain_due_agent_notifications(notification_deadline); + app.refresh_agent_notification_delivery_contexts(&mut deliveries); assert_eq!( - app.state.toast.as_ref().map(|toast| toast.context.as_str()), + deliveries + .first() + .and_then(|delivery| delivery.toast.as_ref()) + .map(|toast| toast.context.as_str()), Some("__herdr_projects__ · 1") ); @@ -1919,7 +1854,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1956,7 +1891,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -2000,7 +1935,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -2051,7 +1986,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -2137,7 +2072,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2187,7 +2122,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2221,7 +2156,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2244,13 +2179,11 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); - app.local_terminal_notifications = false; - let mut workspace = crate::workspace::Workspace::test_new("stale"); workspace.custom_name = None; workspace.identity_cwd = "/__herdr_original__".into(); diff --git a/src/app/api/agent_view.rs b/src/app/api/agent_view.rs index e14a933d..c8c48926 100644 --- a/src/app/api/agent_view.rs +++ b/src/app/api/agent_view.rs @@ -87,8 +87,6 @@ impl App { fn replace_agent_view_override(&mut self, view: Option<AgentViewSetParams>) { self.state.agent_view_override = view; - self.state.agent_panel_scroll = 0; - self.state.mobile_switcher_scroll = 0; } } @@ -103,7 +101,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index 1ed13efc..65a65863 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -319,7 +319,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/integrations.rs b/src/app/api/integrations.rs index 0cca8cba..ba1603e2 100644 --- a/src/app/api/integrations.rs +++ b/src/app/api/integrations.rs @@ -1,9 +1,34 @@ -use crate::api::schema::{IntegrationInstallResult, IntegrationUninstallResult, ResponseResult}; +use crate::api::schema::{ + IntegrationInfo, IntegrationInstallResult, IntegrationState, IntegrationUninstallResult, + ResponseResult, +}; use crate::app::App; use super::responses::{encode_error, encode_success}; impl App { + pub(super) fn handle_integration_list(&self, id: String) -> String { + let integrations = crate::integration::integration_recommendations() + .into_iter() + .map(|recommendation| IntegrationInfo { + target: recommendation.target, + label: recommendation.label.to_owned(), + command: recommendation.command.to_owned(), + available: recommendation.available, + state: match recommendation.state { + crate::integration::IntegrationStatusKind::NotInstalled => { + IntegrationState::NotInstalled + } + crate::integration::IntegrationStatusKind::Current => IntegrationState::Current, + crate::integration::IntegrationStatusKind::Outdated => { + IntegrationState::Outdated + } + }, + }) + .collect(); + encode_success(id, ResponseResult::IntegrationList { integrations }) + } + pub(super) fn handle_integration_install( &mut self, id: String, @@ -14,6 +39,7 @@ impl App { Ok(messages) => messages, Err(err) => return encode_error(id, "integration_install_failed", err.to_string()), }; + self.state.integration_recommendations = crate::integration::integration_recommendations(); encode_success( id, @@ -34,6 +60,7 @@ impl App { Ok(messages) => messages, Err(err) => return encode_error(id, "integration_uninstall_failed", err.to_string()), }; + self.state.integration_recommendations = crate::integration::integration_recommendations(); encode_success( id, diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index 9d2756b8..943ed835 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -608,7 +608,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/pane_graphics.rs b/src/app/api/pane_graphics.rs index 4fc1deec..3dddccc9 100644 --- a/src/app/api/pane_graphics.rs +++ b/src/app/api/pane_graphics.rs @@ -18,17 +18,7 @@ impl App { /// workspaces/tabs and panes hidden by zoom are not placeable. Short-lived UI modes do not /// suspend the producer because the pane becomes visible again without a layout event. fn pane_graphics_visible(&self, ws_idx: usize, pane_id: PaneId) -> bool { - if self.state.active != Some(ws_idx) { - return false; - } - let Some(tab) = self.state.workspaces[ws_idx].active_tab() else { - return false; - }; - if tab.zoomed { - tab.layout.focused() == pane_id - } else { - tab.layout.pane_ids().contains(&pane_id) - } + self.state.pane_visible_on_active_surface(ws_idx, pane_id) } pub(super) fn handle_pane_graphics_info( @@ -566,7 +556,7 @@ mod tests { let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, rx, crate::api::EventHub::default(), @@ -665,7 +655,7 @@ mod tests { } #[test] - fn monolithic_info_keeps_fast_transport_and_exact_pixels_disabled() { + fn non_persistent_info_keeps_fast_transport_and_exact_pixels_disabled() { let (mut app, pane_id) = app(); app.state.host_cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 6615fa0a..f1c3a667 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1,7 +1,8 @@ use bytes::Bytes; use crate::api::schema::{ - EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCurrentParams, + EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCopyMotion, + PaneCopyMotionParams, PaneCopySearchDirection, PaneCopySearchParams, PaneCurrentParams, PaneDirection, PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, PaneFocusDirectionReason, PaneFocusDirectionResult, PaneInfo, PaneInputSetParams, PaneLayoutPane, PaneLayoutParams, PaneLayoutRect, PaneLayoutSnapshot, PaneLayoutSplit, @@ -10,8 +11,9 @@ use crate::api::schema::{ PaneProcessInfoProcess, PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult, - PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, - PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason, + PaneScrollParams, PaneSelectionReadParams, PaneSendInputParams, PaneSendKeysParams, + PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneSwapReason, PaneSwapResult, + PaneTarget, PaneTextPoint, PaneTextRange, PaneZoomMode, PaneZoomParams, PaneZoomReason, PaneZoomResult, ResponseResult, }; use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; @@ -112,7 +114,7 @@ impl App { self.state.switch_workspace_tab(ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } self.terminal_runtimes .insert(new_pane.terminal.id.clone(), new_pane.runtime); @@ -165,6 +167,304 @@ impl App { encode_success(id, ResponseResult::PaneInfo { pane }) } + pub(super) fn handle_pane_scroll(&mut self, id: String, params: PaneScrollParams) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + runtime.set_scroll_offset_from_bottom( + usize::try_from(params.offset_from_bottom).unwrap_or(usize::MAX), + ); + let Some(pane) = self.pane_info(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_edit_scrollback(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let is_focused = self.state.active == Some(ws_idx) + && self + .state + .workspaces + .get(ws_idx) + .and_then(crate::workspace::Workspace::focused_pane_id) + == Some(pane_id); + if !is_focused { + return encode_error(id, "stale_pane_target", "pane is no longer focused"); + } + match self.open_focused_scrollback_in_editor() { + Ok(()) => encode_success(id, ResponseResult::Ok {}), + Err(err) => encode_error(id, "scrollback_editor_failed", err.to_string()), + } + } + + pub(crate) fn pane_selection_text( + &self, + params: &PaneSelectionReadParams, + ) -> Result<String, (&'static str, String)> { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); + }; + let before = runtime.content_seq(); + if params + .content_revision + .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) + { + return Err(("stale_content", "pane content changed".to_owned())); + } + let selection = crate::selection::Selection::absolute_range( + pane_id, + (params.anchor.row, params.anchor.col), + (params.cursor.row, params.cursor.col), + ); + let Some(text) = runtime.extract_selection(&selection) else { + return Err(( + "selection_unavailable", + "selection text is unavailable".to_owned(), + )); + }; + if params.content_revision.is_some() && runtime.content_seq() != before { + return Err(("stale_content", "pane content changed".to_owned())); + } + Ok(text) + } + + pub(super) fn handle_pane_selection_read( + &mut self, + id: String, + params: PaneSelectionReadParams, + ) -> String { + match self.pane_selection_text(¶ms) { + Ok(text) => encode_success( + id, + ResponseResult::PaneSelection { + pane_id: params.pane_id, + text, + }, + ), + Err((code, message)) => encode_error(id, code, message), + } + } + + pub(super) fn handle_pane_copy_motion( + &mut self, + id: String, + params: PaneCopyMotionParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let before = runtime.content_seq(); + if params + .content_revision + .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) + { + return encode_error(id, "stale_content", "pane content changed"); + } + let target = match params.motion { + PaneCopyMotion::LineEnd | PaneCopyMotion::FirstNonBlank => { + let width = runtime + .terminal_dimensions() + .map_or(1, |(cols, _)| cols.max(1)); + let selection = crate::selection::Selection::absolute_range( + pane_id, + (params.cursor.row, 0), + (params.cursor.row, width.saturating_sub(1)), + ); + let Some(text) = runtime.extract_selection(&selection) else { + return encode_error( + id, + "copy_motion_unavailable", + "terminal row is unavailable", + ); + }; + let col = match params.motion { + PaneCopyMotion::LineEnd => { + crate::copy_mode::last_character_col(&text).unwrap_or(0) + } + PaneCopyMotion::FirstNonBlank => { + crate::copy_mode::first_non_blank_col(&text).unwrap_or(0) + } + _ => unreachable!(), + }; + crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: col.min(width.saturating_sub(1)), + } + } + PaneCopyMotion::NextWordStart + | PaneCopyMotion::PreviousWordStart + | PaneCopyMotion::NextWordEnd + | PaneCopyMotion::NextBigWordStart + | PaneCopyMotion::PreviousBigWordStart + | PaneCopyMotion::NextBigWordEnd => { + let motion = match params.motion { + PaneCopyMotion::NextWordStart => crate::pane::TerminalWordMotion::NextStart, + PaneCopyMotion::PreviousWordStart => { + crate::pane::TerminalWordMotion::PreviousStart + } + PaneCopyMotion::NextWordEnd => crate::pane::TerminalWordMotion::NextEnd, + PaneCopyMotion::NextBigWordStart => { + crate::pane::TerminalWordMotion::NextBigStart + } + PaneCopyMotion::PreviousBigWordStart => { + crate::pane::TerminalWordMotion::PreviousBigStart + } + PaneCopyMotion::NextBigWordEnd => crate::pane::TerminalWordMotion::NextBigEnd, + _ => unreachable!(), + }; + runtime + .word_motion_target(params.cursor.row, params.cursor.col, motion) + .unwrap_or(crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }) + } + PaneCopyMotion::PreviousParagraph | PaneCopyMotion::NextParagraph => runtime + .paragraph_motion_target( + params.cursor.row, + if params.motion == PaneCopyMotion::PreviousParagraph { + -1 + } else { + 1 + }, + ) + .map(|target| crate::pane::TerminalTextPoint { + row: target.row, + col: params.cursor.col, + }) + .unwrap_or(crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }), + }; + let after = runtime.content_seq(); + if params.content_revision.is_some() && after != before { + return encode_error(id, "stale_content", "pane content changed"); + } + encode_success( + id, + ResponseResult::PaneCopyMotion { + pane_id: params.pane_id, + cursor: crate::api::schema::PaneTextPoint { + row: target.row, + col: target.col, + }, + content_revision: after, + }, + ) + } + + pub(super) fn handle_pane_copy_search( + &mut self, + id: String, + params: PaneCopySearchParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + const MAX_QUERY_BYTES: usize = 4096; + const MAX_RETURNED_MATCHES: usize = 1024; + if params.query.len() > MAX_QUERY_BYTES { + return encode_error(id, "query_too_large", "copy search query is too large"); + } + let before = runtime.content_seq(); + if before != params.content_revision || !before.is_multiple_of(2) { + return encode_error(id, "stale_content", "pane content changed"); + } + let cursor = crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }; + let previous = params.previous.map(|previous| { + ( + crate::pane::TerminalTextPoint { + row: previous.start.row, + col: previous.start.col, + }, + crate::pane::TerminalTextPoint { + row: previous.end.row, + col: previous.end.col, + }, + ) + }); + let direction = match params.direction { + PaneCopySearchDirection::Forward => crate::pane::TerminalSearchDirection::Forward, + PaneCopySearchDirection::Backward => crate::pane::TerminalSearchDirection::Backward, + }; + let result = runtime.search_text_window( + ¶ms.query, + params.query.chars().any(char::is_uppercase), + direction, + cursor, + previous, + MAX_RETURNED_MATCHES, + ); + let after = runtime.content_seq(); + if after != before || !after.is_multiple_of(2) { + return encode_error(id, "stale_content", "pane content changed"); + } + let matches = result + .matches + .into_iter() + .map(|text_match| PaneTextRange { + start: PaneTextPoint { + row: text_match.start.row, + col: text_match.start.col, + }, + end: PaneTextPoint { + row: text_match.end.row, + col: text_match.end.col, + }, + }) + .collect(); + encode_success( + id, + ResponseResult::PaneCopySearch { + pane_id: params.pane_id, + content_revision: after, + matches, + total: u64::try_from(result.total).unwrap_or(u64::MAX), + current: result.current.and_then(|index| u32::try_from(index).ok()), + current_global: result + .current_global + .and_then(|index| u64::try_from(index).ok()), + }, + ) + } + pub(super) fn handle_pane_focus(&mut self, id: String, target: PaneTarget) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return pane_not_found(id, &target.pane_id); @@ -175,7 +475,7 @@ impl App { self.state.focus_pane_in_workspace(ws_idx, pane_id); self.state.mark_active_tab_seen(); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(pane) = self.pane_info(ws_idx, pane_id) else { return pane_not_found(id, &target.pane_id); @@ -367,7 +667,7 @@ impl App { if let Some(target_pane_id) = target { self.state.focus_pane_in_workspace(ws_idx, target_pane_id); self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } let focused_pane_id = self .state @@ -950,7 +1250,7 @@ impl App { .switch_workspace_tab(target_ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, target_ws_idx, moved_pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } let created_workspace = created_workspace.then(|| self.workspace_info(target_ws_idx)); let created_tab = if created_tab { @@ -1107,7 +1407,7 @@ impl App { if outcome.changed || outcome.focus_changed { self.schedule_session_save(); } - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; @@ -1912,7 +2212,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2076,6 +2376,272 @@ mod tests { } #[tokio::test] + async fn api_pane_scroll_sets_and_clamps_endpoint_owned_history() { + let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + let max_offset = runtime + .scroll_metrics() + .expect("scroll metrics") + .max_offset_from_bottom; + + let response = app.handle_pane_scroll( + "req".into(), + PaneScrollParams { + pane_id: public_pane_id, + offset_from_bottom: u64::MAX, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneInfo { pane } = success.result else { + panic!("expected pane info response"); + }; + assert_eq!( + pane.scroll.expect("scroll metrics").offset_from_bottom, + max_offset as u64 + ); + } + + #[tokio::test] + async fn api_pane_selection_read_uses_endpoint_terminal_text() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"hello world", + ), + ); + + let response = app.handle_pane_selection_read( + "req".into(), + PaneSelectionReadParams { + pane_id: public_pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 4 }, + content_revision: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneSelection { + pane_id: public_pane_id, + text: "hello".into(), + } + ); + } + + #[tokio::test] + async fn api_copy_motion_uses_endpoint_terminal_word_semantics() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"hello world", + ), + ); + + let response = app.handle_pane_copy_motion( + "req".into(), + PaneCopyMotionParams { + pane_id: public_pane_id.clone(), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + motion: PaneCopyMotion::NextWordStart, + content_revision: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneCopyMotion { + pane_id: public_pane_id, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 6 }, + content_revision: 0, + } + ); + } + + #[tokio::test] + async fn api_paragraph_motion_preserves_the_copy_cursor_column() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"one\r\n\r\nthree", + ), + ); + let response = app.handle_pane_copy_motion( + "req".into(), + PaneCopyMotionParams { + pane_id: public_pane_id.clone(), + cursor: PaneTextPoint { row: 0, col: 2 }, + motion: PaneCopyMotion::NextParagraph, + content_revision: None, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneCopyMotion { + pane_id: public_pane_id, + cursor: PaneTextPoint { row: 1, col: 2 }, + content_revision: 0, + } + ); + } + + #[tokio::test] + async fn api_copy_search_uses_endpoint_terminal_matches_and_wraps() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"alpha beta alpha", + ), + ); + + let content_revision = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime") + .content_seq(); + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id.clone(), + query: "alpha".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision, + previous: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCopySearch { + pane_id, + matches, + current, + total, + current_global, + .. + } = success.result + else { + panic!("expected copy search response"); + }; + assert_eq!(pane_id, public_pane_id); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].start, PaneTextPoint { row: 0, col: 0 }); + assert_eq!(matches[1].start, PaneTextPoint { row: 0, col: 11 }); + assert_eq!(current, Some(1)); + assert_eq!(current_global, Some(1)); + assert_eq!(total, 2); + } + + #[tokio::test] + async fn api_copy_search_bounds_returned_matches_but_keeps_exact_total() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let text = "a ".repeat(1500); + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 200, + 20, + 4000, + text.as_bytes(), + ), + ); + let content_revision = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime") + .content_seq(); + + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id, + query: "a".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision, + previous: None, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCopySearch { matches, total, .. } = success.result else { + panic!("expected copy search response"); + }; + assert_eq!(total, 1500); + assert_eq!(matches.len(), 1024); + } + + #[tokio::test] + async fn api_copy_search_rejects_stale_content_revision() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"alpha beta", + ), + ); + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id, + query: "alpha".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision: 2, + previous: None, + }, + ); + assert!(response.contains("stale_content")); + } + + #[tokio::test] + async fn api_edit_scrollback_rejects_a_pane_that_is_no_longer_focused() { + let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); + app.state.active = None; + + let response = app.handle_pane_edit_scrollback( + "req".into(), + PaneTarget { + pane_id: public_pane_id, + }, + ); + + assert_eq!(metadata_error_code(&response), "stale_pane_target"); + assert!(app.overlay_panes.is_empty()); + } + + #[tokio::test] async fn api_pane_read_reports_when_older_rows_are_omitted() { let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); @@ -2255,7 +2821,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2305,7 +2871,6 @@ mod tests { let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); - assert_eq!(app.state.request_remove_linked_worktree, None); assert!(app.state.workspaces.is_empty()); } @@ -2415,7 +2980,11 @@ mod tests { let source = app.state.workspaces[0].tabs[0].root_pane; let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(source); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let source_public = app.public_pane_id(0, source).unwrap(); let target_public = app.public_pane_id(0, target).unwrap(); @@ -2443,45 +3012,15 @@ mod tests { } #[test] - fn api_pane_swap_unfocused_source_updates_last_pane_history() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let focused = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Vertical); - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].tabs[0].layout.focus_pane(focused); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); - let source_public = app.public_pane_id(0, source).unwrap(); - let target_public = app.public_pane_id(0, target).unwrap(); - - let response = app.handle_pane_swap( - "req".into(), - PaneSwapParams { - source_pane_id: Some(source_public), - target_pane_id: Some(target_public), - ..PaneSwapParams::default() - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneSwap { swap } = success.result else { - panic!("expected pane swap response"); - }; - assert!(swap.changed); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - - app.state.last_pane(); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(focused)); - } - - #[test] fn api_pane_swap_direction_no_neighbor_returns_unchanged_layout() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(source); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let source_public = app.public_pane_id(0, source).unwrap(); let response = app.handle_pane_swap( @@ -2637,124 +3176,6 @@ mod tests { Some(&source_terminal) ); } - - #[test] - fn api_pane_move_focuses_copy_mode_pane_back_into_copy_mode() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let target_tab = app.state.workspaces[0].test_add_tab(Some("target")); - let target = app.state.workspaces[0].tabs[target_tab].root_pane; - seed_terminal_states(&mut app); - app.state.copy_mode = Some(crate::app::state::CopyModeState { - pane_id: source, - cursor_row: 0, - cursor_col: 0, - entry_offset_from_bottom: 0, - selection: None, - search: Default::default(), - }); - let source_public = app.public_pane_id(0, source).unwrap(); - let target_public = app.public_pane_id(0, target).unwrap(); - let target_tab_public = app.public_tab_id(0, target_tab).unwrap(); - - let response = app.handle_pane_move( - "req".into(), - PaneMoveParams { - pane_id: source_public, - destination: PaneMoveDestination::Tab { - tab_id: target_tab_public, - target_pane_id: Some(target_public), - split: SplitDirection::Right, - ratio: None, - }, - focus: true, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneMove { move_result } = success.result else { - panic!("expected pane move response"); - }; - assert!(move_result.changed); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode.expect("copy mode").pane_id, source); - assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source); - } - - #[tokio::test] - async fn key_release_follows_pane_moved_across_workspaces() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let source_terminal_id = app.state.workspaces[0].tabs[0] - .terminal_id(source) - .unwrap() - .clone(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[>15u", - 2, - ); - app.terminal_runtimes.insert(source_terminal_id, runtime); - app.state.workspaces.push(Workspace::test_new("other")); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let source_public = app.public_pane_id(0, source).unwrap(); - let target = app.state.workspaces[1].tabs[0].root_pane; - let target_tab_id = app.public_tab_id(1, 0).unwrap(); - let target_pane_id = app.public_pane_id(1, target).unwrap(); - - app.route_client_events_from( - 42, - vec![crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('j'), - crossterm::event::KeyModifiers::empty(), - ), - )], - false, - ); - let response = app.handle_pane_move( - "req".into(), - PaneMoveParams { - pane_id: source_public, - destination: PaneMoveDestination::Tab { - tab_id: target_tab_id, - target_pane_id: Some(target_pane_id), - split: SplitDirection::Down, - ratio: None, - }, - focus: false, - }, - ); - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert!(matches!(success.result, ResponseResult::PaneMove { .. })); - app.route_client_events_from( - 42, - vec![crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('j'), - crossterm::event::KeyModifiers::empty(), - ) - .with_kind(crossterm::event::KeyEventKind::Release), - )], - false, - ); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("forwarded release after pane move"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - #[test] fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() { let mut app = app_with_linked_worktree(); @@ -3316,86 +3737,6 @@ mod tests { } #[test] - fn api_pane_zoom_explicit_background_pane_updates_focus_history() { - let mut app = app_with_linked_worktree(); - app.state.workspaces.push(Workspace::test_new("other")); - let first = app.state.workspaces[0].tabs[0].root_pane; - let target = app.state.workspaces[1].tabs[0].root_pane; - let _other = app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].tabs[0].layout.focus_pane(first); - let target_public = app.public_pane_id(1, target).unwrap(); - - let response = app.handle_pane_zoom( - "req".into(), - PaneZoomParams { - pane_id: Some(target_public.clone()), - mode: PaneZoomMode::On, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneZoom { zoom } = success.result else { - panic!("expected pane zoom response"); - }; - assert!(zoom.changed); - assert!(zoom.zoom_changed); - assert!(zoom.focus_changed); - assert_eq!(zoom.pane_id, target_public); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); - assert!(app.state.workspaces[1].tabs[0].zoomed); - - app.state.last_pane(); - - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(first)); - } - - #[test] - fn api_pane_zoom_focuses_copy_mode_pane_back_into_copy_mode() { - let mut app = app_with_linked_worktree(); - app.state.workspaces.push(Workspace::test_new("other")); - let source = app.state.workspaces[0].tabs[0].root_pane; - let target = app.state.workspaces[1].tabs[0].root_pane; - let _other = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let _target_other = - app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); - app.state.workspaces[1].tabs[0].layout.focus_pane(target); - app.state.active = Some(1); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - app.state.copy_mode = Some(crate::app::state::CopyModeState { - pane_id: source, - cursor_row: 0, - cursor_col: 0, - entry_offset_from_bottom: 0, - selection: None, - search: Default::default(), - }); - let source_public = app.public_pane_id(0, source).unwrap(); - - let response = app.handle_pane_zoom( - "req".into(), - PaneZoomParams { - pane_id: Some(source_public), - mode: PaneZoomMode::On, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneZoom { zoom } = success.result else { - panic!("expected pane zoom response"); - }; - assert!(zoom.focus_changed); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); - } - - #[test] fn api_pane_zoom_single_pane_returns_noop() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); @@ -3565,7 +3906,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3596,7 +3941,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3623,7 +3972,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_edges( @@ -3650,7 +4003,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(right); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3688,7 +4045,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3796,7 +4157,11 @@ mod tests { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_pane_focus_direction( diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index efef83b9..8aee1c31 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -357,10 +357,6 @@ impl App { .as_ref() .and_then(|pane| pane.cwd.clone()) .or_else(|| Some(self.default_cwd_for_workspace(ws_idx).display().to_string())); - let selected_text = focused_pane - .as_ref() - .and_then(|pane| self.parse_pane_id(&pane.pane_id)) - .and_then(|(_, pane_id)| self.selected_text_for_pane(pane_id)); PluginInvocationContext { workspace_id: Some(workspace.workspace_id), workspace_label: Some(workspace.label), @@ -372,7 +368,9 @@ impl App { focused_pane_cwd: focused_pane.as_ref().and_then(|pane| pane.cwd.clone()), focused_pane_agent: focused_pane.as_ref().and_then(|pane| pane.agent.clone()), focused_pane_status: focused_pane.as_ref().map(|pane| pane.agent_status), - selected_text, + // Selection is client presentation state. Client keybindings provide + // revision-validated coordinates; API callers can provide explicit context. + selected_text: None, invocation_source: Some("api".to_string()), correlation_id: Some(correlation_id.to_string()), clicked_url: None, @@ -380,22 +378,6 @@ impl App { } } - fn selected_text_for_pane(&self, pane_id: crate::layout::PaneId) -> Option<String> { - let selection = self.state.selection.as_ref()?; - if selection.pane_id != pane_id || !selection.is_visible() { - return None; - } - let terminal_id = self - .state - .workspaces - .iter() - .find_map(|workspace| workspace.terminal_id(pane_id))?; - self.terminal_runtimes - .get(terminal_id) - .and_then(|runtime| runtime.extract_selection(selection)) - .filter(|text| !text.is_empty()) - } - fn default_cwd_for_workspace(&self, ws_idx: usize) -> std::path::PathBuf { self.state .workspaces diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index ddf3fba6..056315c3 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -6,11 +6,11 @@ mod runtime; use super::responses::{encode_error, encode_success}; use crate::api::schema::{ - InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams, - PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction, - PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo, - PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams, - ResponseResult, + InstalledPluginInfo, PaneLinkActivateParams, PluginActionInfo, PluginActionInvokeParams, + PluginActionListParams, PluginLinkParams, PluginListParams, PluginLogListParams, + PluginManifestAction, PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, + PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, + PluginUnlinkParams, ResponseResult, }; use crate::app::App; pub(super) use manifest::normalize_plugin_id; @@ -37,7 +37,7 @@ impl App { } fn refresh_installed_plugins(&mut self) -> std::io::Result<()> { - if self.no_session { + if !self.policy.persist_plugin_registry { return Ok(()); } let entries = crate::persist::plugin_registry::try_load()?; @@ -49,7 +49,7 @@ impl App { &mut self, mutation: impl FnOnce(&mut crate::app::state::InstalledPluginRegistry) -> T, ) -> std::io::Result<T> { - if self.no_session { + if !self.policy.persist_plugin_registry { return Ok(mutation(&mut self.state.installed_plugins)); } let (result, entries) = crate::persist::plugin_registry::update(|entries| { @@ -225,6 +225,7 @@ impl App { pub(crate) fn invoke_plugin_action_from_keybind( &mut self, action_id: String, + selected_text: Option<String>, ) -> Result<(), String> { self.refresh_installed_plugins() .map_err(|err| format!("failed to load plugin registry: {err}"))?; @@ -241,6 +242,7 @@ impl App { .map_err(|(_, message)| message)?; let mut context = self.current_plugin_context("keybinding"); context.invocation_source = Some("keybinding".to_string()); + context.selected_text = selected_text; self.start_plugin_command( &plugin, Some(action.action_id), @@ -253,6 +255,80 @@ impl App { .map_err(|(_, message)| message) } + pub(super) fn handle_pane_link_activate( + &mut self, + id: String, + params: PaneLinkActivateParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return encode_error(id, "pane_not_found", "pane not found"); + }; + if !self.state.pane_visible_on_active_surface(ws_idx, pane_id) { + return encode_error(id, "stale_target", "pane is no longer visible"); + } + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return encode_error(id, "pane_not_found", "pane runtime not found"); + }; + let current_offset = runtime + .scroll_metrics() + .map(|metrics| metrics.offset_from_bottom as u64); + if params + .offset_from_bottom + .is_some_and(|expected| current_offset != Some(expected)) + { + return encode_error( + id, + "stale_content", + "pane viewport changed before link activation", + ); + } + let content_revision = runtime.content_seq(); + if content_revision % 2 != 0 + || params + .content_revision + .is_some_and(|expected| expected != content_revision) + { + return encode_error( + id, + "stale_content", + "pane content changed before link activation", + ); + } + let url = self.state.url_at_pane_surface_cell( + &self.terminal_runtimes, + ws_idx, + pane_id, + params.viewport_row, + params.col, + ); + if runtime.content_seq() != content_revision + || runtime + .scroll_metrics() + .map(|metrics| metrics.offset_from_bottom as u64) + != current_offset + { + return encode_error( + id, + "stale_content", + "pane content or viewport changed during link activation", + ); + } + let handled = match url.as_deref() { + Some(url) => match self.invoke_plugin_link_handler_for_url(url, pane_id) { + Ok(handled) => handled, + Err(err) => { + tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler"); + false + } + }, + None => false, + }; + encode_success(id, ResponseResult::PaneLinkActivated { url, handled }) + } + pub(crate) fn invoke_plugin_link_handler_for_url( &mut self, url: &str, @@ -392,13 +468,8 @@ impl App { "width and height are only supported when placement is popup", ); } - if placement == PluginPanePlacement::Popup && self.state.mode != crate::app::Mode::Terminal - { - return encode_error( - id, - "ui_busy", - "popup panes can only open from the normal workspace view", - ); + if placement == PluginPanePlacement::Popup && self.state.popup_pane.is_some() { + return encode_error(id, "ui_busy", "a popup pane is already open"); } match placement { PluginPanePlacement::Overlay | PluginPanePlacement::Popup => { @@ -457,7 +528,7 @@ impl App { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); } self.state.focus_pane_in_workspace(ws_idx, pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); }; @@ -716,7 +787,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1395,69 +1466,6 @@ platforms = ["linux", "macos"] let _ = std::fs::remove_dir_all(root); } - #[test] - fn plugin_pane_open_popup_preserves_existing_ui_modes() { - let mut app = test_app(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("modal")]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - let root_pane = app.state.workspaces[0].tabs[0].root_pane; - let root = unique_temp_path("plugin-popup-ui-busy"); - write_manifest(&root); - link_manifest(&mut app, &root); - - let open_popup = |app: &mut App, id: &str| { - app.handle_api_request(Request { - id: id.into(), - method: Method::PluginPaneOpen(PluginPaneOpenParams { - plugin_id: "example.worktree-bootstrap".into(), - entrypoint: "board".into(), - placement: Some(PluginPanePlacement::Popup), - width: None, - height: None, - workspace_id: None, - target_pane_id: None, - direction: None, - cwd: None, - focus: true, - env: std::collections::HashMap::new(), - }), - }) - }; - - app.state.mode = crate::app::Mode::Settings; - app.state.settings.original_theme = Some("settings-theme".into()); - let settings_response = open_popup(&mut app, "settings-popup"); - let settings_error: serde_json::Value = serde_json::from_str(&settings_response).unwrap(); - assert_eq!(settings_error["error"]["code"], "ui_busy"); - assert_eq!(app.state.mode, crate::app::Mode::Settings); - assert_eq!( - app.state.settings.original_theme.as_deref(), - Some("settings-theme") - ); - assert!(app.state.popup_pane.is_none()); - - let copy_mode = crate::app::state::CopyModeState { - pane_id: root_pane, - cursor_row: 2, - cursor_col: 3, - entry_offset_from_bottom: 4, - selection: None, - search: crate::app::state::CopyModeSearchState::default(), - }; - app.state.mode = crate::app::Mode::Copy; - app.state.copy_mode = Some(copy_mode.clone()); - let copy_response = open_popup(&mut app, "copy-popup"); - let copy_error: serde_json::Value = serde_json::from_str(©_response).unwrap(); - assert_eq!(copy_error["error"]["code"], "ui_busy"); - assert_eq!(app.state.mode, crate::app::Mode::Copy); - assert_eq!(app.state.copy_mode, Some(copy_mode)); - assert!(app.state.popup_pane.is_none()); - - let _ = std::fs::remove_dir_all(root); - } - #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { @@ -1681,7 +1689,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PL let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1764,7 +1772,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1843,7 +1851,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1922,7 +1930,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1958,8 +1966,8 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] write_manifest_content(&root, &manifest); link_manifest(&mut app, &root); - let open = app.handle_api_request(Request { - id: "pane-open-popup".into(), + let popup_request = |id: &str| Request { + id: id.into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.popup".into(), entrypoint: "board".into(), @@ -1973,8 +1981,14 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] focus: true, env: std::collections::HashMap::new(), }), - }); + }; + app.state.mode = crate::app::Mode::Navigate; + let open = app.handle_api_request(popup_request("pane-open-popup")); assert_eq!(response_result(&open), ResponseResult::Ok {}); + let duplicate = app.handle_api_request(popup_request("pane-open-popup-duplicate")); + let duplicate: crate::api::schema::ErrorResponse = + serde_json::from_str(&duplicate).unwrap(); + assert_eq!(duplicate.error.code, "ui_busy"); assert_eq!( read_capture_when_ready(&env_capture, || { app.drain_internal_events(); @@ -2184,7 +2198,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] .unwrap(); let mut app = test_app(); - app.no_session = false; + app.policy.persist_plugin_registry = true; let workspace = crate::workspace::Workspace::test_new("plugin-refresh"); let pane_id = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; @@ -2202,7 +2216,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] make_stale(&mut app); assert!(app - .invoke_plugin_action_from_keybind("bootstrap".into()) + .invoke_plugin_action_from_keybind("bootstrap".into(), None) .unwrap_err() .contains("disabled")); @@ -2419,7 +2433,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG } #[tokio::test] - async fn current_plugin_context_includes_selected_text_for_focused_pane() { + async fn current_plugin_context_leaves_client_owned_selection_empty() { let mut app = test_app(); let workspace = crate::workspace::Workspace::test_new("plugin-selection"); let pane_id = workspace.tabs[0].root_pane; @@ -2433,11 +2447,9 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG terminal_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hello plugin\n"), ); - app.state.selection = Some(crate::selection::Selection::range(pane_id, 0, 0, 4, None)); - let context = app.current_plugin_context("selection-test"); - assert_eq!(context.selected_text.as_deref(), Some("hello")); + assert_eq!(context.selected_text, None); } #[cfg(unix)] diff --git a/src/app/api/responses.rs b/src/app/api/responses.rs index 7c871c37..3cfa1a5c 100644 --- a/src/app/api/responses.rs +++ b/src/app/api/responses.rs @@ -1,10 +1,10 @@ use crate::api::schema::{ErrorBody, ErrorResponse, ResponseResult, SuccessResponse}; -pub(super) fn encode_success(id: String, result: ResponseResult) -> String { +pub(crate) fn encode_success(id: String, result: ResponseResult) -> String { serde_json::to_string(&SuccessResponse { id, result }).unwrap() } -pub(super) fn encode_error(id: String, code: &str, message: impl Into<String>) -> String { +pub(crate) fn encode_error(id: String, code: &str, message: impl Into<String>) -> String { encode_error_body( id, ErrorBody { diff --git a/src/app/api/session.rs b/src/app/api/session.rs index b2238faa..a7d14b3d 100644 --- a/src/app/api/session.rs +++ b/src/app/api/session.rs @@ -13,7 +13,7 @@ impl App { ) } - fn session_snapshot(&self) -> SessionSnapshot { + pub(crate) fn session_snapshot(&self) -> SessionSnapshot { let focused_workspace_id = self .state .active @@ -66,7 +66,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = crate::app::App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 42d2b029..c1bd86e9 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -157,13 +157,6 @@ impl App { }; tab.set_custom_name(params.label.clone()); crate::logging::tab_renamed(&workspace_id, &tab_id); - if self.state.active == Some(ws_idx) { - // Reflow the tab bar so the new label width takes effect immediately. - // The tab bar renders into cached hit areas; without this refresh the - // old geometry lingers until the next refresh (e.g. a tab switch), - // leaving the visible label stale. Mirrors handle_tab_move. - self.state.refresh_tab_bar_view(); - } self.schedule_session_save(); self.emit_event(EventEnvelope { event: EventKind::TabRenamed, @@ -206,10 +199,6 @@ impl App { let tabs = self.tab_list_info(ws_idx); if moved { self.schedule_session_save(); - if self.state.active == Some(ws_idx) { - self.state.tab_scroll_follow_active = true; - self.state.refresh_tab_bar_view(); - } self.emit_event(EventEnvelope { event: EventKind::TabMoved, data: EventData::TabMoved { @@ -337,7 +326,13 @@ mod tests { fn api_tab_close_last_tab_closes_workspace_and_emits_both_events() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("tabs")]; app.state.active = Some(0); app.state.selected = 0; @@ -384,7 +379,13 @@ mod tests { fn api_tab_move_reorders_tabs_in_target_workspace() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); let mut workspace = Workspace::test_new("tabs"); workspace.test_add_tab(Some("two")); workspace.test_add_tab(Some("three")); @@ -424,42 +425,17 @@ mod tests { })); } - #[test] - fn api_tab_rename_reflows_active_tab_bar() { - let event_hub = crate::api::EventHub::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); - let workspace = Workspace::test_new("tabs"); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.view.tab_bar_rect = ratatui::layout::Rect::new(0, 0, 60, 1); - app.state.refresh_tab_bar_view(); - - let tab_id = app.public_tab_id(0, 0).unwrap(); - let width_before = app.state.view.tab_hit_areas[0].width; - - app.handle_tab_rename( - "req".into(), - TabRenameParams { - tab_id, - label: "a much longer custom tab label".into(), - }, - ); - - let width_after = app.state.view.tab_hit_areas[0].width; - assert!( - width_after > width_before, - "tab bar should reflow to the new label width immediately: \ - before={width_before}, after={width_after}" - ); - } - #[tokio::test] async fn tab_create_follows_cached_focused_pane_cwd_without_runtime() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.default_shell = exiting_test_command().into(); app.state.shell_mode = ShellModeConfig::NonLogin; let workspace = Workspace::test_new("tabs"); diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index d9e689e3..9b125e57 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -41,12 +41,25 @@ impl App { id: String, params: WorkspaceCreateParams, ) -> String { + let source_workspace_index = if params.cwd.is_some() { + None + } else { + match params.source_workspace_id.as_deref() { + Some(workspace_id) => match self + .parse_workspace_id(workspace_id) + .filter(|index| self.state.workspaces.get(*index).is_some()) + { + Some(index) => Some(index), + None => return workspace_not_found(id, workspace_id), + }, + None => self.workspace_creation_source(), + } + }; let cwd = params.cwd.map(PathBuf::from).unwrap_or_else(|| { - let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - self.resolve_new_terminal_cwd(follow_cwd) + source_workspace_index.map_or_else( + || self.resolve_new_terminal_cwd(None), + |index| self.resolved_new_workspace_cwd_from(index), + ) }); let extra_env = match super::env::normalize_launch_env(params.env) { Ok(env) => env, @@ -360,7 +373,11 @@ fn workspace_not_found(id: String, workspace_id: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace}; + use crate::{ + api::schema::{ErrorResponse, SuccessResponse}, + config::Config, + workspace::Workspace, + }; // `new_cwd = follow` must anchor on the focused pane for every creation // surface. Splits and tabs already do; a new workspace must follow the @@ -373,7 +390,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -419,6 +436,7 @@ mod tests { let response = app.handle_workspace_create( "req".into(), WorkspaceCreateParams { + source_workspace_id: None, cwd: None, focus: false, label: None, @@ -444,11 +462,99 @@ mod tests { let _ = std::fs::remove_dir_all(&focused_cwd); } + #[tokio::test] + async fn workspace_create_uses_explicit_source_workspace() { + use super::super::test_support::{exiting_test_command, shutdown_test_runtimes}; + use crate::config::ShellModeConfig; + + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.default_shell = exiting_test_command().into(); + app.state.shell_mode = ShellModeConfig::NonLogin; + app.state.workspaces = vec![Workspace::test_new("first"), Workspace::test_new("source")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.ensure_test_terminals(); + shutdown_test_runtimes(&mut app); + + let source_cwd = + std::env::temp_dir().join(format!("herdr-ws-explicit-source-{}", std::process::id())); + std::fs::create_dir_all(&source_cwd).unwrap(); + let pane_id = app.state.workspaces[1].focused_pane_id().unwrap(); + let terminal_id = app.state.workspaces[1] + .terminal_id(pane_id) + .cloned() + .unwrap(); + app.state.terminals.get_mut(&terminal_id).unwrap().cwd = source_cwd.clone(); + let source_workspace_id = app.public_workspace_id(1); + + let response = app.handle_workspace_create( + "req".into(), + WorkspaceCreateParams { + source_workspace_id: Some(source_workspace_id), + cwd: None, + focus: false, + label: None, + env: Default::default(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!( + success.result, + ResponseResult::WorkspaceCreated { .. } + )); + assert_eq!( + crate::worktree::canonical_or_original(&app.state.workspaces[2].identity_cwd), + crate::worktree::canonical_or_original(&source_cwd) + ); + + let invalid = app.handle_workspace_create( + "invalid".into(), + WorkspaceCreateParams { + source_workspace_id: Some("w_999".into()), + cwd: None, + focus: false, + label: None, + env: Default::default(), + }, + ); + let error: ErrorResponse = serde_json::from_str(&invalid).unwrap(); + assert_eq!(error.error.code, "workspace_not_found"); + + let captured = app.handle_workspace_create( + "captured".into(), + WorkspaceCreateParams { + source_workspace_id: Some("w_999".into()), + cwd: Some(source_cwd.display().to_string()), + focus: false, + label: None, + env: Default::default(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&captured).unwrap(); + assert!(matches!( + success.result, + ResponseResult::WorkspaceCreated { .. } + )); + assert_eq!( + crate::worktree::canonical_or_original(&app.state.workspaces[3].identity_cwd), + crate::worktree::canonical_or_original(&source_cwd) + ); + shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(&source_cwd); + } + fn app_with_linked_worktree() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -604,7 +710,6 @@ mod tests { let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); - assert_eq!(app.state.request_remove_linked_worktree, None); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.workspaces[0].display_name(), "parent"); } @@ -613,7 +718,13 @@ mod tests { fn api_workspace_close_event_includes_final_worktree_snapshot() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = app_with_linked_worktree().state.workspaces; let workspace_id = app.state.workspaces[0].id.clone(); @@ -647,7 +758,13 @@ mod tests { fn workspace_metadata_tokens_patch_clear_and_emit_snapshot() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; let workspace_id = app.public_workspace_id(0); @@ -699,7 +816,13 @@ mod tests { fn workspace_token_ttl_expires_through_runtime_and_emits_update() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; let workspace_id = app.public_workspace_id(0); let response = app.handle_workspace_report_metadata( @@ -731,7 +854,13 @@ mod tests { fn api_workspace_move_reorders_workspaces() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![ Workspace::test_new("one"), Workspace::test_new("two"), @@ -773,7 +902,13 @@ mod tests { fn api_workspace_move_block_reorders_atomically() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![ Workspace::test_new("child"), Workspace::test_new("normal"), @@ -826,7 +961,13 @@ mod tests { fn api_workspace_move_noop_does_not_emit_event() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; let moved_id = app.public_workspace_id(0); diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 4a3e23a8..cf4ca3f9 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -638,14 +638,6 @@ impl App { }); } - #[cfg(test)] - pub(crate) fn emit_worktree_opened_for_workspace(&mut self, ws_idx: usize, already_open: bool) { - let Some(worktree) = self.worktree_info_for_workspace(ws_idx) else { - return; - }; - self.emit_worktree_opened_event(ws_idx, worktree, already_open); - } - fn emit_worktree_opened_event( &mut self, ws_idx: usize, @@ -802,7 +794,13 @@ mod tests { fn test_app_with_event_hub(event_hub: crate::api::EventHub) -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - App::new(&Config::default(), true, None, api_rx, event_hub) + App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ) } #[cfg(windows)] @@ -936,6 +934,7 @@ mod tests { assert_eq!(tab.workspace_id, workspace.workspace_id); assert_eq!(root_pane.workspace_id, workspace.workspace_id); assert_eq!(worktree.branch.as_deref(), Some("worktree/api-create")); + assert!(Path::new(&worktree.path).starts_with(&worktree_root)); assert!(Path::new(&worktree.path).join("README.md").exists()); assert_eq!(app.state.workspaces.len(), 2); assert!( diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 4755b38d..110f00c9 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -377,12 +377,6 @@ impl App { self.pending_api_worktree_creates.remove(&checkout_key); if let Err(err) = result.result { - if let Some(create) = &mut self.state.worktree_create { - if create.checkout_path == result.path { - create.creating = false; - create.error = Some(err.clone()); - } - } Self::send_api_response( api.respond_to, encode_error(api.id, "worktree_create_failed", err), @@ -438,17 +432,6 @@ impl App { ws.set_custom_name(label); } } - if self - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.checkout_path == result.path) - { - self.state.worktree_create = None; - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - self.state.mode = crate::app::Mode::Terminal; - } self.state.mark_session_dirty(); if created_workspace { self.emit_workspace_open_events(ws_idx); @@ -520,17 +503,6 @@ impl App { } else { "worktree_remove_failed" }; - if let Some(remove) = &mut self.state.worktree_remove { - if remove.workspace_id == result.workspace_id && remove.path == result.path { - remove.removing = false; - if code == "dirty_worktree_requires_force" && !remove.force_confirmation { - remove.force_confirmation = true; - remove.error = None; - } else { - remove.error = Some(message.clone()); - } - } - } Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); return; } @@ -592,16 +564,6 @@ impl App { worktree, result.forced, ); - if self.state.worktree_remove.as_ref().is_some_and(|remove| { - remove.workspace_id == result.workspace_id && remove.path == result.path - }) { - self.state.worktree_remove = None; - self.state.mode = if self.state.active.is_some() { - crate::app::Mode::Terminal - } else { - crate::app::Mode::Navigate - }; - } let response = encode_success( api.id, ResponseResult::WorktreeRemoved { diff --git a/src/app/config_io.rs b/src/app/config_io.rs deleted file mode 100644 index 9f7445b9..00000000 --- a/src/app/config_io.rs +++ /dev/null @@ -1,128 +0,0 @@ -use super::App; - -impl App { - pub(super) fn update_config_file<F>(&mut self, error_context: &str, update: F) -> bool - where - F: FnOnce(&str) -> String, - { - #[cfg(test)] - if std::env::var_os(crate::config::CONFIG_PATH_ENV_VAR).is_none() { - return false; - } - - let path = crate::config::config_path(); - if let Some(parent) = path.parent() { - if let Err(err) = std::fs::create_dir_all(parent) { - crate::logging::config_write_failed(&path, error_context, &err.to_string()); - self.state.config_diagnostic = - Some(format!("failed to save {error_context}: {err}")); - self.config_diagnostic_deadline = - Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); - return false; - } - } - - let content = std::fs::read_to_string(&path).unwrap_or_default(); - let new_content = update(&content); - if let Err(err) = std::fs::write(&path, new_content) { - crate::logging::config_write_failed(&path, error_context, &err.to_string()); - self.state.config_diagnostic = Some(format!("failed to save {error_context}: {err}")); - self.config_diagnostic_deadline = - Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); - return false; - } - - true - } - - pub(super) fn mark_onboarding_complete(&mut self) { - self.update_config_file("onboarding setting", |content| { - crate::config::upsert_top_level_bool(content, "onboarding", false) - }); - } - - pub(super) fn save_theme(&mut self, name: &str) { - if self.update_config_file("theme", |content| { - let content = crate::config::upsert_section_value( - content, - "theme", - "name", - &format!("\"{name}\""), - ); - crate::config::upsert_section_bool(&content, "theme", "auto_switch", false) - }) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_status_indicators(&mut self, style: crate::config::StatusIndicatorStyle) { - if self.update_config_file("status indicators", |content| { - crate::config::upsert_section_value( - content, - "ui", - "status_indicators", - &format!("\"{}\"", style.as_str()), - ) - }) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_sound(&mut self, enabled: bool) { - if self.update_config_file("sound setting", |content| { - crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled) - }) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_toast_delivery(&mut self, delivery: crate::config::ToastDelivery) { - let value = match delivery { - crate::config::ToastDelivery::Off => "\"off\"", - crate::config::ToastDelivery::Herdr => "\"herdr\"", - crate::config::ToastDelivery::Terminal => "\"terminal\"", - crate::config::ToastDelivery::System => "\"system\"", - }; - if self.update_config_file("toast setting", |content| { - let content = - crate::config::upsert_section_value(content, "ui.toast", "delivery", value); - crate::config::remove_section_key(&content, "ui.toast", "enabled") - }) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_agent_border_labels(&mut self, enabled: bool) { - if self.update_config_file("agent border labels", |content| { - crate::config::upsert_section_bool( - content, - "ui", - "show_agent_labels_on_pane_borders", - enabled, - ) - }) { - self.apply_config_from_disk(false); - } - } - - pub(super) fn save_agent_panel_sort(&mut self, sort: crate::app::state::AgentPanelSort) { - let value = match sort { - crate::app::state::AgentPanelSort::Spaces => { - crate::config::AgentPanelSortConfig::Spaces.as_str() - } - crate::app::state::AgentPanelSort::Priority => { - crate::config::AgentPanelSortConfig::Priority.as_str() - } - }; - if self.update_config_file("agent panel sort", |content| { - crate::config::upsert_section_value( - content, - "ui", - "agent_panel_sort", - &format!("\"{value}\""), - ) - }) { - self.apply_config_from_disk(false); - } - } -} diff --git a/src/app/creation.rs b/src/app/creation.rs index d531e733..fbe61902 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -1,13 +1,10 @@ use std::path::PathBuf; -use crate::api::schema::{EventData, EventEnvelope, EventKind}; -#[cfg(test)] -use tracing::error; - use super::{ api_helpers::{pane_agent_status, tab_attention_priority}, App, Mode, }; +use crate::api::schema::{EventData, EventEnvelope, EventKind}; use crate::{config::NewTerminalCwdConfig, workspace::Workspace}; pub(crate) fn resolve_new_terminal_cwd( @@ -81,6 +78,13 @@ impl App { resolve_new_terminal_cwd(&self.state.new_terminal_cwd, follow_cwd) } + pub(crate) fn resolved_new_workspace_cwd_from(&self, ws_idx: usize) -> PathBuf { + let follow_cwd = self + .focused_pane_cwd_in_workspace(ws_idx) + .or_else(|| self.seed_cwd_from_workspace(ws_idx)); + self.resolve_new_terminal_cwd(follow_cwd) + } + pub(super) fn workspace_creation_source(&self) -> Option<usize> { if self.state.mode == Mode::Navigate && self.state.workspaces.get(self.state.selected).is_some() @@ -96,128 +100,6 @@ impl App { }) } - pub(super) fn begin_tui_workspace_create(&mut self, request_id: &'static str) { - if self.state.prompt_new_workspace_name { - let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - let cwd = self.resolve_new_terminal_cwd(follow_cwd); - super::input::open_new_workspace_dialog(&mut self.state, cwd); - return; - } - - self.runtime_workspace_create( - request_id, - crate::api::schema::WorkspaceCreateParams { - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - /// Create a workspace with a real PTY (needs event_tx). - #[cfg(test)] - pub(crate) fn create_workspace(&mut self) { - let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd); - if let Err(e) = self.create_workspace_with_events(initial_cwd, true) { - error!(err = %e, "failed to create workspace"); - self.state.mode = Mode::Navigate; - } - } - - #[cfg(test)] - pub(crate) fn create_tab(&mut self) { - let custom_name = self.state.requested_new_tab_name.take(); - let active_before = self.state.active; - let follow_cwd = self.state.active.and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd); - match self.create_tab_with_options(initial_cwd, true) { - Ok(created_idx) => { - let created_workspace = active_before.is_none(); - let ws_idx = if created_workspace { - Some(created_idx) - } else { - self.state.active - }; - let tab_idx = if created_workspace { 0 } else { created_idx }; - if let Some(name) = custom_name { - if let Some(ws) = - ws_idx.and_then(|ws_idx| self.state.workspaces.get_mut(ws_idx)) - { - if let Some(tab) = ws.tabs.get_mut(tab_idx) { - tab.set_custom_name(name); - } - self.schedule_session_save(); - } - } - if let Some(ws_idx) = ws_idx { - if created_workspace { - self.emit_workspace_open_events(ws_idx); - } else { - self.emit_tab_created_events(ws_idx, tab_idx); - } - } - } - Err(e) => { - error!(err = %e, "failed to create tab"); - } - } - } - - #[cfg(test)] - pub(super) fn create_tab_with_options( - &mut self, - initial_cwd: PathBuf, - focus: bool, - ) -> std::io::Result<usize> { - let Some(ws_idx) = self.state.active else { - return self.create_workspace_with_options(initial_cwd, focus); - }; - let (rows, cols) = self.state.estimate_pane_size(); - let ws = &mut self.state.workspaces[ws_idx]; - let (idx, terminal, runtime) = ws.create_tab( - rows, - cols, - initial_cwd, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.state.host_terminal_appearance, - crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), - Vec::new(), - )?; - let root_pane = ws.tabs[idx].root_pane; - self.terminal_runtimes.insert(terminal.id.clone(), runtime); - self.state.terminals.insert(terminal.id.clone(), terminal); - self.state.remove_alias_shadowed_by_new_pane(root_pane); - if focus { - self.state.switch_workspace_tab(ws_idx, idx); - self.state.mode = Mode::Terminal; - } - let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let tab_id = self - .public_tab_id(ws_idx, idx) - .unwrap_or_else(|| crate::workspace::public_tab_id_for_number(&workspace_id, idx + 1)); - let root_pane = self.state.workspaces[ws_idx].tabs[idx].root_pane.raw(); - crate::logging::tab_created(&workspace_id, &tab_id, root_pane); - self.schedule_session_save(); - Ok(idx) - } - pub(crate) fn create_workspace_with_options( &mut self, initial_cwd: PathBuf, @@ -226,17 +108,6 @@ impl App { self.create_workspace_with_launch_env(initial_cwd, focus, Vec::new()) } - #[cfg(test)] - pub(crate) fn create_workspace_with_events( - &mut self, - initial_cwd: PathBuf, - focus: bool, - ) -> std::io::Result<()> { - let ws_idx = self.create_workspace_with_options(initial_cwd, focus)?; - self.emit_workspace_open_events(ws_idx); - Ok(()) - } - pub(crate) fn create_workspace_with_launch_env( &mut self, initial_cwd: PathBuf, diff --git a/src/app/custom_commands.rs b/src/app/custom_commands.rs new file mode 100644 index 00000000..a2245884 --- /dev/null +++ b/src/app/custom_commands.rs @@ -0,0 +1,782 @@ +use std::fs; +use std::io::{self, Write}; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use ratatui::layout::Direction; + +use super::{App, Mode}; + +static NEXT_COMMAND_NAMESPACE: AtomicU64 = AtomicU64::new(1); + +pub(super) fn new_command_namespace() -> String { + let counter = NEXT_COMMAND_NAMESPACE.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{}-{nanos}-{counter}", std::process::id()) +} + +#[derive(Debug)] +pub(super) struct EndpointCommandRegistry { + entries: Vec<EndpointCommand>, +} + +#[derive(Debug)] +struct EndpointCommand { + id: String, + binding: crate::config::CustomCommandKeybind, + action: crate::protocol::ClientShellCommandAction, +} + +impl EndpointCommandRegistry { + pub(super) fn new(bindings: &[crate::config::CustomCommandKeybind]) -> Self { + let namespace = new_command_namespace(); + let entries = bindings + .iter() + .enumerate() + .map(|(index, binding)| EndpointCommand { + action: binding.action.into(), + id: format!("cmd_{namespace}_{index}"), + binding: binding.clone(), + }) + .collect(); + Self { entries } + } +} + +impl App { + pub(crate) fn client_shell_keybindings_profile(&self) -> Option<&str> { + self.client_shell_keybindings_profile.as_deref() + } + + pub(crate) fn client_shell_command_manifest(&self) -> Vec<crate::protocol::ClientShellCommand> { + self.endpoint_commands + .entries + .iter() + .map(|entry| crate::protocol::ClientShellCommand { + command_id: entry.id.clone(), + binding_label: entry.binding.label.clone(), + binding_labels: entry.binding.bindings.labels(), + action: entry.action, + description: entry.binding.description.clone(), + }) + .collect() + } + + pub(crate) fn resolve_client_shell_command( + &self, + id: &str, + ) -> Option<crate::config::CustomCommandKeybind> { + self.endpoint_commands + .entries + .iter() + .find(|entry| entry.id == id) + .map(|entry| entry.binding.clone()) + } + + pub(crate) fn handle_command_invoke( + &mut self, + id: String, + params: crate::api::schema::CommandInvokeParams, + ) -> String { + let Some(binding) = self.resolve_client_shell_command(¶ms.command_id) else { + return crate::app::api::responses::encode_error( + id, + "command_not_found", + "custom command manifest is stale; reload configuration", + ); + }; + if let Err((code, message)) = self.focus_client_shell_command_target(¶ms) { + return crate::app::api::responses::encode_error(id, code, message); + } + let selected_text = if binding.action == crate::config::CustomCommandAction::PluginAction { + let Some(selection) = params.selection.as_ref() else { + return self.execute_custom_command_response(id, &binding, None); + }; + if params.pane_id.as_deref() != Some(selection.pane_id.as_str()) { + return crate::app::api::responses::encode_error( + id, + "command_target_mismatch", + "command selection does not belong to the requested pane", + ); + } + match self.pane_selection_text(selection) { + Ok(text) => Some(text), + Err((code, message)) => { + return crate::app::api::responses::encode_error(id, code, message); + } + } + } else { + None + }; + self.execute_custom_command_response(id, &binding, selected_text) + } + + fn execute_custom_command_response( + &mut self, + id: String, + binding: &crate::config::CustomCommandKeybind, + selected_text: Option<String>, + ) -> String { + match self.execute_custom_command_binding(binding, selected_text) { + Ok(()) => crate::app::api::responses::encode_success( + id, + crate::api::schema::ResponseResult::Ok {}, + ), + Err(error) => { + crate::app::api::responses::encode_error(id, "command_failed", error.to_string()) + } + } + } + + fn focus_client_shell_command_target( + &mut self, + params: &crate::api::schema::CommandInvokeParams, + ) -> Result<(), (&'static str, String)> { + if let Some(pane_id) = params.pane_id.as_deref() { + let Some((workspace_index, pane)) = self.parse_pane_id(pane_id) else { + return Err(("pane_not_found", format!("pane not found: {pane_id}"))); + }; + let Some(tab_index) = + self.state.workspaces[workspace_index].find_tab_index_for_pane(pane) + else { + return Err(("pane_not_found", format!("pane not found: {pane_id}"))); + }; + self.validate_client_shell_command_parent_ids(params, workspace_index, tab_index)?; + self.state.focus_pane_in_workspace(workspace_index, pane); + return Ok(()); + } + + if let Some(tab_id) = params.tab_id.as_deref() { + let Some((workspace_index, tab_index)) = self.parse_tab_id(tab_id) else { + return Err(("tab_not_found", format!("tab not found: {tab_id}"))); + }; + self.validate_client_shell_command_workspace_id(params, workspace_index)?; + self.state.switch_workspace_tab(workspace_index, tab_index); + return Ok(()); + } + + if let Some(workspace_id) = params.workspace_id.as_deref() { + let Some(workspace_index) = self.parse_workspace_id(workspace_id) else { + return Err(( + "workspace_not_found", + format!("workspace not found: {workspace_id}"), + )); + }; + self.state.switch_workspace(workspace_index); + } + Ok(()) + } + + fn validate_client_shell_command_parent_ids( + &self, + params: &crate::api::schema::CommandInvokeParams, + workspace_index: usize, + tab_index: usize, + ) -> Result<(), (&'static str, String)> { + self.validate_client_shell_command_workspace_id(params, workspace_index)?; + if let Some(tab_id) = params.tab_id.as_deref() { + let actual = self + .public_tab_id(workspace_index, tab_index) + .unwrap_or_default(); + if tab_id != actual { + return Err(( + "command_target_mismatch", + "command pane does not belong to the requested tab".to_owned(), + )); + } + } + Ok(()) + } + + fn validate_client_shell_command_workspace_id( + &self, + params: &crate::api::schema::CommandInvokeParams, + workspace_index: usize, + ) -> Result<(), (&'static str, String)> { + if params + .workspace_id + .as_deref() + .is_some_and(|workspace_id| workspace_id != self.public_workspace_id(workspace_index)) + { + return Err(( + "command_target_mismatch", + "command target does not belong to the requested workspace".to_owned(), + )); + } + Ok(()) + } + pub(crate) fn execute_custom_command_binding( + &mut self, + binding: &crate::config::CustomCommandKeybind, + selected_text: Option<String>, + ) -> io::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(), selected_text) + .map_err(io::Error::other), + } + } + + 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(crate) 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::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::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)) + } +} + +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 { + fn test_app() -> crate::app::App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + crate::app::App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ) + } + + fn binding(action: crate::config::CustomCommandAction) -> crate::config::CustomCommandKeybind { + crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::prefix("z"), + label: "prefix+z".into(), + command: "secret-command --token hidden".into(), + action, + description: Some("safe description".into()), + width: None, + height: None, + } + } + + fn install(app: &mut crate::app::App, binding: crate::config::CustomCommandKeybind) { + app.endpoint_commands = super::EndpointCommandRegistry::new(&[binding]); + } + + #[test] + fn manifest_exposes_opaque_ids_without_command_text() { + let mut app = test_app(); + install(&mut app, binding(crate::config::CustomCommandAction::Shell)); + let manifest = app.client_shell_command_manifest(); + assert_eq!(manifest.len(), 1); + assert_eq!(manifest[0].binding_label, "prefix+z"); + assert_eq!(manifest[0].binding_labels, ["prefix+z"]); + assert_eq!(manifest[0].description.as_deref(), Some("safe description")); + assert!(!format!("{:?}", manifest).contains("secret-command")); + assert_eq!( + app.resolve_client_shell_command(&manifest[0].command_id) + .map(|binding| binding.command), + Some("secret-command --token hidden".into()) + ); + } + + #[test] + fn stale_command_id_is_rejected_after_definition_changes() { + let mut app = test_app(); + install(&mut app, binding(crate::config::CustomCommandAction::Shell)); + let old_id = app.client_shell_command_manifest()[0].command_id.clone(); + let mut replacement = binding(crate::config::CustomCommandAction::Shell); + replacement.command = "replacement-command".into(); + install(&mut app, replacement); + + let response = app.handle_command_invoke( + "request-1".into(), + crate::api::schema::CommandInvokeParams { + command_id: old_id, + workspace_id: None, + tab_id: None, + pane_id: None, + selection: None, + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "command_not_found"); + } + + #[test] + fn foreground_keybinding_projection_cannot_remap_command_ids() { + let mut app = test_app(); + install(&mut app, binding(crate::config::CustomCommandAction::Shell)); + let command_id = app.client_shell_command_manifest()[0].command_id.clone(); + let mut foreground_binding = binding(crate::config::CustomCommandAction::Shell); + foreground_binding.command = "different-foreground-command".into(); + app.state.keybinds.custom_commands = vec![foreground_binding]; + + assert_eq!( + app.resolve_client_shell_command(&command_id) + .map(|binding| binding.command), + Some("secret-command --token hidden".into()) + ); + } + + #[test] + fn command_ids_do_not_alias_across_endpoint_restarts() { + let mut old_app = test_app(); + install( + &mut old_app, + binding(crate::config::CustomCommandAction::Shell), + ); + let old_id = old_app.client_shell_command_manifest()[0] + .command_id + .clone(); + + let mut replacement_app = test_app(); + let mut replacement = binding(crate::config::CustomCommandAction::Shell); + replacement.command = "replacement-command".into(); + install(&mut replacement_app, replacement); + assert_ne!( + replacement_app.client_shell_command_manifest()[0].command_id, + old_id + ); + let response = replacement_app.handle_command_invoke( + "request-1".into(), + crate::api::schema::CommandInvokeParams { + command_id: old_id, + workspace_id: None, + tab_id: None, + pane_id: None, + selection: None, + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "command_not_found"); + } + + #[tokio::test] + async fn plugin_command_rejects_stale_client_selection_before_invocation() { + let mut app = test_app(); + let workspace = crate::workspace::Workspace::test_new("plugin-selection"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.terminal_runtimes.insert( + terminal_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"selected text\n"), + ); + let mut plugin = binding(crate::config::CustomCommandAction::PluginAction); + plugin.command = "missing.plugin-action".into(); + install(&mut app, plugin); + let command_id = app.client_shell_command_manifest()[0].command_id.clone(); + let workspace_id = app.public_workspace_id(0); + let tab_id = app.public_tab_id(0, 0).unwrap(); + let pane_id = app.public_pane_id(0, pane_id).unwrap(); + + let response = app.handle_command_invoke( + "request-selection".into(), + crate::api::schema::CommandInvokeParams { + command_id, + workspace_id: Some(workspace_id), + tab_id: Some(tab_id), + pane_id: Some(pane_id.clone()), + selection: Some(crate::api::schema::PaneSelectionReadParams { + pane_id, + anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 7 }, + content_revision: Some(u64::MAX), + }), + }, + ); + + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "stale_content"); + } + + #[cfg(unix)] + #[test] + fn shell_command_invocation_executes_endpoint_owned_definition() { + let mut app = test_app(); + let path = std::path::PathBuf::from(format!( + "/var/tmp/herdr-command-invoke-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_file(&path); + let mut command = binding(crate::config::CustomCommandAction::Shell); + command.command = format!("printf invoked > {}", path.display()); + install(&mut app, command); + let command_id = app.client_shell_command_manifest()[0].command_id.clone(); + + let response = app.handle_command_invoke( + "request-1".into(), + crate::api::schema::CommandInvokeParams { + command_id, + workspace_id: None, + tab_id: None, + pane_id: None, + selection: None, + }, + ); + let success: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, crate::api::schema::ResponseResult::Ok {}); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !path.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(std::fs::read_to_string(&path).unwrap(), "invoked"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn popup_commands_are_advertised_without_exposing_the_command_text() { + let mut app = test_app(); + install(&mut app, binding(crate::config::CustomCommandAction::Popup)); + + let manifest = app.client_shell_command_manifest(); + assert_eq!(manifest.len(), 1); + assert_eq!( + manifest[0].action, + crate::protocol::ClientShellCommandAction::Popup + ); + assert!(!format!("{manifest:?}").contains("secret-command")); + } +} diff --git a/src/app/git_refresh.rs b/src/app/git_refresh.rs index c266add4..fd512d55 100644 --- a/src/app/git_refresh.rs +++ b/src/app/git_refresh.rs @@ -529,7 +529,7 @@ mod tests { fn test_app(config: &crate::config::Config) -> super::super::App { super::super::App::new( config, - true, + crate::app::AppPolicy::TEST, None, tokio::sync::mpsc::unbounded_channel().1, crate::api::EventHub::default(), diff --git a/src/app/ids.rs b/src/app/ids.rs index c2fc0a4d..c33a5fff 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -24,7 +24,7 @@ impl App { )) } - pub(super) fn public_pane_id( + pub(crate) fn public_pane_id( &self, ws_idx: usize, pane_id: crate::layout::PaneId, 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 - ); - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs index c28f62da..90ee33b8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,7 +2,6 @@ //! //! - `state.rs` — AppState, Mode, and pure data structs //! - `actions.rs` — state mutations (testable without PTYs/async) -//! - `input.rs` — key/mouse → action translation pub(crate) mod actions; mod agent_resume; @@ -12,15 +11,13 @@ pub(crate) use agents::{AGENT_START_SETTLE_DELAY, MAX_AGENT_START_TIMEOUT}; mod api; mod api_helpers; pub(crate) use api_helpers::limit_snapshot_lines; -mod config_io; mod creation; +mod custom_commands; mod git_refresh; mod ids; -mod input; pub(crate) mod pane_graphics; mod popup; mod runtime; -mod runtime_mutations; mod session; pub mod state; mod tab_bar_status; @@ -30,31 +27,20 @@ mod theme_sync; mod window_title; mod worktrees; -use std::collections::{HashMap, HashSet}; -use std::future::pending; -use std::io::{self, Write}; +use std::collections::HashMap; +#[cfg(unix)] +use std::io; use std::sync::Arc; use std::time::{Duration, Instant}; const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); -pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30); -const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const GIT_REPO_DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750); const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); -const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -const PANE_COPY_HIGHLIGHT_DURATION: Duration = Duration::from_millis(500); -const COPY_FEEDBACK_DURATION: Duration = Duration::from_secs(2); - -use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, - execute, terminal, -}; + use ratatui::layout::Rect; -use ratatui::DefaultTerminal; use tokio::sync::{mpsc, Notify}; use tracing::info; @@ -80,21 +66,37 @@ pub(crate) struct OverlayPaneState { temp_files: Vec<std::path::PathBuf>, } -#[derive(Debug, Clone, Copy)] -pub(crate) struct PaneClickState { - pane_id: crate::layout::PaneId, - viewport_row: u16, - col: u16, - at: Instant, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AppPolicy { + pub(crate) restore_session: bool, + pub(crate) persist_session: bool, + pub(crate) persist_plugin_registry: bool, + pub(crate) background_updates: bool, } -impl PaneClickState { - fn is_double_click_for(self, next: Self) -> bool { - self.pane_id == next.pane_id - && next.at.duration_since(self.at) <= PANE_DOUBLE_CLICK_WINDOW - && self.viewport_row.abs_diff(next.viewport_row) <= 1 - && self.col.abs_diff(next.col) <= 1 - } +impl AppPolicy { + pub(crate) const PRODUCTION: Self = Self { + restore_session: true, + persist_session: true, + persist_plugin_registry: true, + background_updates: true, + }; + + #[cfg(test)] + pub(crate) const TEST: Self = Self { + restore_session: false, + persist_session: false, + persist_plugin_registry: false, + background_updates: false, + }; + + #[cfg(unix)] + pub(crate) const HANDOFF_REPLACEMENT: Self = Self { + restore_session: false, + persist_session: true, + persist_plugin_registry: true, + background_updates: true, + }; } pub struct App { @@ -109,12 +111,9 @@ pub struct App { pub(crate) api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, pub(crate) event_hub: crate::api::EventHub, pub(crate) last_focus: Option<(usize, crate::layout::PaneId)>, - pub(crate) no_session: bool, - pub(crate) input_rx: Option<mpsc::Receiver<crate::raw_input::RawInputEvent>>, - pub(crate) last_terminal_size: Option<(u16, u16)>, + pub(crate) policy: AppPolicy, pub(crate) config_diagnostic_deadline: Option<Instant>, pub(crate) toast_deadline: Option<Instant>, - pub(crate) copy_feedback_deadline: Option<Instant>, pub(crate) last_api_notification_at: Option<Instant>, pub(crate) last_git_remote_status_refresh: Instant, pub(crate) last_git_repo_discovery_refresh: Instant, @@ -126,10 +125,6 @@ pub struct App { pub(crate) pending_api_worktree_removes: HashMap<String, u64>, pub(crate) pending_api_worktree_remove_paths: HashMap<std::path::PathBuf, u64>, pub(crate) next_api_worktree_operation_id: u64, - pub(crate) last_sidebar_divider_click: Option<Instant>, - pub(crate) last_pane_click: Option<PaneClickState>, - pub(crate) pending_url_click_sources: HashSet<InputSourceId>, - pub(crate) next_resize_poll: Instant, pub(crate) next_auto_update_check: Option<Instant>, pub(crate) next_agent_manifest_update_check: Option<Instant>, pub(crate) update_version_check_enabled: bool, @@ -137,8 +132,6 @@ pub struct App { pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig, pub(crate) agent_metadata_deadline: Option<Instant>, pub(crate) pending_agent_resume_deadline: Option<Instant>, - pub(crate) selection_autoscroll_deadline: Option<Instant>, - pub(crate) selection_highlight_clear_deadline: Option<Instant>, pub(crate) session_save_deadline: Option<Instant>, pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>, pub(crate) detached_process_children: Vec<std::process::Child>, @@ -153,91 +146,30 @@ pub struct App { pub(crate) last_render_at: Option<Instant>, /// Last attempt that could update a connected presentation surface. pub(crate) last_presentation_at: Option<Instant>, - pub(crate) input_leases: input::InputLeaseTable, pub render_notify: Arc<Notify>, pub(crate) render_dirty: Arc<crate::render_signal::RenderSignal>, pub(crate) full_redraw_pending: bool, pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>, - pub(crate) local_terminal_notifications: bool, - /// Whether this process applies `AppEvent::PrefixInputSource` to the host input source. - /// The headless server sets this to false: the switch belongs to the foreground client, - /// even when an App-internal drain consumes the event before the forwarding drain. - pub(crate) local_input_source_switch: bool, pub(crate) config_reloaded_from_disk: bool, - prefix_input_source: Box<dyn crate::platform::PrefixInputSource>, + client_shell_keybindings_profile: Option<String>, + endpoint_commands: custom_commands::EndpointCommandRegistry, } pub(crate) const APP_EVENT_CHANNEL_CAPACITY: usize = 256; pub(crate) const APP_EVENT_DRAIN_LIMIT: usize = 64; -pub(crate) enum LoopEvent { - Timer, - Internal(AppEvent), - Api(Box<crate::api::ApiRequestMessage>), - RawInput(crate::raw_input::RawInputEvent), - InputClosed, - RenderRequested, +fn auto_updates_enabled(background_updates: bool) -> bool { + background_updates && !cfg!(debug_assertions) } -struct SyncOutputGuard; - -impl SyncOutputGuard { - fn begin() -> io::Result<Self> { - let mut stdout = io::stdout().lock(); - stdout.write_all(b"\x1b[?2026h")?; - stdout.flush()?; - Ok(Self) - } +fn background_update_check_enabled(background_updates: bool, check_enabled: bool) -> bool { + auto_updates_enabled(background_updates) && check_enabled } -impl Drop for SyncOutputGuard { - fn drop(&mut self) { - let mut stdout = io::stdout().lock(); - let _ = stdout.write_all(b"\x1b[?2026l"); - let _ = stdout.flush(); - } -} - -async fn recv_raw_input_or_pending( - input_rx: Option<&mut mpsc::Receiver<crate::raw_input::RawInputEvent>>, -) -> Option<crate::raw_input::RawInputEvent> { - match input_rx { - Some(rx) => rx.recv().await, - None => pending().await, - } -} - -async fn sleep_until_or_pending(deadline: Option<Instant>) { - match deadline { - Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await, - None => pending().await, - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TerminalInputTarget { - terminal_id: crate::terminal::TerminalId, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum TerminalInputContext { - Pane, - Popup(crate::terminal::TerminalId), -} - -pub(crate) type InputSourceId = u64; -const LOCAL_INPUT_SOURCE: InputSourceId = 0; - -fn auto_updates_enabled(no_session: bool) -> bool { - !no_session && !cfg!(debug_assertions) -} - -fn background_update_check_enabled(no_session: bool, check_enabled: bool) -> bool { - auto_updates_enabled(no_session) && check_enabled -} - -fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginRegistry { - if no_session { +fn load_plugin_registry( + persist_plugin_registry: bool, +) -> crate::app::state::InstalledPluginRegistry { + if !persist_plugin_registry { return std::collections::HashMap::new(); } let entries = crate::persist::plugin_registry::load(); @@ -393,10 +325,33 @@ fn resolve_effective_theme( ) } +pub(crate) fn client_theme_runtime_from_config(config: &Config) -> state::ThemeRuntimeConfig { + theme_runtime_config(config, true) +} + +pub(crate) fn client_palette_for_theme( + runtime: &state::ThemeRuntimeConfig, + name: &str, +) -> state::Palette { + resolve_palette_for_theme_name(name, "catppuccin", runtime, None) +} + +pub(crate) fn client_palette_from_config(config: &Config) -> state::Palette { + let runtime = client_theme_runtime_from_config(config); + resolve_effective_theme(&runtime, None).0 +} + +pub(crate) fn client_palette_for_appearance( + runtime: &state::ThemeRuntimeConfig, + appearance: crate::terminal_theme::HostAppearance, +) -> state::Palette { + resolve_effective_theme(runtime, Some(appearance)).0 +} + impl App { pub fn new( config: &Config, - no_session: bool, + policy: AppPolicy, config_diagnostic: Option<String>, api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, event_hub: crate::api::EventHub, @@ -410,24 +365,8 @@ impl App { // Try to restore previous session let mut restored_terminals = std::collections::HashMap::new(); let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let ( - workspaces, - active, - selected, - sidebar_width, - sidebar_width_source, - sidebar_section_split, - collapsed_space_keys, - ) = if no_session { - ( - Vec::new(), - None, - 0, - config.ui.sidebar_width, - state::SidebarWidthSource::ConfigDefault, - 0.5_f32, - std::collections::HashSet::new(), - ) + let (workspaces, active, selected) = if !policy.restore_session { + (Vec::new(), None, 0) } else if let Some(snap) = crate::persist::load() { let history = config .experimental @@ -451,67 +390,19 @@ impl App { restored_terminal_runtimes = terminal_runtimes.into(); if ws.is_empty() { crate::logging::session_restored(0, "empty"); - ( - Vec::new(), - None, - 0, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), - if snap.sidebar_width.is_some() { - state::SidebarWidthSource::Persisted - } else { - state::SidebarWidthSource::ConfigDefault - }, - snap.sidebar_section_split.unwrap_or(0.5), - snap.collapsed_space_keys, - ) + (Vec::new(), None, 0) } else { crate::logging::session_restored(ws.len(), "ok"); let active = snap.active.filter(|&i| i < ws.len()); let selected = snap.selected.min(ws.len().saturating_sub(1)); - ( - ws, - active, - selected, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), - if snap.sidebar_width.is_some() { - state::SidebarWidthSource::Persisted - } else { - state::SidebarWidthSource::ConfigDefault - }, - snap.sidebar_section_split.unwrap_or(0.5), - snap.collapsed_space_keys, - ) + (ws, active, selected) } } else { - ( - Vec::new(), - None, - 0, - config.ui.sidebar_width, - state::SidebarWidthSource::ConfigDefault, - 0.5_f32, - std::collections::HashSet::new(), - ) + (Vec::new(), None, 0) }; let agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); - // Validate sidebar bounds before they reach any `u16::clamp(min, max)` - // call: `clamp` panics when `min > max`. On bad config, fall back to - // the built-in defaults rather than crashing on the first render. - let (sidebar_min_width, sidebar_max_width) = crate::config::validated_sidebar_bounds( - config.ui.sidebar_min_width, - config.ui.sidebar_max_width, - ) - .unwrap_or_else(|| { - tracing::warn!( - min = config.ui.sidebar_min_width, - max = config.ui.sidebar_max_width, - "ui.sidebar_min_width is greater than sidebar_max_width; falling back to default bounds (18, 36)" - ); - (18, 36) - }); - let worktree_directory = crate::worktree::expand_tilde_absolute_path(&config.worktrees.directory); @@ -530,11 +421,7 @@ impl App { let startup_product_announcement = crate::product_announcements::load_unseen_for_current_version(); - let mode = if config.should_show_onboarding() { - state::Mode::Onboarding - } else if startup_product_announcement.is_some() { - state::Mode::ProductAnnouncement - } else if active.is_some() { + let mode = if active.is_some() { state::Mode::Terminal } else { state::Mode::Navigate @@ -560,33 +447,9 @@ impl App { selected, mode, should_quit: false, - detach_exits: no_session, - detach_requested: false, - request_new_workspace: false, - request_new_tab: false, - request_new_linked_worktree: None, - request_open_existing_worktree: None, - request_new_workspace_cwd: None, - request_remove_linked_worktree: None, - request_submit_worktree_create: false, - request_submit_worktree_open: false, - request_submit_worktree_remove: false, - request_reload_config: false, request_client_config_reload: false, - request_clipboard_write: None, - creating_new_tab: false, - requested_new_tab_name: None, - pending_workspace_create_cwd: None, - rename_pane_target: None, - worktree_create: None, - worktree_open: None, - worktree_remove: None, worktree_directory, - collapsed_space_keys, - request_complete_onboarding: false, - name_input: String::new(), - name_input_replace_on_type: false, - release_notes: None, + latest_release_notes, product_announcement: startup_product_announcement.map(|announcement| { state::ProductAnnouncementState { version: announcement.version, @@ -597,36 +460,10 @@ impl App { preview: announcement.preview, } }), - keybind_help: state::KeybindHelpState::default(), - navigator: state::NavigatorState::default(), - copy_mode: None, - workspace_scroll: 0, - agent_panel_scroll: 0, - tab_scroll: 0, - tab_scroll_follow_active: true, - mobile_switcher_scroll: 0, view: state::ViewState { - layout: state::ViewLayout::Desktop, - sidebar_rect: Rect::default(), - workspace_card_areas: Vec::new(), - tab_bar_rect: Rect::default(), - tab_hit_areas: Vec::new(), - tab_scroll_left_hit_area: Rect::default(), - tab_scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), - mobile_header_rect: Rect::default(), - mobile_menu_hit_area: Rect::default(), - toast_hit_area: Rect::default(), pane_infos: Vec::new(), - split_borders: Vec::new(), }, - drag: None, - workspace_presses: HashMap::new(), - tab_presses: HashMap::new(), - selection: None, - selection_autoscroll: None, - context_menu: None, update_available, update_install_command, latest_release_notes_available, @@ -634,61 +471,33 @@ impl App { config_diagnostic, toast: None, pending_agent_notifications: std::collections::HashMap::new(), - copy_feedback: None, outer_terminal_focus: None, prefix_code, prefix_mods, headless_size: config.headless_size(), - default_sidebar_width: config.ui.sidebar_width, - sidebar_width, - sidebar_min_width, - sidebar_max_width, - mobile_width_threshold: config.ui.mobile_width_threshold, - sidebar_width_source, - sidebar_width_auto: false, - sidebar_collapsed: config.ui.sidebar_start_collapsed, - sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, - sidebar_section_split, agent_panel_sort, - status_indicators: config.ui.status_indicators, agent_view_override: None, sidebar_agents: config.ui.sidebar.agents.clone(), sidebar_spaces: config.ui.sidebar.spaces.clone(), next_agent_state_change_seq: 0, - mouse_capture: config.ui.mouse_capture, - copy_on_select: config.ui.copy_on_select, - right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), - right_click_passthrough: None, - redraw_on_focus_gained: config.ui.redraw_on_focus_gained, - mouse_scroll_lines: config.ui.mouse_scroll_lines(), confirm_close: config.ui.confirm_close, - prompt_new_tab_name: config.ui.prompt_new_tab_name, - prompt_new_workspace_name: config.ui.prompt_new_workspace_name, pane_borders: config.ui.pane_borders, pane_outer_borders: config.ui.pane_outer_borders, pane_scrollbars: config.ui.pane_scrollbars, pane_gaps: config.ui.pane_gaps, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, - hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, - tab_bar_position: config.ui.tab_bar_position, tab_bar_right: Vec::new(), tab_bar_right_separator: String::new(), - pane_history_persistence: config.experimental.pane_history, reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime, cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(), cjk_ime_agents: parse_cjk_ime_agents(&config.experimental.cjk_ime_agents), cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(), - switch_ascii_input_source_in_prefix: config - .experimental - .switch_ascii_input_source_in_prefix, kitty_graphics_enabled: config.experimental.kitty_graphics, default_shell: config.terminal.default_shell.clone(), shell_mode: config.terminal.shell_mode, new_terminal_cwd: config.terminal.new_cwd.clone(), pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, - accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), - local_sound_playback: true, toast_config: config.ui.toast.clone(), keybinds: config.keybinds(), palette: theme_palette, @@ -696,29 +505,19 @@ impl App { theme_runtime, host_terminal_appearance: None, host_terminal_appearance_explicit: false, - settings: state::SettingsState { - section: state::SettingsSection::Theme, - list: state::SelectionListState::new(0), - original_palette: None, - original_theme: None, - }, integration_recommendations: crate::integration::integration_recommendations(), agent_manifest_summaries, agent_manifest_update_status: crate::detect::manifest_update::load_status(), - integration_install_messages: Vec::new(), - installed_plugins: load_plugin_registry(no_session), + installed_plugins: load_plugin_registry(policy.persist_plugin_registry), plugin_panes: std::collections::HashMap::new(), popup_pane: None, plugin_command_logs: Vec::new(), next_plugin_command_log_id: 1, plugin_commands_in_flight: 0, - global_menu: state::MenuListState::new(0), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), host_cell_size: crate::kitty_graphics::HostCellSize::default(), - host_mouse_pixels: None, session_dirty: false, terminal_runtime_shutdowns: Vec::new(), - confirm_close_workspace_id: None, }; state.terminals = restored_terminals; @@ -730,13 +529,15 @@ impl App { cwd.as_deref().and_then(crate::workspace::git_branch); } - // Background auto-update is disabled in monolithic no-session mode + // Background auto-update is disabled for non-persistent test apps // and in debug/test builds so local development never mutates the // running binary out from under spawned test processes. let version_check_enabled = - background_update_check_enabled(no_session, config.update.version_check); - let manifest_check_enabled = - background_update_check_enabled(no_session, config.update.manifest_check); + background_update_check_enabled(policy.background_updates, config.update.version_check); + let manifest_check_enabled = background_update_check_enabled( + policy.background_updates, + config.update.manifest_check, + ); if version_check_enabled { let update_tx = event_tx.clone(); std::thread::spawn(move || crate::update::auto_update(update_tx)); @@ -754,11 +555,13 @@ impl App { .get(idx) .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); + let client_shell_keybindings_profile = config.local_keybindings_profile_toml().ok(); + let endpoint_commands = + custom_commands::EndpointCommandRegistry::new(&state.keybinds.custom_commands); let mut app = Self { config_diagnostic_deadline: None, toast_deadline: None, - copy_feedback_deadline: None, last_api_notification_at: None, state, pane_graphics: pane_graphics::Runtime::default(), @@ -778,10 +581,6 @@ impl App { pending_api_worktree_removes: HashMap::new(), pending_api_worktree_remove_paths: HashMap::new(), next_api_worktree_operation_id: 1, - last_sidebar_divider_click: None, - last_pane_click: None, - pending_url_click_sources: HashSet::new(), - next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL, next_auto_update_check: version_check_enabled .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), next_agent_manifest_update_check: manifest_check_enabled @@ -799,26 +598,20 @@ impl App { tab_bar_commands: Vec::new(), next_tab_bar_datetime_refresh: None, window_title_template: None, - selection_autoscroll_deadline: None, - selection_highlight_clear_deadline: None, persist_pane_history: config.experimental.pane_history, last_render_at: None, last_presentation_at: None, - input_leases: input::InputLeaseTable::default(), api_rx, event_hub, last_focus, - no_session, - input_rx: None, - last_terminal_size: terminal::size().ok(), + policy, render_notify, render_dirty, full_redraw_pending: false, overlay_panes: HashMap::new(), - local_terminal_notifications: true, - local_input_source_switch: true, config_reloaded_from_disk: false, - prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()), + client_shell_keybindings_profile, + endpoint_commands, }; app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator); app.configure_window_title(&config.ui.window_title); @@ -837,7 +630,13 @@ impl App { crate::handoff_runtime::ImportedHandoffRuntime, >, ) -> io::Result<Self> { - let mut app = Self::new(config, true, config_diagnostic, api_rx, event_hub); + let mut app = Self::new( + config, + AppPolicy::HANDOFF_REPLACEMENT, + config_diagnostic, + api_rx, + event_hub, + ); let (workspaces, terminals, runtimes) = crate::persist::restore_handoff( snapshot, config.advanced.scrollback_limit_bytes, @@ -850,20 +649,6 @@ impl App { )?; let pane_id_aliases = crate::persist::handoff_pane_aliases(snapshot, &workspaces); - app.no_session = false; - app.state.installed_plugins = load_plugin_registry(app.no_session); - let now = Instant::now(); - if background_update_check_enabled(app.no_session, app.update_version_check_enabled) { - app.next_auto_update_check = app - .state - .update_available - .is_none() - .then_some(now + AUTO_UPDATE_CHECK_INTERVAL); - } - if background_update_check_enabled(app.no_session, app.update_manifest_check_enabled) { - app.next_agent_manifest_update_check = Some(now + AUTO_UPDATE_CHECK_INTERVAL); - } - app.state.detach_exits = false; app.state.pane_id_aliases = pane_id_aliases; app.state.workspaces = workspaces; app.state.terminals = terminals; @@ -874,14 +659,6 @@ impl App { app.state.selected = snapshot .selected .min(app.state.workspaces.len().saturating_sub(1)); - if let Some(width) = snapshot.sidebar_width { - app.state.sidebar_width = width; - app.state.sidebar_width_source = state::SidebarWidthSource::Persisted; - } - if let Some(split) = snapshot.sidebar_section_split { - app.state.sidebar_section_split = split; - } - app.state.collapsed_space_keys = snapshot.collapsed_space_keys.clone(); app.state.mode = if app.state.active.is_some() { state::Mode::Terminal } else { @@ -906,367 +683,15 @@ impl App { self.terminal_runtimes.assume_handoff_ownership(); } - fn request_repaint(&mut self) { - self.full_redraw_pending = true; - } - - pub(crate) fn sync_prefix_input_source(&mut self, previous_mode: Mode) { - // Emit the input-source intent on entering/leaving the ASCII realm, like `ClipboardWrite`; - // the foreground (client, or this app in monolithic mode) applies the switch. Keyed on the - // realm so multi-level prefix commands stay ASCII. The switch is flag-gated but the restore - // always fires on exit, so a mid-interaction flag toggle can't strand the host on ASCII. - let active = match ( - previous_mode.wants_ascii_input(), - self.state.mode.wants_ascii_input(), - ) { - (false, true) if self.state.switch_ascii_input_source_in_prefix => true, - (true, false) => false, - _ => return, - }; - if let Err(err) = self - .event_tx - .try_send(crate::events::AppEvent::PrefixInputSource { active }) - { - tracing::warn!(active, %err, "failed to queue prefix input-source change"); - } - } - - pub(crate) fn handle_internal_event_with_prefix_sync( - &mut self, - event: crate::events::AppEvent, - ) -> bool { - let previous_mode = self.state.mode; - let changed = self.handle_internal_event_with_render_impact(event); - self.sync_prefix_input_source(previous_mode); - changed - } - - #[cfg(test)] - pub(crate) fn set_prefix_input_source( - &mut self, - source: Box<dyn crate::platform::PrefixInputSource>, - ) { - self.prefix_input_source = source; - } - - pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { - if self.input_rx.is_none() { - self.input_rx = Some(crate::raw_input::spawn_input_reader()); - } - self.query_host_terminal_theme(); - - let mut needs_render = true; - let mut sent_window_title: Option<Option<String>> = None; - let mut host_mouse_capture_active = self.state.mouse_capture; - let mut host_keyboard_report_all_active = false; - - while !self.state.should_quit { - self.reap_finished_detached_processes(); - if self.render_dirty.is_pending() { - needs_render = true; - } - // Drain a bounded internal-event batch for responsiveness. API handlers - // perform an exhaustive drain before reading pane/runtime state. - if self.drain_internal_events() { - needs_render = true; - } - if self.expire_due_metadata(Instant::now()) { - needs_render = true; - } - if self.drain_api_requests() { - needs_render = true; - } - - self.sync_focus_events(); - self.sync_session_save_schedule(); - - let now = Instant::now(); - if self.handle_scheduled_tasks(now, needs_render) { - needs_render = true; - } - - if self.state.request_complete_onboarding { - self.state.request_complete_onboarding = false; - self.open_settings_from_onboarding(); - needs_render = true; - } - - if self.state.request_new_workspace { - self.state.request_new_workspace = false; - self.runtime_workspace_create( - "tui.workspace.create", - crate::api::schema::WorkspaceCreateParams { - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - needs_render = true; - } - - if self.state.request_new_tab { - self.state.request_new_tab = false; - let label = self.state.requested_new_tab_name.take(); - self.runtime_tab_create( - "tui.tab.create", - crate::api::schema::TabCreateParams { - workspace_id: None, - cwd: None, - focus: true, - label, - env: Default::default(), - }, - ); - needs_render = true; - } - - if let Some(ws_idx) = self.state.request_new_linked_worktree.take() { - self.open_new_linked_worktree_dialog(ws_idx); - needs_render = true; - } - - if let Some(ws_idx) = self.state.request_open_existing_worktree.take() { - self.open_existing_worktree_dialog(ws_idx); - needs_render = true; - } - - if let Some(cwd) = self.state.request_new_workspace_cwd.take() { - self.runtime_workspace_create( - "tui.workspace.create_cwd", - crate::api::schema::WorkspaceCreateParams { - cwd: Some(cwd.display().to_string()), - focus: true, - label: None, - env: Default::default(), - }, - ); - needs_render = true; - } - - if let Some(ws_idx) = self.state.request_remove_linked_worktree.take() { - self.open_remove_linked_worktree_confirmation(ws_idx); - needs_render = true; - } - - if self.state.request_submit_worktree_create { - self.state.request_submit_worktree_create = false; - self.submit_worktree_create_via_api(); - needs_render = true; - } - - if self.state.request_submit_worktree_open { - self.state.request_submit_worktree_open = false; - self.submit_worktree_open_via_api(); - needs_render = true; - } - - if self.state.request_submit_worktree_remove { - self.state.request_submit_worktree_remove = false; - self.submit_worktree_remove_via_api(); - needs_render = true; - } - - if self.state.request_reload_config { - self.state.request_reload_config = false; - self.reload_config(); - needs_render = true; - } - - if self.ensure_default_workspace() { - needs_render = true; - } - - if self.pane_graphics.retain_live_panes(&self.state) { - needs_render = true; - } - - let now = Instant::now(); - self.render_dirty - .set_immediate_pty_sources(self.state.app_surface_pane_ids()); - self.sync_host_mouse_capture(&mut host_mouse_capture_active)?; - self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?; - - if needs_render && self.can_render_now(now) { - let render_request = self.render_dirty.take(); - self.sync_terminal_titles(&render_request.terminal_title_sources); - if self.window_title_configured() { - let title = self - .window_title() - .and_then(|title| crate::config::sanitize_window_title_text(&title)); - if sent_window_title.as_ref() != Some(&title) { - crate::terminal_effects::write_window_title( - &mut std::io::stdout(), - title.as_deref(), - )?; - sent_window_title = Some(title); - } - } - let _sync_output = SyncOutputGuard::begin()?; - let kitty_graphics_enabled = self.state.kitty_graphics_enabled; - if self.full_redraw_pending { - for cell in &mut terminal.current_buffer_mut().content { - cell.set_skip(true); - } - terminal.swap_buffers(); - self.full_redraw_pending = false; - } - let mut cell_size = self.state.host_cell_size; - terminal.draw(|frame| { - let area = frame.area(); - if kitty_graphics_enabled { - if let Some(observed_cell_size) = - crate::kitty_graphics::HostCellSize::try_from_terminal(area) - { - self.state.host_cell_size = observed_cell_size; - cell_size = observed_cell_size; - } else if !cell_size.is_known() { - cell_size = - crate::kitty_graphics::HostCellSize::fallback_for_area(area); - } - crate::ui::compute_view_with_cell_size( - &mut self.state, - &self.terminal_runtimes, - area, - cell_size, - ); - } else { - crate::ui::compute_view_with_runtime_registry( - &mut self.state, - &self.terminal_runtimes, - area, - ); - } - crate::ui::render_with_runtime_registry( - &self.state, - &self.terminal_runtimes, - frame, - ); - })?; - if kitty_graphics_enabled { - crate::kitty_graphics::paint_local_pane_graphics( - &self.state, - &self.pane_graphics, - &self.terminal_runtimes, - cell_size, - )?; - } - self.sync_pending_agent_resume_deadline(now); - if self.start_pending_agent_resumes(self.pending_agent_resume_due(now)) { - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - self.record_render_attempt(now, true); - needs_render = false; - continue; - } - - let next_deadline = self.next_loop_deadline(now, needs_render); - let event = { - let input_rx = self.input_rx.as_mut(); - tokio::select! { - maybe_api = self.api_rx.recv() => match maybe_api { - Some(msg) => LoopEvent::Api(Box::new(msg)), - None => LoopEvent::Timer, - }, - maybe_ev = self.event_rx.recv() => match maybe_ev { - Some(ev) => LoopEvent::Internal(ev), - None => LoopEvent::Timer, - }, - maybe_input = recv_raw_input_or_pending(input_rx) => match maybe_input { - Some(input) => LoopEvent::RawInput(input), - None => LoopEvent::InputClosed, - }, - _ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer, - _ = self.render_notify.notified() => LoopEvent::RenderRequested, - } - }; - - match event { - LoopEvent::Timer => {} - LoopEvent::Internal(ev) => { - if self.handle_internal_event_with_prefix_sync(ev) { - needs_render = true; - } - } - LoopEvent::Api(msg) => { - if self.handle_api_request_message(*msg) { - needs_render = true; - } - } - LoopEvent::RawInput(input) => { - if self.handle_raw_input_batch(input).await { - needs_render = true; - } - } - LoopEvent::InputClosed => { - self.input_rx = None; - } - LoopEvent::RenderRequested => { - if self.render_dirty.is_pending() { - needs_render = true; - } - } - } - } - - // Save session on exit (skip in --no-session mode) - if !self.no_session { - self.save_session_now(); - } - - Ok(()) - } - - fn sync_host_keyboard_report_all(&self, active: &mut bool) -> io::Result<()> { - let desired = self.host_keyboard_report_all_requested(); - if desired == *active { - return Ok(()); - } - crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired)?; - *active = desired; - Ok(()) - } - - fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> { - let desired = self - .state - .should_capture_host_mouse_from(&self.terminal_runtimes); - if desired == *active { - return Ok(()); - } - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - if desired { - execute!(io::stdout(), EnableMouseCapture)?; - } else { - execute!(io::stdout(), DisableMouseCapture)?; - } - *active = desired; - Ok(()) - } - pub(crate) fn ensure_default_workspace(&mut self) -> bool { - if !self.state.workspaces.is_empty() - || self.state.mode == Mode::Onboarding - || self.state.pending_workspace_create_cwd.is_some() - { + if !self.state.workspaces.is_empty() { return false; } - let previous_mode = self.state.mode; - let preserve_mode = matches!( - previous_mode, - Mode::ReleaseNotes | Mode::ProductAnnouncement | Mode::Settings - ); let cwd = self.resolve_new_terminal_cwd(None); match self.create_workspace_with_options(cwd, true) { - Ok(_) => { - if preserve_mode { - self.state.mode = previous_mode; - } - true - } + Ok(_) => true, Err(err) => { tracing::error!(err = %err, "failed to create default workspace"); self.state.mode = Mode::Navigate; @@ -1275,14 +700,7 @@ impl App { } } - pub(crate) fn dismiss_release_notes(&mut self) { - let preview = self - .state - .release_notes - .as_ref() - .is_some_and(|notes| notes.preview); - - self.state.release_notes = None; + fn mark_release_notes_seen(&mut self, preview: bool) { if !preview { if let Err(err) = crate::release_notes::mark_current_version_seen() { self.state.config_diagnostic = @@ -1290,16 +708,6 @@ impl App { self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5)); } } - - if self.state.product_announcement.is_some() { - self.state.mode = Mode::ProductAnnouncement; - } else { - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } } pub(crate) fn dismiss_product_announcement(&mut self) { @@ -1314,87 +722,6 @@ impl App { } } } - - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - pub(crate) fn scroll_release_notes(&mut self, delta: i16) { - let max_scroll = self.state.release_notes_max_scroll(); - if let Some(notes) = &mut self.state.release_notes { - notes.scroll = if delta.is_negative() { - notes.scroll.saturating_sub(delta.unsigned_abs()) - } else { - notes.scroll.saturating_add(delta as u16) - } - .min(max_scroll); - } - } - - pub(crate) fn scroll_product_announcement(&mut self, delta: i16) { - let max_scroll = self.state.product_announcement_max_scroll(); - if let Some(announcement) = &mut self.state.product_announcement { - announcement.scroll = if delta.is_negative() { - announcement.scroll.saturating_sub(delta.unsigned_abs()) - } else { - announcement.scroll.saturating_add(delta as u16) - } - .min(max_scroll); - } - } - - pub(crate) fn open_settings_from_onboarding(&mut self) { - self.mark_onboarding_complete(); - self.refresh_integration_recommendations(); - crate::app::input::open_settings_at(&mut self.state, state::SettingsSection::Integrations); - } - - pub(crate) fn refresh_integration_recommendations(&mut self) { - self.state.integration_recommendations = crate::integration::integration_recommendations(); - } - - pub(crate) fn install_recommended_integrations(&mut self) { - let targets = self - .state - .integration_recommendations - .iter() - .filter(|recommendation| recommendation.needs_install()) - .map(|recommendation| recommendation.target) - .collect::<Vec<_>>(); - - self.state.integration_install_messages.clear(); - if targets.is_empty() { - self.state - .integration_install_messages - .push("all detected integrations are current".to_string()); - return; - } - - for target in targets { - let label = crate::integration::integration_target_label(target); - match crate::integration::install_target(target) { - Ok(messages) => { - self.state - .integration_install_messages - .push(format!("installed {label}")); - self.state - .integration_install_messages - .extend(messages.into_iter().filter(|message| { - message.starts_with(crate::integration::INSTALL_WARNING_PREFIX) - })); - } - Err(err) => self - .state - .integration_install_messages - .push(format!("{label}: {err}")), - } - } - - self.state.integration_recommendations = crate::integration::integration_recommendations(); - self.state.mark_session_dirty(); } pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport { @@ -1431,6 +758,8 @@ impl App { } } }; + self.endpoint_commands = + custom_commands::EndpointCommandRegistry::new(&self.state.keybinds.custom_commands); self.sync_toast_deadline(previous_toast); report } @@ -1452,6 +781,12 @@ impl App { self.state.prefix_code = live.prefix.0; self.state.prefix_mods = live.prefix.1; self.state.keybinds = live.keybinds; + match config.local_keybindings_profile_toml() { + Ok(profile) => self.client_shell_keybindings_profile = Some(profile), + Err(err) => diagnostics.push(format!( + "failed to publish server keybindings: {err}; kept previous keybindings" + )), + } diagnostics.extend(keybind_diagnostics); } Err(keybind_diagnostics) => { @@ -1480,44 +815,14 @@ impl App { &config.ui.window_title, )); - self.state.default_sidebar_width = config.ui.sidebar_width; - if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault { - self.state.sidebar_width = config.ui.sidebar_width; - } - self.state.sidebar_min_width = config.ui.sidebar_min_width; - self.state.sidebar_max_width = config.ui.sidebar_max_width; - self.state.sidebar_collapsed_mode = config.ui.sidebar_collapsed_mode; - self.state.mobile_width_threshold = config.ui.mobile_width_threshold; - // Re-clamp the live width to the new bounds. No source guard — bounds - // always apply, including to widths owned by Persisted or Manual. - self.state.sidebar_width = self - .state - .sidebar_width - .clamp(self.state.sidebar_min_width, self.state.sidebar_max_width); - self.state.mouse_capture = config.ui.mouse_capture; - self.state.copy_on_select = config.ui.copy_on_select; - if self.state.redraw_on_focus_gained != config.ui.redraw_on_focus_gained { - self.state.request_client_config_reload = true; - } - self.state.redraw_on_focus_gained = config.ui.redraw_on_focus_gained; - if self.loaded_host_cursor != config.ui.host_cursor { - self.state.request_client_config_reload = true; - } self.loaded_host_cursor = config.ui.host_cursor; - self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines(); - self.state.right_click_passthrough_modifiers = - config.ui.right_click_passthrough_modifiers(); self.state.confirm_close = config.ui.confirm_close; - self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name; - self.state.prompt_new_workspace_name = config.ui.prompt_new_workspace_name; self.state.pane_borders = config.ui.pane_borders; self.state.pane_outer_borders = config.ui.pane_outer_borders; self.state.pane_scrollbars = config.ui.pane_scrollbars; self.state.pane_gaps = config.ui.pane_gaps; self.state.show_agent_labels_on_pane_borders = config.ui.show_agent_labels_on_pane_borders; - self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab; - self.state.tab_bar_position = config.ui.tab_bar_position; self.configure_tab_bar_status( &config.ui.tab_bar_right, &config.ui.tab_bar_right_separator, @@ -1525,14 +830,8 @@ impl App { self.configure_window_title(&config.ui.window_title); self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); - self.state.status_indicators = config.ui.status_indicators; self.state.sidebar_agents = config.ui.sidebar.agents.clone(); self.state.sidebar_spaces = config.ui.sidebar.spaces.clone(); - self.state.agent_panel_scroll = 0; - self.state.accent = crate::config::parse_color(&config.ui.accent); - if !self.state.local_sound_playback && self.state.sound != config.ui.sound { - self.state.request_client_config_reload = true; - } self.state.sound = config.ui.sound.clone(); self.state.toast_config = config.ui.toast.clone(); } @@ -1543,7 +842,6 @@ impl App { self.state.kitty_graphics_enabled = config.experimental.kitty_graphics; crate::kitty_graphics::set_enabled(config.experimental.kitty_graphics); if was_kitty_graphics_enabled && !config.experimental.kitty_graphics { - let _ = crate::kitty_graphics::clear_all_host_graphics(); self.pane_graphics.clear(); self.state.host_cell_size = crate::kitty_graphics::HostCellSize::default(); } @@ -1554,10 +852,7 @@ impl App { self.state.cjk_ime_agents = parse_cjk_ime_agents(&config.experimental.cjk_ime_agents); self.state.cjk_ime_cursor_shape = config.experimental.cjk_ime_cursor_shape.to_decscusr(); - self.state.switch_ascii_input_source_in_prefix = - config.experimental.switch_ascii_input_source_in_prefix; self.persist_pane_history = config.experimental.pane_history; - self.state.pane_history_persistence = config.experimental.pane_history; if !self.persist_pane_history { crate::persist::clear_history(); } @@ -1586,7 +881,7 @@ impl App { self.next_auto_update_check = None; } else if !previous_version_check_enabled && background_update_check_enabled( - self.no_session, + self.policy.background_updates, self.update_version_check_enabled, ) && self.state.update_available.is_none() @@ -1598,7 +893,7 @@ impl App { self.next_agent_manifest_update_check = None; } else if !previous_manifest_check_enabled && background_update_check_enabled( - self.no_session, + self.policy.background_updates, self.update_manifest_check_enabled, ) { @@ -1654,459 +949,27 @@ impl App { } } + self.state.request_client_config_reload = true; crate::config::ConfigReloadReport { status, diagnostics, } } } - -// --------------------------------------------------------------------------- -// Input routing for headless server mode -// --------------------------------------------------------------------------- - -impl App { - pub(crate) fn terminal_input_context(&self) -> Option<TerminalInputContext> { - if let Some(popup) = &self.state.popup_pane { - Some(TerminalInputContext::Popup(popup.terminal_id.clone())) - } else if self.state.mode == Mode::Terminal { - Some(TerminalInputContext::Pane) - } else { - None - } - } - - fn execute_repeat_plan_headless( - &mut self, - source_id: InputSourceId, - lease_key: input::InputLeaseKey, - key: crate::input::TerminalKey, - plan: input::RepeatPlan, - ) { - match plan { - input::RepeatPlan::Forwarded(target) => { - if !self.forward_terminal_key_to_target_headless(&target, key) { - self.input_leases.remove(&lease_key); - } - } - input::RepeatPlan::Reprocess { - context, - repetitions, - tracked, - } => { - let key = key - .with_kind(crossterm::event::KeyEventKind::Repeat) - .with_repeat_count(1); - let mut forwarded_target = None; - for _ in 0..repetitions { - if let Some(target) = &forwarded_target { - if !self.forward_terminal_key_to_target_headless(target, key.clone()) { - self.input_leases.remove(&lease_key); - break; - } - continue; - } - let current_context = self.terminal_input_context(); - if !self.input_leases.reprocess_allowed( - lease_key, - &context, - current_context.as_ref(), - tracked, - ) { - break; - } - if let Some(target) = - self.handle_terminal_key_headless_from(source_id, key.clone()) - { - if tracked { - self.input_leases.insert_forwarded( - lease_key, - target.clone(), - key.clone(), - ); - forwarded_target = Some(target); - } - } - } - } - input::RepeatPlan::Ignore => {} - } - } - - /// Routes raw input bytes from a client through the existing input pipeline. - /// - /// The input bytes are parsed into `RawInputEvent`s and then processed. - /// In terminal mode, keys are routed through the same semantic - /// key-handling path as monolithic herdr so they are re-encoded for the - /// focused pane's negotiated keyboard protocol instead of passing host - /// terminal escape sequences through unchanged. - #[cfg(test)] - pub(crate) fn route_client_input(&mut self, data: Vec<u8>) { - let events = crate::raw_input::parse_raw_input_bytes_sync(&data); - self.route_client_events(events, true); - } - - pub(crate) fn route_client_pixel_mouse( - &mut self, - source_id: InputSourceId, - data: &[u8], - geometry: crate::input::mouse::HostGeometry, - ) -> bool { - let Some((x, y)) = crate::input::mouse::parse_report(data) else { - return false; - }; - let Some((column, row)) = geometry.cell(x, y) else { - return false; - }; - let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else { - return false; - }; - let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); - if events.len() != 1 || !matches!(events[0], crate::raw_input::RawInputEvent::Mouse(_)) { - return false; - } - self.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); - self.route_client_events_from(source_id, std::mem::take(&mut events), false); - self.state.host_mouse_pixels = None; - true - } - - pub(crate) fn route_client_events( - &mut self, - events: Vec<crate::raw_input::RawInputEvent>, - apply_host_terminal_theme: bool, - ) { - self.route_client_events_from(LOCAL_INPUT_SOURCE, events, apply_host_terminal_theme); - } - - pub(crate) fn route_client_events_from( - &mut self, - source_id: InputSourceId, - events: Vec<crate::raw_input::RawInputEvent>, - apply_host_terminal_theme: bool, - ) { - for event in events { - let previous_mode = self.state.mode; - match event { - crate::raw_input::RawInputEvent::Key(key) => { - let lease_key = input::InputLeaseKey::new(source_id, &key); - let key = self.input_leases.normalize_press(&lease_key, key); - match key.kind { - crossterm::event::KeyEventKind::Press => { - let initial_context = self.terminal_input_context(); - let target = if initial_context.is_some() { - self.handle_terminal_key_headless_from(source_id, key.clone()) - } else { - self.handle_non_terminal_key_headless(key.clone()); - None - }; - let resulting_context = self.terminal_input_context(); - let plan = self.input_leases.complete_press( - lease_key, - &key, - initial_context.as_ref(), - resulting_context.as_ref(), - target, - ); - self.execute_repeat_plan_headless(source_id, lease_key, key, plan); - } - crossterm::event::KeyEventKind::Repeat => { - let current_context = self.terminal_input_context(); - let plan = self.input_leases.plan_repeat( - lease_key, - &key, - current_context.as_ref(), - ); - self.execute_repeat_plan_headless(source_id, lease_key, key, plan); - } - crossterm::event::KeyEventKind::Release => { - if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { - let _ = self - .forward_terminal_key_to_target_headless(&lease.target, key); - } - } - } - } - crate::raw_input::RawInputEvent::Text(text) => { - self.handle_text_commit_headless(text.as_str()); - } - crate::raw_input::RawInputEvent::Mouse(mouse) => { - if self.state.popup_pane.is_some() || self.state.mouse_capture { - self.handle_mouse_event_headless(source_id, mouse); - } else { - self.state - .handle_pane_mouse_only(&self.terminal_runtimes, mouse); - } - } - crate::raw_input::RawInputEvent::Paste(text) => { - if self.try_route_paste_to_popup(&text) { - } else if self.state.mode != Mode::Terminal { - self.paste_into_active_text_input(&text); - } else { - if let Some(ws_idx) = self.state.active { - if let Some(ws) = self.state.workspaces.get(ws_idx) { - if let Some(focused) = ws.focused_pane_id() { - if let Some(runtime) = self.state.runtime_for_pane_in_workspace( - &self.terminal_runtimes, - ws_idx, - focused, - ) { - let _ = runtime.try_send_paste(text); - } - } - } - } - } - } - crate::raw_input::RawInputEvent::OuterFocusGained => { - self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); - } - crate::raw_input::RawInputEvent::OuterFocusLost => { - self.release_input_source_headless(source_id); - self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); - } - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - if apply_host_terminal_theme { - self.update_host_terminal_theme(kind, color); - } - } - crate::raw_input::RawInputEvent::HostPaletteColors { colors } => { - if apply_host_terminal_theme { - self.update_host_terminal_palette_colors(&colors); - } - } - crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { - if apply_host_terminal_theme { - self.set_host_terminal_appearance(appearance, true); - } - } - crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => {} - crate::raw_input::RawInputEvent::Unsupported => {} - } - self.sync_prefix_input_source(previous_mode); - } - } - - pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) { - // Call this only when the input source is gone for good. A pending URL - // click has to outlive a plain focus change, because opening the URL - // raises the browser and costs the host terminal its focus before the - // mouse release arrives. - self.pending_url_click_sources.remove(&source_id); - self.release_input_source_headless(source_id); - } - - /// Handles a key event in non-terminal mode for the headless server. - /// - /// Uses the standalone handler functions that work on `&mut AppState` - /// since the server doesn't have the async context of the monolithic App. - fn handle_non_terminal_key_headless(&mut self, key: crate::input::TerminalKey) { - let key_event = key.as_key_event(); - if input::modal_paste_target_active(&self.state) - && input::is_modal_paste_shortcut(&key_event) - { - if let Some(text) = crate::platform::read_clipboard_text() { - self.paste_into_active_text_input(&text); - } - return; - } - - match self.state.mode { - Mode::Prefix => { - self.handle_prefix_key(key); - } - Mode::Navigate => { - self.handle_navigate_key(key); - } - Mode::Copy => { - self.handle_copy_mode_key(key); - } - 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::KeybindHelp => { - input::handle_keybind_help_key(&mut self.state, key); - } - Mode::GlobalMenu => { - input::handle_global_menu_key(&mut self.state, key_event); - } - 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::Settings => { - self.handle_settings_key(key_event); - } - Mode::Navigator => { - input::handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event); - } - Mode::Terminal => { - // Should not be called in terminal mode. - } - } - } - - /// Handles a mouse event for the headless server. - /// - /// Delegates to the same mouse handling logic used in the monolithic - /// mode (hit-testing against the rendered UI), which works because - /// the server's AppState maintains view geometry from virtual rendering. - fn handle_mouse_event_headless( - &mut self, - source_id: InputSourceId, - mouse: crossterm::event::MouseEvent, - ) { - self.handle_mouse_from_input_source(source_id, mouse); - } -} - #[cfg(test)] mod tests { use super::*; use crate::config::Config; use crate::detect::{Agent, AgentState}; - use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; - use std::cell::Cell; - use std::rc::Rc; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::sync::Mutex; - fn raw_key( - code: KeyCode, - modifiers: KeyModifiers, - kind: KeyEventKind, - ) -> crate::raw_input::RawInputEvent { - crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new(code, modifiers).with_kind(kind), - ) - } - - #[cfg(windows)] - #[tokio::test] - async fn native_repeats_and_releases_follow_the_pressed_pane() { - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 27, - virtual_scan_code: 1, - unicode: 27, - control_key_state: 0, - }; - let key = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(record); - let enhanced = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(crate::input::WindowsKeyRecord { - control_key_state: 0x0100, - ..record - }); - assert_ne!( - input::InputLeaseKey::new(7, &key), - input::InputLeaseKey::new(7, &enhanced) - ); - - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_capacity(80, 24, 6); - let (other_runtime, mut other_rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let release = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) - .with_windows_record(crate::input::WindowsKeyRecord { - key_down: false, - repeat_count: 1, - unicode: 0, - ..record - }) - .with_kind(KeyEventKind::Release); - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Key(key.clone())], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_events( - vec![ - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::Key(key.clone().with_kind(KeyEventKind::Repeat)), - crate::raw_input::RawInputEvent::Key(release), - crate::raw_input::RawInputEvent::Key(key.clone()), - crate::raw_input::RawInputEvent::OuterFocusLost, - ], - false, - ); - - for _ in 0..3 { - assert_eq!( - pressed_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") - ); - } - assert_eq!( - pressed_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;0;0;0;1_") - ); - assert!(pressed_rx.try_recv().is_err()); - - assert_eq!( - other_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") - ); - assert_eq!( - other_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_") - ); - assert!(other_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - fn release_notes_state() -> state::ReleaseNotesState { - state::ReleaseNotesState { - version: "0.1.0".into(), - body: "notes".into(), - scroll: 0, - preview: true, - } - } - fn test_app() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2131,230 +994,6 @@ mod tests { "/usr/bin/true" } - #[derive(Clone, Default)] - struct FakePrefixInputSource { - switch_calls: Rc<Cell<usize>>, - restore_calls: Rc<Cell<usize>>, - switched: Rc<Cell<bool>>, - will_switch: bool, - } - - impl FakePrefixInputSource { - fn switching() -> Self { - Self { - will_switch: true, - ..Self::default() - } - } - - fn no_op() -> Self { - Self { - will_switch: false, - ..Self::default() - } - } - } - - impl crate::platform::PrefixInputSource for FakePrefixInputSource { - fn switch_to_ascii(&mut self) { - self.switch_calls.set(self.switch_calls.get() + 1); - if self.will_switch { - self.switched.set(true); - } - } - - fn restore(&mut self) { - if self.switched.replace(false) { - self.restore_calls.set(self.restore_calls.get() + 1); - } - } - } - - /// Drain the app event channel, returning the `active` flags of any emitted - /// `PrefixInputSource` events (the host-local input-source intents). - fn drained_prefix_active(app: &mut App) -> Vec<bool> { - let mut out = Vec::new(); - while let Ok(ev) = app.event_rx.try_recv() { - if let crate::events::AppEvent::PrefixInputSource { active } = ev { - out.push(active); - } - } - out - } - - #[test] - fn sync_prefix_input_source_emits_switch_then_restore_when_enabled() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - // Terminal -> Prefix emits the ASCII-switch intent. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> Terminal emits the restore intent. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn sync_prefix_input_source_does_not_emit_switch_when_flag_disabled() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = false; - - // Entering the realm with the flag off emits nothing. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert!(drained_prefix_active(&mut app).is_empty()); - - // Leaving the realm still emits the restore (harmless if nothing was switched), so a - // mid-interaction flag toggle can't strand the host on ASCII. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn mode_wants_ascii_input_classification() { - // Allowlist: the prefix command/navigation realm wants ASCII. - for mode in [ - Mode::Prefix, - Mode::Navigate, - Mode::Navigator, - Mode::Copy, - Mode::Resize, - Mode::ConfirmClose, - Mode::ConfirmRemoveWorktree, - Mode::ContextMenu, - Mode::GlobalMenu, - Mode::KeybindHelp, - ] { - assert!(mode.wants_ascii_input(), "{mode:?} should want ASCII"); - } - // Everything else (terminal, text entry, startup overlays) keeps the user's IME. - for mode in [ - Mode::Terminal, - Mode::RenameWorkspace, - Mode::RenameTab, - Mode::RenamePane, - Mode::NewLinkedWorktree, - Mode::OpenExistingWorktree, - Mode::Settings, - Mode::Onboarding, - Mode::ReleaseNotes, - Mode::ProductAnnouncement, - ] { - assert!(!mode.wants_ascii_input(), "{mode:?} should keep the IME"); - } - } - - #[test] - fn sync_prefix_input_source_keeps_realm_across_multi_level_prefix_commands() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - // Terminal -> Prefix switches once. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> sub-mode and sub-mode -> sub-mode stay in the realm: no emit. - app.state.mode = Mode::Navigator; - app.sync_prefix_input_source(Mode::Prefix); - app.state.mode = Mode::Resize; - app.sync_prefix_input_source(Mode::Navigator); - assert!( - drained_prefix_active(&mut app).is_empty(), - "must not switch or restore while still in the realm" - ); - - // Leaving the realm back to the terminal restores. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Resize); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn sync_prefix_input_source_restores_when_entering_rename_text_mode() { - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Prefix -> RenameTab leaves the realm (text entry wants the IME): restore. - app.state.mode = Mode::RenameTab; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - - #[test] - fn handle_internal_event_prefix_input_source_applies_switch_and_restore() { - // The monolithic (in-process) path applies the host switch when it consumes the event. - let mut app = test_app(); - let fake = FakePrefixInputSource::switching(); - let switch_calls = fake.switch_calls.clone(); - let restore_calls = fake.restore_calls.clone(); - app.set_prefix_input_source(Box::new(fake)); - - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true }); - assert_eq!(switch_calls.get(), 1); - assert_eq!(restore_calls.get(), 0); - - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false }); - assert_eq!(restore_calls.get(), 1); - } - - #[test] - fn handle_internal_event_prefix_input_source_restore_is_safe_when_switch_was_noop() { - // Already-ASCII / failed-switch case: the restore on leave must stay harmless. - let mut app = test_app(); - let fake = FakePrefixInputSource::no_op(); - let switch_calls = fake.switch_calls.clone(); - let restore_calls = fake.restore_calls.clone(); - app.set_prefix_input_source(Box::new(fake)); - - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true }); - app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false }); - assert_eq!(switch_calls.get(), 1); - assert_eq!(restore_calls.get(), 0); - } - - #[tokio::test] - async fn raw_input_dispatch_emits_input_source_intent_when_leaving_prefix() { - // Leaving prefix mode happens inside the raw-input dispatch, not in `handle_key` itself — - // the sync must sit at the dispatch layer so any event that exits prefix (here Esc) still - // emits the restore intent. - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - // ctrl+b (the default prefix key) enters prefix mode → switch intent. - app.handle_raw_input_event(raw_key( - KeyCode::Char('b'), - KeyModifiers::CONTROL, - KeyEventKind::Press, - )) - .await; - assert_eq!(app.state.mode, Mode::Prefix); - assert_eq!(drained_prefix_active(&mut app), vec![true]); - - // Esc leaves prefix mode → restore intent. - app.handle_raw_input_event(raw_key( - KeyCode::Esc, - KeyModifiers::empty(), - KeyEventKind::Press, - )) - .await; - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(drained_prefix_active(&mut app), vec![false]); - } - fn config_env_lock() -> &'static Mutex<()> { crate::config::test_config_env_lock() } @@ -2393,7 +1032,7 @@ mod tests { let mut app = test_app(); app.git_refresh_in_flight = true; - let changed = app.handle_internal_event_with_prefix_sync(AppEvent::GitStatusRefreshed { + let changed = app.handle_internal_event_with_render_impact(AppEvent::GitStatusRefreshed { results: Vec::new(), cache_updates: Vec::new(), }); @@ -2424,10 +1063,10 @@ mod tests { result: Ok(output.map(str::to_string)), }; - assert!(!app.handle_internal_event_with_prefix_sync(event(generation, None))); - assert!(app.handle_internal_event_with_prefix_sync(event(generation, Some("ready")))); - assert!(!app.handle_internal_event_with_prefix_sync(event(generation, Some("ready")))); - assert!(!app.handle_internal_event_with_prefix_sync(event( + assert!(!app.handle_internal_event_with_render_impact(event(generation, None))); + assert!(app.handle_internal_event_with_render_impact(event(generation, Some("ready")))); + assert!(!app.handle_internal_event_with_render_impact(event(generation, Some("ready")))); + assert!(!app.handle_internal_event_with_render_impact(event( generation.wrapping_add(1), Some("stale"), ))); @@ -2475,59 +1114,6 @@ mod tests { } #[test] - fn clipboard_write_event_shows_feedback_toast() { - let mut app = test_app(); - - app.handle_internal_event(AppEvent::ClipboardWrite { - content: b"copied".to_vec(), - }); - - assert!(app.state.toast.is_none()); - let feedback = app.state.copy_feedback.as_ref().expect("copy feedback"); - assert_eq!(feedback.message, "copied to clipboard"); - assert!(app.copy_feedback_deadline.is_some()); - } - - #[test] - fn clipboard_feedback_can_be_disabled() { - let mut app = test_app(); - app.state.toast_config.clipboard.enabled = false; - - app.handle_internal_event(AppEvent::ClipboardWrite { - content: b"copied".to_vec(), - }); - - assert!(app.state.copy_feedback.is_none()); - assert!(app.copy_feedback_deadline.is_none()); - } - - #[test] - fn clipboard_feedback_does_not_replace_notification_toast() { - let mut app = test_app(); - app.state.toast = Some(crate::app::state::ToastNotification { - kind: crate::app::state::ToastKind::NeedsAttention, - title: "pi needs attention".to_string(), - context: "background · 2".to_string(), - position: None, - target: None, - }); - let original_toast = app.state.toast.clone(); - - app.handle_internal_event(AppEvent::ClipboardWrite { - content: b"copied".to_vec(), - }); - - assert_eq!(app.state.toast, original_toast); - assert_eq!( - app.state - .copy_feedback - .as_ref() - .map(|feedback| feedback.message.as_str()), - Some("copied to clipboard") - ); - } - - #[test] fn notification_show_api_creates_herdr_toast_with_position() { let mut app = test_app(); app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; @@ -2564,38 +1150,6 @@ mod tests { } #[test] - fn notification_show_api_herdr_toast_expires() { - let mut app = test_app(); - app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - - let response = - app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { - id: "notify".into(), - method: crate::api::schema::Method::NotificationShow( - crate::api::schema::NotificationShowParams { - title: "build failed".into(), - body: None, - position: None, - sound: crate::api::schema::NotificationShowSound::None, - }, - ), - }); - - let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap(); - assert_eq!( - parsed.result, - crate::api::schema::ResponseResult::NotificationShow { - shown: true, - reason: crate::api::schema::NotificationShowReason::Shown, - } - ); - let deadline = app.toast_deadline.expect("api toast deadline"); - assert!(app.handle_scheduled_tasks(deadline, false)); - assert!(app.state.toast.is_none()); - assert!(app.toast_deadline.is_none()); - } - - #[test] fn notification_show_api_respects_off_delivery() { let mut app = test_app(); app.state.toast_config.delivery = crate::config::ToastDelivery::Off; @@ -2765,62 +1319,18 @@ mod tests { config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); } #[test] - fn startup_uses_configured_sidebar_state() { - let mut config = Config::default(); - config.ui.sidebar_start_collapsed = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(app.state.sidebar_collapsed); - } - - #[test] - fn startup_uses_redraw_on_focus_gained_config() { - let mut config = Config::default(); - config.ui.redraw_on_focus_gained = false; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(!app.state.redraw_on_focus_gained); - } - - #[test] - fn workspace_name_prompt_suppresses_default_creation_while_pending() { - let mut app = test_app(); - app.state.prompt_new_workspace_name = true; - - app.begin_tui_workspace_create("test.workspace.create"); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(!app.ensure_default_workspace()); - assert!(app.state.workspaces.is_empty()); - - app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); - assert!(app.state.workspaces.is_empty()); - assert!(app.state.pending_workspace_create_cwd.is_none()); - } - - #[test] - fn startup_uses_workspace_name_prompt_config() { - let mut config = Config::default(); - config.ui.prompt_new_workspace_name = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert!(app.state.prompt_new_workspace_name); - } - - #[test] fn theme_auto_switch_is_opt_in_and_preserves_manual_default() { let mut config = Config::default(); config.theme.name = Some("tokyo-night".to_string()); @@ -2837,7 +1347,13 @@ mod tests { }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert!(!app.state.theme_runtime.auto_switch); assert_eq!(app.state.theme_name, "tokyo-night"); @@ -2850,12 +1366,19 @@ mod tests { config.theme.name = Some("tokyo-night".to_string()); config.theme.auto_switch = true; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.theme_name, "tokyo-night"); - assert!( - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true) - ); + assert!(app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + )); assert_eq!(app.state.theme_name, "tokyo-night-day"); assert_eq!(app.state.palette, state::Palette::tokyo_night_day()); @@ -2871,9 +1394,18 @@ mod tests { ..Default::default() }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true); + app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + ); assert_eq!(app.state.theme_name, "gruvbox-light"); assert_eq!( @@ -2903,7 +1435,13 @@ mod tests { ..Default::default() }); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!( app.state.palette.accent, @@ -2922,7 +1460,10 @@ mod tests { ratatui::style::Color::Rgb(16, 17, 18) ); - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true); + app.set_host_terminal_appearance_state( + Some(crate::terminal_theme::HostAppearance::Light), + true, + ); assert_eq!( app.state.palette.accent, @@ -2932,31 +1473,6 @@ mod tests { } #[test] - fn inferred_background_appearance_does_not_override_explicit_report() { - let mut config = Config::default(); - config.theme.name = Some("catppuccin".to_string()); - config.theme.auto_switch = true; - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Dark, true); - app.update_host_terminal_theme( - crate::terminal_theme::DefaultColorKind::Background, - crate::terminal_theme::RgbColor { - r: 0xff, - g: 0xff, - b: 0xff, - }, - ); - - assert_eq!( - app.state.host_terminal_appearance, - Some(crate::terminal_theme::HostAppearance::Dark) - ); - assert_eq!(app.state.theme_name, "catppuccin"); - } - - #[test] fn startup_restores_preview_update_available_from_saved_notes() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("startup-preview-update-available"); @@ -2969,6 +1485,81 @@ mod tests { assert_eq!(app.state.update_available.as_deref(), Some("99.99.99")); assert!(app.state.latest_release_notes_available); + assert_eq!( + app.state + .latest_release_notes + .as_ref() + .map(|notes| notes.version.as_str()), + Some("99.99.99") + ); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn update_ready_refreshes_cached_release_notes() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("update-ready-refreshes-release-notes"); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + let mut app = test_app(); + assert!(app.state.latest_release_notes.is_none()); + + crate::release_notes::save_pending("99.99.99", "### Changed\n- One").unwrap(); + app.handle_internal_event(AppEvent::UpdateReady { + version: "99.99.99".into(), + install_command: "herdr update".into(), + }); + + assert_eq!( + app.state.latest_release_notes.as_ref().map(|notes| ( + notes.version.as_str(), + notes.body.as_str(), + notes.preview + )), + Some(("99.99.99", "### Changed\n- One", true)) + ); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn release_notes_dismiss_api_marks_current_seen_but_keeps_preview_unseen() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("release-notes-dismiss-persistence"); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let dismiss = |app: &mut App, version: &str| { + let response = app.handle_api_request(crate::api::schema::Request { + id: format!("dismiss-{version}"), + method: crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: version.to_owned(), + }, + ), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["result"]["type"], "ok"); + }; + let show_on_startup = || { + let stored: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(crate::release_notes::pending_path()).unwrap(), + ) + .unwrap(); + stored["show_on_startup"].as_bool() + }; + + let current = env!("CARGO_PKG_VERSION"); + crate::release_notes::save_pending(current, "### Changed\n- Current").unwrap(); + let mut app = test_app(); + dismiss(&mut app, current); + assert_eq!(show_on_startup(), Some(false)); + + crate::release_notes::save_pending("99.99.99", "### Changed\n- Preview").unwrap(); + let mut app = test_app(); + dismiss(&mut app, "99.99.99"); + assert_eq!(show_on_startup(), Some(true)); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); @@ -3005,10 +1596,15 @@ mod tests { }; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.state.release_notes.is_none()); assert!(app.state.latest_release_notes_available); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -3016,7 +1612,7 @@ mod tests { } #[test] - fn startup_still_auto_opens_unseen_product_announcement() { + fn startup_loads_unseen_product_announcement_for_clients() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("startup-product-announcement-auto-open"); let state_home = path.parent().unwrap().join("state"); @@ -3042,9 +1638,15 @@ mod tests { }; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + let app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); - assert_eq!(app.state.mode, Mode::ProductAnnouncement); + assert_eq!(app.state.mode, Mode::Navigate); assert_eq!( app.state .product_announcement @@ -3052,7 +1654,6 @@ mod tests { .map(|announcement| announcement.id.as_str()), Some("startup-announcement") ); - assert!(app.state.release_notes.is_none()); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); restore_xdg_state_home(original_xdg_state_home); @@ -3066,35 +1667,12 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", + "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let selection_pane = crate::layout::PaneId::alloc(); - app.state.selection = Some(crate::selection::Selection::range( - selection_pane, - 0, - 0, - 1, - None, - )); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 1, - last_mouse_screen_row: 1, - inner_rect: ratatui::layout::Rect::new(0, 0, 2, 2), - }); - let selection_deadline = Instant::now(); - app.selection_autoscroll_deadline = Some(selection_deadline); - app.selection_highlight_clear_deadline = Some(selection_deadline); - app.last_pane_click = Some(PaneClickState { - pane_id: selection_pane, - viewport_row: 0, - col: 0, - at: selection_deadline, - }); app.next_auto_update_check = Some(Instant::now()); app.next_agent_manifest_update_check = Some(Instant::now()); let report = app.reload_config(); @@ -3113,33 +1691,8 @@ mod tests { crate::config::ToastDelivery::Herdr ); assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); - assert!(!app.state.redraw_on_focus_gained); - assert!(!app.state.copy_on_select); - assert!(app.state.prompt_new_workspace_name); - assert!(app.state.selection.is_some()); - assert!(app.state.selection_autoscroll.is_some()); - assert_eq!(app.selection_autoscroll_deadline, Some(selection_deadline)); - assert_eq!( - app.selection_highlight_clear_deadline, - Some(selection_deadline) - ); - assert!(app.last_pane_click.is_some()); - - app.state.mode = Mode::Copy; - app.state.selection = Some(crate::selection::Selection::range( - selection_pane, - 0, - 0, - 1, - None, - )); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert!(app.state.selection.is_some()); - assert_eq!( - app.state.right_click_passthrough_modifiers, - Some(KeyModifiers::CONTROL) - ); assert!(app.state.request_client_config_reload); assert_eq!(app.state.default_shell, "nu"); assert_eq!( @@ -3154,7 +1707,6 @@ mod tests { assert!(!app.update_manifest_check_enabled); assert!(app.next_auto_update_check.is_none()); assert!(app.next_agent_manifest_update_check.is_none()); - assert!(app.state.switch_ascii_input_source_in_prefix); assert!(app.state.config_diagnostic.is_none()); let toast = app.state.toast.as_ref().unwrap(); assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled); @@ -3166,23 +1718,19 @@ mod tests { } #[test] - fn reload_config_requests_client_reload_for_host_cursor_only_change() { + fn reload_config_requests_client_reload_for_key_only_change() { let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-host-cursor"); + let path = temp_config_path("reload-config-key-only"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "[ui]\nhost_cursor = \"native\"\n").unwrap(); + std::fs::write(&path, "[keys]\nprefix = \"ctrl+a\"\n").unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); app.state.request_client_config_reload = false; - let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!( - app.loaded_host_cursor, - crate::config::HostCursorModeConfig::Native - ); + assert_eq!(app.state.prefix_code, KeyCode::Char('a')); assert!(app.state.request_client_config_reload); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -3190,31 +1738,24 @@ mod tests { } #[test] - fn reload_config_updates_sidebar_width_only_when_config_owned() { + fn reload_config_requests_client_reload_for_host_cursor_only_change() { let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-width"); + let path = temp_config_path("reload-config-host-cursor"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "[ui]\nhost_cursor = \"native\"\n").unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - assert_eq!( - app.state.sidebar_width_source, - state::SidebarWidthSource::ConfigDefault - ); + app.state.request_client_config_reload = false; - std::fs::write(&path, "[ui]\nsidebar_width = 34\n").unwrap(); let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.default_sidebar_width, 34); - assert_eq!(app.state.sidebar_width, 34); - app.state.sidebar_width = 31; - app.state.sidebar_width_source = state::SidebarWidthSource::Manual; - std::fs::write(&path, "[ui]\nsidebar_width = 35\n").unwrap(); - let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.default_sidebar_width, 35); - assert_eq!(app.state.sidebar_width, 31); + assert_eq!( + app.loaded_host_cursor, + crate::config::HostCursorModeConfig::Native + ); + assert!(app.state.request_client_config_reload); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); @@ -3233,11 +1774,9 @@ mod tests { "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\nrow_gap = 1\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\nrow_gap = 3\n", ) .unwrap(); - app.state.agent_panel_scroll = 5; let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.agent_panel_scroll, 0); assert_eq!( app.state.sidebar_agents.rows, vec![vec![ @@ -3276,146 +1815,6 @@ mod tests { } #[test] - fn reload_config_does_not_reset_sidebar_to_startup_state() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-start-collapsed"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert!(!app.state.sidebar_collapsed); - - std::fs::write(&path, "[ui]\nsidebar_start_collapsed = true\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert!(!app.state.sidebar_collapsed); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_sidebar_collapsed_mode() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-collapsed-mode"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.sidebar_collapsed_mode, - crate::config::SidebarCollapsedModeConfig::Compact - ); - - std::fs::write(&path, "[ui]\nsidebar_collapsed_mode = \"hidden\"\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!( - app.state.sidebar_collapsed_mode, - crate::config::SidebarCollapsedModeConfig::Hidden - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_sidebar_bounds_and_reclamps() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-sidebar-bounds"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - // Default bounds. - assert_eq!(app.state.sidebar_min_width, 18); - assert_eq!(app.state.sidebar_max_width, 36); - assert_eq!( - app.state.mobile_width_threshold, - crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD - ); - - // Manually set a width and flip the source so the existing - // sidebar_width-only-when-config-owned guard does NOT update it. - app.state.sidebar_width = 30; - app.state.sidebar_width_source = state::SidebarWidthSource::Manual; - - // Tightening max below the current width must re-clamp the live width - // even when source is Manual — bounds always apply. - std::fs::write(&path, "[ui]\nsidebar_max_width = 24\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_max_width, 24); - assert_eq!( - app.state.sidebar_width, 24, - "manual width must re-clamp to new max" - ); - - // Loosening max leaves the live width alone (it's already within bounds). - app.state.sidebar_width = 24; - std::fs::write(&path, "[ui]\nsidebar_max_width = 60\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_max_width, 60); - assert_eq!(app.state.sidebar_width, 24); - - // Raising min above the current width re-clamps upward. - std::fs::write(&path, "[ui]\nsidebar_min_width = 30\n").unwrap(); - let report = app.reload_config(); - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.sidebar_min_width, 30); - assert_eq!( - app.state.sidebar_width, 30, - "manual width must re-clamp up to new min" - ); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn reload_config_updates_mobile_width_threshold() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("reload-config-mobile-width-threshold"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.mobile_width_threshold, - crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD - ); - - std::fs::write(&path, "[ui]\nmobile_width_threshold = 96\n").unwrap(); - let report = app.reload_config(); - - assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.mobile_width_threshold, 96); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn app_new_falls_back_to_default_bounds_on_inverted_config() { - let mut config = Config::default(); - config.ui.sidebar_min_width = 50; - config.ui.sidebar_max_width = 30; - - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - - assert_eq!( - app.state.sidebar_min_width, 18, - "App::new must fall back to default min when bounds are inverted" - ); - assert_eq!( - app.state.sidebar_max_width, 36, - "App::new must fall back to default max when bounds are inverted" - ); - } - - #[test] fn reload_config_invalid_sidebar_bounds_keeps_previous_ui_and_returns_partial() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("reload-config-invalid-sidebar-bounds"); @@ -3423,17 +1822,15 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let original_min = app.state.sidebar_min_width; - let original_max = app.state.sidebar_max_width; - let original_mouse_capture = app.state.mouse_capture; + let original_pane_borders = app.state.pane_borders; // Pair the bad bounds with another `[ui]` field change to confirm the // entire section is treated as invalid (not just the bounds). - let target_mouse_capture = !original_mouse_capture; + let target_pane_borders = !original_pane_borders; std::fs::write( &path, format!( - "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\nmouse_capture = {}\n", - target_mouse_capture + "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\npane_borders = {}\n", + target_pane_borders ), ) .unwrap(); @@ -3445,11 +1842,9 @@ mod tests { && diagnostic.contains("sidebar_max_width") && diagnostic.contains("greater") })); - assert_eq!(app.state.sidebar_min_width, original_min); - assert_eq!(app.state.sidebar_max_width, original_max); assert_eq!( - app.state.mouse_capture, original_mouse_capture, - "[ui] is treated as invalid on bad bounds; mouse_capture must not apply" + app.state.pane_borders, original_pane_borders, + "[ui] is treated as invalid on bad bounds; pane_borders must not apply" ); assert_eq!( app.state.config_diagnostic.as_deref(), @@ -3501,10 +1896,10 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let target_mouse_capture = !app.state.mouse_capture; + let target_pane_borders = !app.state.pane_borders; std::fs::write( &path, - format!("[ui]\nmouse_capture = {target_mouse_capture}\nmouse_captur = false\n"), + format!("[ui]\npane_borders = {target_pane_borders}\nmouse_captur = false\n"), ) .unwrap(); @@ -3515,7 +1910,7 @@ mod tests { report.diagnostics, vec!["unknown config key ui.mouse_captur; ignoring key"] ); - assert_eq!(app.state.mouse_capture, target_mouse_capture); + assert_eq!(app.state.pane_borders, target_pane_borders); assert_eq!( app.state.config_diagnostic.as_deref(), Some("config.toml has unknown keys; herdr config check") @@ -3622,85 +2017,6 @@ mod tests { std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - - #[test] - fn settings_save_toast_delivery_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("settings-save-toast-delivery"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.toast_config.delivery, - crate::config::ToastDelivery::Off - ); - - app.save_toast_delivery(crate::config::ToastDelivery::Terminal); - - assert_eq!( - app.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal - ); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("delivery = \"terminal\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn save_status_indicators_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("save-status-indicators"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!( - app.state.status_indicators, - crate::config::StatusIndicatorStyle::Dots - ); - - app.save_status_indicators(crate::config::StatusIndicatorStyle::Symbols); - - assert_eq!( - app.state.status_indicators, - crate::config::StatusIndicatorStyle::Symbols - ); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("status_indicators = \"symbols\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - #[test] - fn save_agent_panel_sort_persists_then_applies_live_config() { - let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("save-agent-panel-sort"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "onboarding = false\n").unwrap(); - std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); - - let mut app = test_app(); - assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Spaces); - - app.save_agent_panel_sort(state::AgentPanelSort::Priority); - - assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("agent_panel_sort = \"priority\"")); - assert!(app.state.config_diagnostic.is_none()); - - std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - #[test] fn reload_config_keeps_current_state_on_invalid_toml() { let _guard = config_env_lock().lock().unwrap(); @@ -3734,323 +2050,6 @@ mod tests { std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - - #[tokio::test] - async fn raw_input_waits_when_reader_is_gone() { - let result = - tokio::time::timeout(Duration::from_millis(20), recv_raw_input_or_pending(None)).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn terminal_mode_handles_repeat_key_events() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let handled = app - .handle_raw_input_event(raw_key( - KeyCode::Backspace, - KeyModifiers::empty(), - KeyEventKind::Repeat, - )) - .await; - - assert!(handled); - } - - #[tokio::test] - async fn monolithic_input_forwards_report_all_printable_event_kinds() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - for kind in [ - KeyEventKind::Press, - KeyEventKind::Repeat, - KeyEventKind::Release, - ] { - app.handle_raw_input_event(raw_key(KeyCode::Char('j'), KeyModifiers::empty(), kind)) - .await; - } - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn outer_focus_gained_marks_visible_done_panes_seen() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let root_pane = workspace.tabs[0].root_pane; - let split_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - let background_tab = workspace.test_add_tab(Some("background")); - let background_pane = workspace.tabs[background_tab].root_pane; - - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - let root_terminal_id = app.state.workspaces[0].tabs[0].panes[&root_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&root_terminal_id) - .unwrap() - .state = AgentState::Idle; - app.state.workspaces[0].tabs[0] - .panes - .get_mut(&root_pane) - .unwrap() - .seen = false; - let split_terminal_id = app.state.workspaces[0].tabs[0].panes[&split_pane] - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&split_terminal_id) - .unwrap() - .state = AgentState::Idle; - app.state.workspaces[0].tabs[0] - .panes - .get_mut(&split_pane) - .unwrap() - .seen = false; - let bg_terminal_id = app.state.workspaces[0].tabs[background_tab].panes[&background_pane] - .attached_terminal_id - .clone(); - app.state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Idle; - app.state.workspaces[0].tabs[background_tab] - .panes - .get_mut(&background_pane) - .unwrap() - .seen = false; - - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.outer_terminal_focus = Some(false); - - let handled = app - .handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await; - - assert!(handled); - assert_eq!(app.state.outer_terminal_focus, Some(true)); - assert!(app.state.workspaces[0].tabs[0].panes[&root_pane].seen); - assert!(app.state.workspaces[0].tabs[0].panes[&split_pane].seen); - assert!(!app.state.workspaces[0].tabs[background_tab].panes[&background_pane].seen); - } - - #[tokio::test] - async fn outer_focus_gained_does_not_require_full_redraw_when_disabled() { - let mut app = test_app(); - app.state.redraw_on_focus_gained = false; - - let handled = app - .handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await; - - assert!(handled); - assert_eq!(app.state.outer_terminal_focus, Some(true)); - assert!(!app.full_redraw_pending); - } - - #[tokio::test] - async fn monolithic_outer_focus_events_reach_reporting_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("focus-reporting"); - let pane_id = workspace.tabs[0].root_pane; - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[?1004h", - 4, - ); - workspace.insert_test_runtime(pane_id, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await - ); - assert_eq!( - input_rx - .recv() - .await - .expect("forwarded focus gained report"), - bytes::Bytes::from_static(b"\x1b[I") - ); - - assert!( - !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost) - .await - ); - assert_eq!( - input_rx.recv().await.expect("forwarded focus lost report"), - bytes::Bytes::from_static(b"\x1b[O") - ); - } - - #[tokio::test] - async fn outer_focus_events_reconcile_pending_pane_focus() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("focus-transition"); - let previous_pane = workspace.tabs[0].root_pane; - let next_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(previous_pane); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[?1004h", - 4, - ); - workspace.insert_test_runtime(next_pane, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.last_focus = Some((0, previous_pane)); - - assert!(app.state.focus_pane_in_workspace(0, next_pane)); - assert!( - !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost) - .await - ); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - assert!(input_rx.try_recv().is_err()); - - assert!(app.state.focus_pane_in_workspace(0, previous_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - - assert!(app.state.focus_pane_in_workspace(0, next_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) - .await - ); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[I") - ); - - assert!(app.state.focus_pane_in_workspace(0, previous_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[O") - ); - assert!(app.state.focus_pane_in_workspace(0, next_pane)); - app.sync_focus_events(); - assert_eq!( - input_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"\x1b[I") - ); - } - - #[tokio::test] - async fn repeat_key_events_are_ignored_outside_terminal_mode() { - let mut app = test_app(); - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - let handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Repeat, - )) - .await; - - assert!(!handled); - assert_eq!(app.state.mode, Mode::ReleaseNotes); - assert!(app.state.release_notes.is_some()); - } - - #[tokio::test] - async fn modal_press_does_not_leak_repeat_into_terminal_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - let press_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Press, - )) - .await; - let repeat_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Repeat, - )) - .await; - let release_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Release, - )) - .await; - let next_press_handled = app - .handle_raw_input_event(raw_key( - KeyCode::Enter, - KeyModifiers::empty(), - KeyEventKind::Press, - )) - .await; - - assert!(press_handled); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(!repeat_handled); - assert!(!release_handled); - assert!(next_press_handled); - } - #[test] fn read_only_api_requests_do_not_force_rerender() { let read_only = crate::api::schema::Request { @@ -4117,6 +2116,35 @@ mod tests { crate::api::schema::AgentViewClearParams::default(), ), }; + let command_invoke = crate::api::schema::Request { + id: "req_10".into(), + method: crate::api::schema::Method::CommandInvoke( + crate::api::schema::CommandInvokeParams { + command_id: "cmd_boot_1_0".into(), + workspace_id: Some("w1".into()), + tab_id: Some("w1:t1".into()), + pane_id: Some("w1:p1".into()), + selection: None, + }, + ), + }; + let announcement_dismiss = crate::api::schema::Request { + id: "req_11".into(), + method: crate::api::schema::Method::ProductAnnouncementDismiss( + crate::api::schema::ProductAnnouncementDismissParams { + version: "0.8.2".into(), + id: "client-shell".into(), + }, + ), + }; + let release_notes_dismiss = crate::api::schema::Request { + id: "req_12".into(), + method: crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: "0.8.2".into(), + }, + ), + }; assert!(!crate::api::request_changes_ui(&read_only)); assert!(!crate::api::request_changes_ui(&worktree_list)); @@ -4127,6 +2155,9 @@ mod tests { assert!(crate::api::request_changes_ui(&pane_focus_direction)); assert!(crate::api::request_changes_ui(&pane_resize)); assert!(crate::api::request_changes_ui(&agent_view)); + assert!(crate::api::request_changes_ui(&command_invoke)); + assert!(crate::api::request_changes_ui(&announcement_dismiss)); + assert!(crate::api::request_changes_ui(&release_notes_dismiss)); } #[test] @@ -4538,105 +2569,6 @@ mod tests { } #[tokio::test] - async fn pane_split_request_targets_pane_in_background_tab() { - let _guard = config_env_lock().lock().unwrap(); - let original_shell = std::env::var_os("SHELL"); - std::env::set_var("SHELL", exiting_test_command()); - - let mut app = test_app(); - let mut workspace = Workspace::test_new("api-pane-split-background-tab"); - let active_pane = workspace.tabs[0].root_pane; - let background_tab = workspace.test_add_tab(Some("worker")); - let target_pane = workspace.tabs[background_tab].root_pane; - workspace.switch_tab(background_tab); - let background_previous_focus = - workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.switch_tab(0); - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - let split_cwd = std::env::temp_dir(); - let target_terminal_id = app.state.workspaces[0] - .pane_state(target_pane) - .unwrap() - .attached_terminal_id - .clone(); - app.state - .terminals - .get_mut(&target_terminal_id) - .unwrap() - .cwd = split_cwd.clone(); - app.state.active = Some(0); - app.state.selected = 0; - app.state - .focus_pane_in_workspace(0, background_previous_focus); - app.state.focus_pane_in_workspace(0, active_pane); - - let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id; - let target_tab_id = app.public_tab_id(0, background_tab).unwrap(); - - let response = app.handle_api_request(crate::api::schema::Request { - id: "req_pane_split_background_tab".into(), - method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams { - workspace_id: None, - target_pane_id: Some(target_pane_id), - direction: crate::api::schema::SplitDirection::Right, - ratio: None, - cwd: None, - focus: false, - right_click: Default::default(), - env: Default::default(), - }), - }); - let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - - assert_eq!(response["result"]["type"], "pane_info"); - assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id); - let response_cwd = - std::path::PathBuf::from(response["result"]["pane"]["cwd"].as_str().unwrap()); - assert_eq!( - crate::worktree::canonical_or_original(&response_cwd), - crate::worktree::canonical_or_original(&split_cwd) - ); - assert_eq!(response["result"]["pane"]["focused"], false); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - active_pane - ); - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .focused(), - background_previous_focus - ); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .pane_count(), - 3 - ); - app.state.last_pane(); - assert_eq!(app.state.workspaces[0].active_tab, background_tab); - assert_eq!( - app.state.workspaces[0].tabs[background_tab] - .layout - .focused(), - background_previous_focus - ); - - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } - match original_shell { - Some(value) => std::env::set_var("SHELL", value), - None => std::env::remove_var("SHELL"), - } - } - - #[tokio::test] async fn pane_split_request_focuses_new_pane_when_requested() { let _guard = config_env_lock().lock().unwrap(); let original_shell = std::env::var_os("SHELL"); @@ -4977,15 +2909,14 @@ mod tests { let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["error"]["code"], "confirmation_required"); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.selected, 0); + assert_eq!(app.state.selected, 1); assert_eq!(app.state.workspaces.len(), 2); } #[test] fn session_dirty_flag_schedules_debounced_save() { let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; app.state.session_dirty = true; app.sync_session_save_schedule(); @@ -4995,24 +2926,9 @@ mod tests { } #[test] - fn next_loop_deadline_includes_session_save_deadline() { - let mut app = test_app(); - let now = Instant::now(); - app.session_save_deadline = Some(now + Duration::from_secs(2)); - app.next_resize_poll = now + Duration::from_secs(5); - app.next_auto_update_check = Some(now + Duration::from_secs(6)); - - assert_eq!( - app.next_loop_deadline(now, false), - app.session_save_deadline - ); - } - - #[test] fn headless_next_loop_deadline_ignores_resize_poll() { let mut app = test_app(); let now = Instant::now(); - app.next_resize_poll = now + Duration::from_millis(100); app.session_save_deadline = Some(now + Duration::from_secs(2)); app.next_auto_update_check = Some(now + Duration::from_secs(6)); @@ -5026,7 +2942,6 @@ mod tests { fn headless_next_loop_deadline_returns_none_when_resize_poll_is_only_deadline() { let mut app = test_app(); let now = Instant::now(); - app.next_resize_poll = now - Duration::from_millis(1); app.config_diagnostic_deadline = None; app.toast_deadline = None; app.next_auto_update_check = None; @@ -5040,16 +2955,6 @@ mod tests { } #[test] - fn due_session_save_deadline_is_cleared() { - let mut app = test_app(); - app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); - - app.handle_scheduled_tasks(Instant::now(), false); - - assert!(app.session_save_deadline.is_none()); - } - - #[test] fn due_session_save_starts_background_writer() { let _guard = crate::config::test_config_env_lock().lock().unwrap(); let config_home = unique_temp_path("background-session-save"); @@ -5057,12 +2962,12 @@ mod tests { std::env::remove_var(crate::session::SESSION_ENV_VAR); let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; app.state.workspaces = vec![Workspace::test_new("autosave")]; app.state.ensure_test_terminals(); app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); - app.handle_scheduled_tasks(Instant::now(), false); + app.start_background_session_save(); assert!(app.session_save_thread.is_some()); assert!(app.session_save_deadline.is_none()); @@ -5076,7 +2981,7 @@ mod tests { #[test] fn background_session_save_reschedules_when_writer_is_busy() { let mut app = test_app(); - app.no_session = false; + app.policy.persist_session = true; let (release_tx, release_rx) = std::sync::mpsc::channel(); app.session_save_thread = Some(std::thread::spawn(move || { let _ = release_rx.recv(); @@ -5088,14 +2993,14 @@ mod tests { assert!(app.session_save_deadline.is_some()); release_tx.send(()).unwrap(); - app.no_session = true; + app.policy.persist_session = false; app.save_session_now(); } #[test] fn final_session_save_joins_background_writer_before_returning() { let mut app = test_app(); - app.no_session = true; + app.policy.persist_session = false; let (release_tx, release_rx) = std::sync::mpsc::channel(); let (done_tx, done_rx) = std::sync::mpsc::channel(); app.session_save_thread = Some(std::thread::spawn(move || { @@ -5113,52 +3018,6 @@ mod tests { done_rx.try_recv().unwrap(); assert!(app.session_save_thread.is_none()); } - - #[test] - fn next_loop_deadline_includes_selection_autoscroll_deadline() { - let mut app = test_app(); - let now = Instant::now(); - app.next_resize_poll = now + Duration::from_millis(300); - app.selection_autoscroll_deadline = Some(now + Duration::from_millis(5)); - app.session_save_deadline = Some(now + Duration::from_millis(200)); - assert_eq!( - app.next_loop_deadline(now, false), - app.selection_autoscroll_deadline - ); - } - - #[test] - fn tick_selection_autoscroll_self_heals_when_state_cleared() { - let mut app = test_app(); - let now = Instant::now(); - app.state.selection_autoscroll = None; - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_on_rect_change() { - let mut app = test_app(); - let now = Instant::now(); - let ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - app.state.workspaces.push(ws); - app.state.active = Some(0); - app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); - // Set autoscroll with a stale inner_rect that doesn't match pane_infos - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 999, - inner_rect: ratatui::layout::Rect::new(0, 0, 1, 1), // wrong rect - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - #[tokio::test] async fn full_internal_event_queue_eventually_applies_working_to_idle_transition() { let mut app = test_app(); @@ -5239,1368 +3098,4 @@ mod tests { "Working→Idle should still apply after temporary queue pressure" ); } - - #[test] - fn route_client_input_dispatches_navigate_mode_keybinds() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - - // Start in navigate mode. - app.state.mode = Mode::Navigate; - - // Send Ctrl+B then Esc (prefix → leave navigate mode). - // Ctrl+B is 0x02 in raw terminal input. - // After entering navigate mode and pressing Esc, we should leave navigate mode. - let esc_bytes = vec![0x1b]; // Esc - app.route_client_input(esc_bytes); - // Esc in navigate mode should leave navigate mode. - assert_eq!( - app.state.mode, - Mode::Terminal, - "Esc should leave navigate mode and return to Terminal mode" - ); - } - - #[test] - fn explicit_text_commit_does_not_trigger_navigate_binding() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.detach_exits = false; - app.state.mode = Mode::Navigate; - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Text( - crate::input::TextCommit::new("q"), - )], - false, - ); - - assert!(!app.state.detach_requested); - assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.input_leases.is_empty()); - } - - #[test] - fn route_client_input_q_detaches_in_persistence_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.detach_exits = false; - - // Start in navigate mode. - app.state.mode = Mode::Navigate; - assert!(!app.state.detach_requested); - - let q_bytes = b"q".to_vec(); - app.route_client_input(q_bytes); - - assert!( - app.state.detach_requested, - "q should detach in persistence mode" - ); - assert_eq!( - app.state.mode, - Mode::Terminal, - "q should leave navigate mode" - ); - } - - #[test] - fn route_client_input_prefix_then_q_detaches_in_persistence_mode() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.detach_exits = false; - - // Start in terminal mode (default after workspace creation). - app.state.mode = Mode::Terminal; - assert!(!app.state.detach_requested); - - // Send Ctrl+B (prefix key, raw byte 0x02). - let prefix_bytes = vec![0x02]; - app.route_client_input(prefix_bytes); - - assert_eq!( - app.state.mode, - Mode::Prefix, - "prefix key should enter prefix mode" - ); - assert!( - !app.state.detach_requested, - "prefix key should not set detach flag" - ); - - let q_bytes = b"q".to_vec(); - app.route_client_input(q_bytes); - - assert!( - app.state.detach_requested, - "q should detach in persistence mode" - ); - assert_eq!( - app.state.mode, - Mode::Terminal, - "q should leave navigate mode" - ); - } - - #[test] - fn route_client_input_prefix_tab_dispatches_global_last_pane() { - let config: Config = toml::from_str( - r#" -[keys] -last_pane = "prefix+tab" -"#, - ) - .unwrap(); - let mut app = test_app(); - let mut first = Workspace::test_new("one"); - let first_second_tab = first.test_add_tab(Some("logs")); - let first_second_root = first.tabs[first_second_tab].root_pane; - let second = Workspace::test_new("two"); - let second_root = second.tabs[0].root_pane; - app.state.workspaces = vec![first, second]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.keybinds = config.keybinds(); - app.state.mode = Mode::Terminal; - app.state.switch_workspace_tab(0, first_second_tab); - app.state.switch_workspace_tab(1, 0); - - app.route_client_input(vec![0x02, b'\t']); - - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].active_tab, first_second_tab); - assert_eq!( - app.state.workspaces[0].focused_pane_id(), - Some(first_second_root) - ); - - app.route_client_input(vec![0x02, b'\t']); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(second_root)); - } - - #[tokio::test] - async fn route_client_input_double_prefix_passes_prefix_through_to_focused_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.prefix_code = KeyCode::Char('l'); - app.state.prefix_mods = KeyModifiers::CONTROL; - - app.route_client_input(vec![0x0c]); - assert_eq!(app.state.mode, Mode::Prefix); - - app.route_client_input(vec![0x0c]); - assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![0x0c])); - } - - #[tokio::test] - async fn route_client_input_reencodes_terminal_keys_for_focused_pane_protocol() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - // Ghostty/kitty-style Ctrl-C should be normalized back to the pane's - // negotiated encoding instead of being forwarded verbatim. - app.route_client_input(b"\x1b[99;5u".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![3])); - - // iTerm2 and rxvt-style hosts may send F4 as CSI 14~. Normalize it - // through the same semantic key path instead of leaking host bytes. - app.route_client_input(b"\x1b[14~".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1bOS") - ); - } - - #[tokio::test] - async fn host_report_all_supplies_printable_releases_for_event_type_only_panes() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[>4;2m\x1b[=3;1u", - 4, - ); - assert_eq!( - runtime.keyboard_protocol(), - crate::input::KeyboardProtocol::Kitty { flags: 3 } - ); - assert!(runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!(app.host_keyboard_report_all_requested()); - - app.route_client_input(b"\x1b[106;1:1u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) - .unwrap(); - runtime.test_process_pty_bytes(b"\x1b[>4;0m"); - assert!(!runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - assert!(!app.host_keyboard_report_all_requested()); - - #[cfg(unix)] - { - runtime.test_process_pty_bytes(b"\x1b[>4;1m"); - assert!(!runtime - .input_state() - .is_some_and(|state| state.modify_other_keys)); - assert!(!app.host_keyboard_report_all_requested()); - } - } - - #[tokio::test] - async fn host_report_all_follows_terminal_protocol_and_command_modes() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, _rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 1); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - assert!(app.host_keyboard_report_all_requested()); - - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) - .unwrap() - .test_process_pty_bytes(b"\x1b[<u"); - assert!(!app.host_keyboard_report_all_requested()); - - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) - .unwrap() - .test_process_pty_bytes(b"\x1b[>15u"); - let other_pane = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let (other_runtime, _rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>7u", 1); - app.state.workspaces[0].tabs[0] - .runtimes - .insert(other_pane, other_runtime); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(other_pane)); - assert!(!app.host_keyboard_report_all_requested()); - - assert!(app.state.focus_pane_in_workspace(0, focused)); - app.state.mode = Mode::Prefix; - assert!(app.host_keyboard_report_all_requested()); - app.state.mode = Mode::Navigate; - assert!(app.host_keyboard_report_all_requested()); - app.state.mode = Mode::RenameWorkspace; - assert!(!app.host_keyboard_report_all_requested()); - } - - #[tokio::test] - async fn route_client_input_forwards_report_all_printable_event_kinds() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn explicit_text_commit_bypasses_bindings_leases_and_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Text( - crate::input::TextCommit::new("你🙂"), - )], - false, - ); - - assert_eq!(rx.recv().await.unwrap().as_ref(), "你🙂".as_bytes()); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_ime_text_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input("你".as_bytes().to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static("你".as_bytes()) - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn kitty_associated_ime_text_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[32;;20320:22909u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static("你好".as_bytes()) - ); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_ascii_uppercase_bypasses_report_all_key_encoding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"A".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A")); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn committed_text_does_not_erase_owned_physical_key() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u".to_vec()); - app.route_client_input(b"j".to_vec()); - app.route_client_input(b"\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.try_recv().expect("physical press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("committed text"), - bytes::Bytes::from_static(b"j") - ); - assert_eq!( - rx.try_recv().expect("physical release"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn outer_focus_loss_releases_owned_report_all_keys() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events( - vec![ - raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - ), - crate::raw_input::RawInputEvent::OuterFocusLost, - ], - false, - ); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("synthetic release on focus loss"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn disconnected_input_source_releases_owned_report_all_keys() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events_from( - 42, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - app.clear_input_source(42); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("synthetic release on disconnect"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn physical_count_one_repeats_and_release_keep_the_pressed_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 5); - let (other_runtime, mut other_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - 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 roundtrip = |events: Vec<crate::protocol::ClientInputEvent>| { - let message = crate::protocol::ClientMessage::InputEvents { events }; - let encoded = - bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); - let (decoded, _): (crate::protocol::ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - let crate::protocol::ClientMessage::InputEvents { events } = decoded else { - panic!("expected structured input events"); - }; - events - .into_iter() - .map(|event| event.to_raw_input_event()) - .collect() - }; - let event = |kind, record| crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { record }, - }; - - app.route_client_events( - roundtrip(vec![event(crate::protocol::ClientKeyKind::Press, record)]), - false, - ); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_events( - roundtrip(vec![ - event(crate::protocol::ClientKeyKind::Press, record), - event(crate::protocol::ClientKeyKind::Repeat, record), - event( - crate::protocol::ClientKeyKind::Release, - crate::input::WindowsKeyRecord { - key_down: false, - unicode: 0, - ..record - }, - ), - ]), - false, - ); - - for expected in [ - b"\x1b[97;1:1u".as_slice(), - b"\x1b[97;1:2u".as_slice(), - b"\x1b[97;1:2u".as_slice(), - b"\x1b[97;1:3u".as_slice(), - ] { - assert_eq!(pressed_rx.try_recv().unwrap().as_ref(), expected); - } - assert!(pressed_rx.try_recv().is_err()); - assert!(other_rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - } - - #[tokio::test] - async fn grouped_physical_press_and_runtime_loss_preserve_count_then_close_the_lease() { - let mut app = test_app(); - let workspace = Workspace::test_new("test"); - let pane_id = workspace.focused_pane_id().unwrap(); - let terminal_id = workspace.tabs[0].terminal_id(pane_id).unwrap().clone(); - app.state.workspaces = vec![workspace]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - app.terminal_runtimes.insert(terminal_id.clone(), runtime); - - let record = crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 3, - virtual_key_code: 65, - virtual_scan_code: 30, - unicode: 97, - control_key_state: 0, - }; - let message = crate::protocol::ClientMessage::InputEvents { - events: vec![crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 3, - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { record }, - }], - }; - let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); - let (decoded, _): (crate::protocol::ClientMessage, _) = - bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); - let crate::protocol::ClientMessage::InputEvents { events } = decoded else { - panic!("expected structured input events"); - }; - - app.route_client_events( - events - .into_iter() - .map(|event| event.to_raw_input_event()) - .collect(), - false, - ); - app.shutdown_terminal_runtime(terminal_id.clone()); - - assert_eq!( - rx.try_recv().expect("grouped press"), - bytes::Bytes::from_static(b"\x1b[97;1:1u\x1b[97;1:2u\x1b[97;1:2u") - ); - assert_eq!( - rx.try_recv() - .expect("synthetic release before runtime shutdown"), - bytes::Bytes::from_static(b"\x1b[97;1:3u") - ); - assert!(rx.try_recv().is_err()); - assert!(app.input_leases.is_empty()); - assert!(app.terminal_runtimes.get(&terminal_id).is_none()); - } - - #[tokio::test] - async fn report_all_repeat_and_release_return_to_the_pressed_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let pressed_pane = workspace.focused_pane_id().unwrap(); - let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(pressed_pane); - let (pressed_runtime, mut pressed_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - let (other_runtime, mut other_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); - workspace.tabs[0] - .runtimes - .insert(pressed_pane, pressed_runtime); - workspace.tabs[0].runtimes.insert(other_pane, other_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106u".to_vec()); - assert!(app.state.focus_pane_in_workspace(0, other_pane)); - app.route_client_input(b"\x1b[106;1:2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:2u") - ); - assert_eq!( - pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(pressed_rx.try_recv().is_err()); - assert!(other_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn report_all_key_ownership_is_isolated_by_client() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let first_pane = workspace.focused_pane_id().unwrap(); - let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); - workspace.tabs[0].layout.focus_pane(first_pane); - let (first_runtime, mut first_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - let (second_runtime, mut second_rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(first_pane, first_runtime); - workspace.tabs[0] - .runtimes - .insert(second_pane, second_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_events_from( - 1, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - assert!(app.state.focus_pane_in_workspace(0, second_pane)); - app.route_client_events_from( - 2, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Press, - )], - false, - ); - app.route_client_events_from( - 1, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Release, - )], - false, - ); - app.route_client_events_from( - 2, - vec![raw_key( - KeyCode::Char('j'), - KeyModifiers::empty(), - KeyEventKind::Release, - )], - false, - ); - - for rx in [&mut first_rx, &mut second_rx] { - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - } - - #[tokio::test] - async fn report_all_release_survives_modifier_release_order() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[106:74;2u\x1b[106;1:3u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106:74;2:1u") - ); - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_does_not_forward_release_for_consumed_prefix() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[98;5u\x1b[98;5:3u".to_vec()); - - assert_eq!(app.state.mode, Mode::Prefix); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_preserves_shift_enter_for_modify_other_keys_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - runtime.test_process_pty_bytes(b"\x1b[>4;1m"); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b[13;2u".to_vec()); - - assert_eq!( - rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[27;2;13~") - ); - } - - #[tokio::test] - async fn route_client_input_splits_multi_event_payloads_before_forwarding() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"ab".to_vec()); - - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"a")); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"b")); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_forwards_multilingual_ime_text_to_focused_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let text = "中日한🙂"; - let (runtime, mut rx) = - TerminalRuntime::test_with_channel_capacity(80, 24, text.chars().count()); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(text.as_bytes().to_vec()); - - let mut forwarded = Vec::new(); - for _ in text.chars() { - let chunk = rx.recv().await.unwrap(); - forwarded.extend_from_slice(&chunk); - } - assert_eq!(forwarded, text.as_bytes()); - assert!(rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_input_forwards_long_voice_like_cjk_text_without_truncation() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(64); - let char_count = text.chars().count(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, char_count); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(text.as_bytes().to_vec()); - - let mut forwarded = Vec::new(); - for _ in 0..char_count { - let chunk = rx.recv().await.unwrap(); - forwarded.extend_from_slice(&chunk); - } - assert_eq!(forwarded, text.as_bytes()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn route_client_input_handles_mouse_events() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - - // Send a mouse scroll-up event via SGR encoding. - let mouse_bytes = b"\x1b[<64;10;5M".to_vec(); - // This should not panic even though mouse handling is simplified - // in headless mode. - app.route_client_input(mouse_bytes); - // No assertions on specific behavior — just no panic. - } - - #[test] - fn route_client_input_advances_onboarding_modal() { - let mut app = test_app(); - app.state.mode = Mode::Onboarding; - - app.route_client_input(b"\r".to_vec()); - - assert_eq!(app.state.mode, Mode::Settings); - assert_eq!( - app.state.settings.section, - state::SettingsSection::Integrations - ); - } - - #[test] - fn route_client_input_pastes_bracketed_text_into_rename_modal() { - let mut app = test_app(); - app.state.workspaces = vec![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.route_client_input(b"\x1b[200~feature/logs\x1b[201~".to_vec()); - - assert_eq!(app.state.name_input, "feature/logs"); - assert!(!app.state.name_input_replace_on_type); - } - - #[test] - fn route_client_input_rename_enter_submits_through_api_path() { - let mut app = test_app(); - 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(); - - app.route_client_input(b"\r".to_vec()); - - 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 route_client_input_context_menu_enter_submits_through_api_path() { - let mut app = test_app(); - 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(state::ContextMenuState { - kind: state::ContextMenuKind::Workspace { ws_idx: 1 }, - x: 2, - y: 2, - list: state::MenuListState::new(1), - }); - app.state.mode = Mode::ContextMenu; - - app.route_client_input(b"\r".to_vec()); - - 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) - })); - } - - #[test] - fn raw_ctrl_v_decodes_as_modal_paste_shortcut() { - let events = crate::raw_input::parse_raw_input_bytes_sync(&[0x16]); - let Some(crate::raw_input::RawInputEvent::Key(key)) = events.first() else { - panic!("expected ctrl-v key event"); - }; - - assert!(input::is_modal_paste_shortcut(&key.as_key_event())); - } - - #[test] - fn route_client_events_pastes_text_into_new_linked_worktree_modal() { - 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(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.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste( - "feature/linear-302".into(), - )], - true, - ); - - 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") - ); - } - - #[tokio::test] - async fn route_client_events_pastes_only_into_popup() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12); - app.install_test_popup_runtime(popup_runtime); - assert!(app - .state - .should_capture_host_mouse_from(&app.terminal_runtimes)); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste("popup-only".into())], - true, - ); - - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"popup-only") - ); - assert!(tiled_rx.try_recv().is_err()); - - app.route_client_events( - vec![raw_key( - KeyCode::Char('x'), - KeyModifiers::NONE, - KeyEventKind::Press, - )], - true, - ); - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"x") - ); - assert!(tiled_rx.try_recv().is_err()); - - app.state.mode = Mode::Settings; - assert!( - app.handle_raw_input_event(raw_key( - KeyCode::Char('y'), - KeyModifiers::NONE, - KeyEventKind::Repeat, - )) - .await - ); - assert_eq!( - popup_rx.try_recv().unwrap(), - bytes::Bytes::from_static(b"y") - ); - assert!(tiled_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn route_client_events_discards_paste_when_popup_runtime_is_missing() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let install_missing_popup = |app: &mut App| { - let popup_terminal_id = crate::terminal::TerminalId::alloc(); - app.state.terminals.insert( - popup_terminal_id.clone(), - crate::terminal::TerminalState::new( - popup_terminal_id.clone(), - std::path::PathBuf::from("/popup"), - ), - ); - app.state.popup_pane = Some(state::PopupPaneState { - pane_id: crate::layout::PaneId::alloc(), - terminal_id: popup_terminal_id, - width: None, - height: None, - }); - }; - install_missing_popup(&mut app); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Paste("discard-me".into())], - true, - ); - - assert!(tiled_rx.try_recv().is_err()); - assert!(app.state.popup_pane.is_none()); - - install_missing_popup(&mut app); - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::Paste( - "discard-monolithic".into(), - )) - .await - ); - assert!(tiled_rx.try_recv().is_err()); - assert!(app.state.popup_pane.is_none()); - } - - #[tokio::test] - async fn popup_mouse_motion_preserves_scrollback() { - let mut app = test_app(); - app.state.mode = Mode::Terminal; - app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( - 40, - 2, - 1024, - b"one\r\ntwo\r\nthree\r\n\x1b[?1003h\x1b[?1006h", - 4, - ); - popup_runtime.scroll_up(1); - assert!(popup_runtime - .scroll_metrics() - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - app.install_test_popup_runtime(popup_runtime); - let (_, inner) = - crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap(); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Moved, - column: inner.x + 1, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )], - true, - ); - - assert!(popup_rx.try_recv().is_ok()); - assert!(app - .popup_runtime() - .and_then(TerminalRuntime::scroll_metrics) - .is_some_and(|metrics| metrics.offset_from_bottom > 0)); - } - - #[tokio::test] - async fn route_client_events_routes_popup_mouse_when_global_capture_is_disabled() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("tiled"); - let focused = workspace.focused_pane_id().unwrap(); - let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24); - tiled_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); - workspace.tabs[0].runtimes.insert(focused, tiled_runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.mouse_capture = false; - app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); - - let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12); - popup_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h"); - app.install_test_popup_runtime(popup_runtime); - let (_, inner) = - crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap(); - - app.route_client_events( - vec![crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Down( - crossterm::event::MouseButton::Left, - ), - column: inner.x, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )], - true, - ); - - assert!(popup_rx.try_recv().is_ok()); - assert!(tiled_rx.try_recv().is_err()); - - assert!( - app.handle_raw_input_event(crate::raw_input::RawInputEvent::Mouse( - crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Down( - crossterm::event::MouseButton::Left, - ), - column: inner.x + 1, - row: inner.y, - modifiers: crossterm::event::KeyModifiers::NONE, - }, - )) - .await - ); - assert!(popup_rx.try_recv().is_ok()); - assert!(tiled_rx.try_recv().is_err()); - } - - #[test] - fn route_client_input_closes_release_notes_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::ReleaseNotes; - app.state.release_notes = Some(release_notes_state()); - - app.route_client_input(b"\x1b".to_vec()); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.release_notes.is_none()); - } - - #[test] - fn route_client_input_closes_settings_modal() { - let mut app = test_app(); - app.state.workspaces = vec![Workspace::test_new("test")]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Settings; - app.state.settings.original_theme = Some(app.state.theme_name.clone()); - app.state.settings.original_palette = Some(app.state.palette.clone()); - - app.route_client_input(b"\x1b".to_vec()); - - assert_eq!(app.state.mode, Mode::Terminal); - } - - #[test] - fn route_client_input_updates_host_terminal_theme_from_osc_response() { - let mut app = test_app(); - - app.route_client_input(b"\x1b]11;#123456\x07\x1b]4;7;rgb:aaaa/bbbb/cccc\x1b\\".to_vec()); - - assert_eq!( - app.state.host_terminal_theme.background, - Some(crate::terminal_theme::RgbColor { - r: 0x12, - g: 0x34, - b: 0x56, - }) - ); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xaa, - g: 0xbb, - b: 0xcc, - }) - ); - - app.route_client_input(crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xaa, - g: 0xbb, - b: 0xcc, - }) - ); - - app.route_client_input(b"\x1b]4;7;rgb:dddd/eeee/ffff\x1b\\".to_vec()); - assert_eq!( - app.state.host_terminal_theme.palette[7], - Some(crate::terminal_theme::RgbColor { - r: 0xdd, - g: 0xee, - b: 0xff, - }) - ); - } - - #[tokio::test] - async fn route_client_input_does_not_forward_incomplete_osc_introducer_to_pane() { - let mut app = test_app(); - let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 1); - workspace.tabs[0].runtimes.insert(focused, runtime); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - - app.route_client_input(b"\x1b]".to_vec()); - - assert!(rx.try_recv().is_err()); - } - - #[test] - fn parse_raw_input_bytes_with_ranges_tracks_offsets() { - // Verify that the range-aware parser correctly tracks byte offsets - // for events within a multi-event input buffer. - let input = b"\x1b[Aa".to_vec(); // Up arrow + 'a' - let events = crate::raw_input::parse_raw_input_bytes_with_ranges(&input); - - assert_eq!(events.len(), 2, "should parse Up arrow and 'a'"); - // Up arrow: \x1b[A = 3 bytes starting at offset 0 - assert_eq!(events[0].start, 0); - assert_eq!(events[0].len, 3); - // 'a': 1 byte starting at offset 3 - assert_eq!(events[1].start, 3); - assert_eq!(events[1].len, 1); - - // Verify the raw bytes for each event are correct. - assert_eq!( - &input[events[0].start..events[0].start + events[0].len], - b"\x1b[A" - ); - assert_eq!( - &input[events[1].start..events[1].start + events[1].len], - b"a" - ); - } } diff --git a/src/app/pane_graphics.rs b/src/app/pane_graphics.rs index c1d4841a..7b516116 100644 --- a/src/app/pane_graphics.rs +++ b/src/app/pane_graphics.rs @@ -31,6 +31,7 @@ pub(crate) struct Layer { #[derive(Debug)] pub(crate) struct DirectGate { pub(crate) transfer_id: u64, + pub(crate) image_id: u32, pub(crate) client_id: u64, pub(crate) deadline: std::time::Instant, pub(crate) written: bool, diff --git a/src/app/popup.rs b/src/app/popup.rs index c5877987..8bcd8ae3 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -37,18 +37,6 @@ impl App { true } - pub(crate) fn try_route_paste_to_popup(&mut self, text: &str) -> bool { - if self.state.popup_pane.is_none() { - return false; - } - let Some(runtime) = self.popup_runtime() else { - self.close_popup_pane(); - return true; - }; - let _ = runtime.try_send_paste(text.to_owned()); - true - } - pub(crate) fn spawn_popup_shell_command( &mut self, command: &str, @@ -218,7 +206,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 8367a223..99769e7b 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -3,11 +3,8 @@ use std::time::Instant; #[cfg(test)] use std::time::Duration; -use crossterm::terminal; - use super::{ background_update_check_enabled, App, AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL, - RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL, }; fn retain_detached_process_after_wait( pid: u32, @@ -31,10 +28,6 @@ impl App { } pub(crate) fn shutdown_terminal_runtime(&mut self, terminal_id: crate::terminal::TerminalId) { - let target = super::TerminalInputTarget { - terminal_id: terminal_id.clone(), - }; - self.release_input_target_headless(&target); if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { runtime.shutdown(); } @@ -47,377 +40,6 @@ impl App { } } - pub(crate) fn drain_api_requests(&mut self) -> bool { - let mut changed = false; - while let Ok(msg) = self.api_rx.try_recv() { - changed |= self.handle_api_request_message(msg); - self.shutdown_detached_terminal_runtimes(); - } - changed - } - - pub(super) fn handle_api_request_message( - &mut self, - msg: crate::api::ApiRequestMessage, - ) -> bool { - let previous_mode = self.state.mode; - let stream_open = match &msg.request.method { - crate::api::schema::Method::PaneGraphicsStreamOpen(params) => Some(params.clone()), - _ => None, - }; - let stream_active = msg.stream_active.clone(); - let mut changed = self.expire_due_metadata(Instant::now()); - changed |= crate::api::request_changes_ui(&msg.request); - let skip_default_workspace = matches!( - &msg.request.method, - crate::api::schema::Method::ServerStop(_) - | crate::api::schema::Method::ServerLiveHandoff(_) - ); - if matches!( - &msg.request.method, - crate::api::schema::Method::WorktreeCreate(_) - | crate::api::schema::Method::WorktreeRemove(_) - ) { - self.drain_all_internal_events(); - let deferred_changed = - self.handle_deferred_worktree_api_request(msg.request, msg.respond_to); - if !skip_default_workspace { - changed |= self.ensure_default_workspace(); - } - self.sync_prefix_input_source(previous_mode); - return changed | deferred_changed; - } - let response = self.handle_api_request(msg.request); - if let (Some(params), Some(active)) = (stream_open.as_ref(), stream_active) { - self.attach_pane_graphics_stream_active(params, active, &response); - } - if !skip_default_workspace { - changed |= self.ensure_default_workspace(); - } - let _ = msg.respond_to.send(response); - self.sync_prefix_input_source(previous_mode); - changed - } - - pub(super) async fn handle_raw_input_batch( - &mut self, - first: crate::raw_input::RawInputEvent, - ) -> bool { - let mut changed = self.handle_raw_input_event(first).await; - - while let Some(rx) = self.input_rx.as_mut() { - match rx.try_recv() { - Ok(event) => changed |= self.handle_raw_input_event(event).await, - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { - self.input_rx = None; - break; - } - } - } - - changed - } - - async fn execute_repeat_plan( - &mut self, - lease_key: super::input::InputLeaseKey, - key: crate::input::TerminalKey, - plan: super::input::RepeatPlan, - ) -> bool { - match plan { - super::input::RepeatPlan::Forwarded(target) => { - if !self.forward_terminal_key_to_target(&target, key).await { - self.input_leases.remove(&lease_key); - } - true - } - super::input::RepeatPlan::Reprocess { - context, - repetitions, - tracked, - } => { - let key = key - .with_kind(crossterm::event::KeyEventKind::Repeat) - .with_repeat_count(1); - let mut forwarded_target = None; - for _ in 0..repetitions { - if let Some(target) = &forwarded_target { - if !self - .forward_terminal_key_to_target(target, key.clone()) - .await - { - self.input_leases.remove(&lease_key); - break; - } - continue; - } - let current_context = self.terminal_input_context(); - if !self.input_leases.reprocess_allowed( - lease_key, - &context, - current_context.as_ref(), - tracked, - ) { - break; - } - if let Some(target) = self.handle_key(key.clone()).await { - if tracked { - self.input_leases.insert_forwarded( - lease_key, - target.clone(), - key.clone(), - ); - forwarded_target = Some(target); - } - } - } - true - } - super::input::RepeatPlan::Ignore => false, - } - } - - pub(super) async fn handle_raw_input_event( - &mut self, - event: crate::raw_input::RawInputEvent, - ) -> bool { - let previous_mode = self.state.mode; - let changed = match event { - crate::raw_input::RawInputEvent::Key(key) => { - let lease_key = super::input::InputLeaseKey::new(super::LOCAL_INPUT_SOURCE, &key); - let key = self.input_leases.normalize_press(&lease_key, key); - match key.kind { - crossterm::event::KeyEventKind::Press => { - let initial_context = self.terminal_input_context(); - let target = self.handle_key(key.clone()).await; - let resulting_context = self.terminal_input_context(); - let plan = self.input_leases.complete_press( - lease_key, - &key, - initial_context.as_ref(), - resulting_context.as_ref(), - target, - ); - self.execute_repeat_plan(lease_key, key, plan).await; - true - } - crossterm::event::KeyEventKind::Repeat => { - let current_context = self.terminal_input_context(); - let plan = self.input_leases.plan_repeat( - lease_key, - &key, - current_context.as_ref(), - ); - self.execute_repeat_plan(lease_key, key, plan).await - } - crossterm::event::KeyEventKind::Release => { - if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { - let _ = self - .forward_terminal_key_to_target(&lease.target, key) - .await; - } - false - } - } - } - crate::raw_input::RawInputEvent::Text(text) => { - self.handle_text_commit(text.into_string()).await; - true - } - crate::raw_input::RawInputEvent::Paste(text) => { - self.handle_paste(text).await; - true - } - crate::raw_input::RawInputEvent::Mouse(mouse) => { - let changes_view = !matches!(mouse.kind, crossterm::event::MouseEventKind::Moved) - || self.state.mode.mouse_motion_changes_view(); - if self.state.popup_pane.is_some() || self.state.mouse_capture { - self.handle_mouse(mouse); - } else { - self.state - .handle_pane_mouse_only(&self.terminal_runtimes, mouse); - } - changes_view - } - crate::raw_input::RawInputEvent::OuterFocusGained => { - #[cfg(not(windows))] - self.query_host_terminal_appearance(); - self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); - if self.state.redraw_on_focus_gained { - self.request_repaint(); - } - self.state.outer_terminal_focus = Some(true); - self.state.mark_active_tab_seen(); - true - } - crate::raw_input::RawInputEvent::OuterFocusLost => { - self.release_input_source(super::LOCAL_INPUT_SOURCE).await; - self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); - self.state.outer_terminal_focus = Some(false); - false - } - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - self.update_host_terminal_theme(kind, color) - } - crate::raw_input::RawInputEvent::HostPaletteColors { colors } => { - self.update_host_terminal_palette_colors(&colors) - } - crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => { - self.query_host_terminal_theme(); - self.set_host_terminal_appearance(appearance, true) - } - // Cell size reports are consumed by the thin client, not the runtime. - crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => false, - crate::raw_input::RawInputEvent::Unsupported => false, - }; - self.sync_prefix_input_source(previous_mode); - self.shutdown_detached_terminal_runtimes(); - changed - } - - fn handle_resize_poll(&mut self) -> bool { - let Ok(size) = terminal::size() else { - return false; - }; - if self.last_terminal_size != Some(size) { - self.last_terminal_size = Some(size); - return true; - } - false - } - - pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant, geometry_dirty: bool) -> bool { - let mut changed = false; - let mut resized = false; - - if now >= self.next_resize_poll { - resized = self.handle_resize_poll(); - changed |= resized; - self.next_resize_poll = now + RESIZE_POLL_INTERVAL; - } - - if self - .config_diagnostic_deadline - .is_some_and(|deadline| now >= deadline) - { - self.config_diagnostic_deadline = None; - self.state.config_diagnostic = None; - changed = true; - } - - if self.toast_deadline.is_some_and(|deadline| now >= deadline) { - self.toast_deadline = None; - self.state.toast = None; - changed = true; - } - - if self - .state - .next_pending_agent_notification_deadline() - .is_some_and(|deadline| now >= deadline) - { - let previous_toast = self.state.toast.clone(); - let mut deliveries = self.state.drain_due_agent_notifications(now); - if !deliveries.is_empty() { - self.refresh_agent_notification_delivery_contexts(&mut deliveries); - self.emit_delayed_client_local_agent_notifications(&deliveries); - self.sync_toast_deadline(previous_toast); - changed = true; - } - } - - if self - .state - .next_managed_agent_deadline() - .is_some_and(|deadline| now >= deadline) - { - let panes = self.state.reconcile_managed_agents_at(now); - if !panes.is_empty() { - for (ws_idx, pane_id) in panes { - self.emit_pane_updated(ws_idx, pane_id); - } - self.schedule_session_save(); - changed = true; - } - } - - if self - .copy_feedback_deadline - .is_some_and(|deadline| now >= deadline) - { - self.copy_feedback_deadline = None; - self.state.copy_feedback = None; - changed = true; - } - - if self - .selection_autoscroll_deadline - .is_some_and(|deadline| now >= deadline) - { - self.tick_selection_autoscroll(now); - changed = true; - } - - changed |= self.clear_due_selection_highlight(now); - - self.start_git_status_refresh_if_due(now); - - if self - .next_auto_update_check - .is_some_and(|deadline| now >= deadline) - { - self.run_auto_update_check(); - } - - if self - .next_agent_manifest_update_check - .is_some_and(|deadline| now >= deadline) - { - self.run_agent_manifest_update_check(); - } - - if self - .session_save_deadline - .is_some_and(|deadline| now >= deadline) - { - self.start_background_session_save(); - } - - changed |= self.expire_due_metadata(now); - changed |= self.handle_tab_bar_status_tasks(now); - - if geometry_dirty || resized { - self.pending_agent_resume_deadline = None; - } else { - self.sync_pending_agent_resume_deadline(now); - changed |= self.start_pending_agent_resumes(self.pending_agent_resume_due(now)); - } - changed - } - - /// Clears temporary copied-token highlights, such as after double-click copy. - pub(crate) fn clear_due_selection_highlight(&mut self, now: Instant) -> bool { - if self - .selection_highlight_clear_deadline - .is_none_or(|deadline| now < deadline) - { - return false; - } - - self.selection_highlight_clear_deadline = None; - if self - .state - .selection - .as_ref() - .is_some_and(|selection| !selection.is_in_progress()) - { - self.state.clear_selection(); - return true; - } - false - } - pub(crate) fn sync_agent_metadata_deadline(&mut self) { self.agent_metadata_deadline = self.state.next_agent_metadata_expiry(); } @@ -449,84 +71,6 @@ impl App { self.sync_agent_metadata_deadline(); } - pub(crate) fn tick_selection_autoscroll(&mut self, now: Instant) { - let Some(autoscroll) = self.state.selection_autoscroll.clone() else { - // Self-heal: state cleared but deadline leaked - self.selection_autoscroll_deadline = None; - return; - }; - - // Selection must still be in progress for autoscroll to continue - let Some(pane_id) = self.state.selection.as_ref().map(|s| s.pane_id) else { - self.stop_selection_autoscroll(); - return; - }; - if !self - .state - .selection - .as_ref() - .is_some_and(|s| s.is_dragging()) - { - self.stop_selection_autoscroll(); - return; - } - - // Rect-change detection: if inner_rect changed since drag, stop - let current_rect = self - .state - .pane_info_by_id(pane_id) - .map(|info| info.inner_rect); - if current_rect != Some(autoscroll.inner_rect) { - self.stop_selection_autoscroll(); - return; - } - - // Scrollback boundary detection via ScrollMetrics — fail-closed if unavailable - let Some(metrics) = self - .state - .pane_scroll_metrics(&self.terminal_runtimes, pane_id) - else { - self.stop_selection_autoscroll(); - return; - }; - match autoscroll.direction { - crate::app::state::SelectionAutoscrollDirection::Up => { - let at_top = metrics.offset_from_bottom >= metrics.max_offset_from_bottom; - if at_top { - self.stop_selection_autoscroll(); - return; - } - self.state - .scroll_pane_up(&self.terminal_runtimes, pane_id, 1); - } - crate::app::state::SelectionAutoscrollDirection::Down => { - let at_bottom = metrics.offset_from_bottom == 0; - if at_bottom { - self.stop_selection_autoscroll(); - return; - } - self.state - .scroll_pane_down(&self.terminal_runtimes, pane_id, 1); - } - } - - // Extend selection cursor to last known mouse position - self.state.update_selection_cursor( - &self.terminal_runtimes, - pane_id, - autoscroll.last_mouse_screen_col, - autoscroll.last_mouse_screen_row, - ); - - // Reschedule - self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL); - } - - pub(crate) fn stop_selection_autoscroll(&mut self) { - self.state.stop_selection_autoscroll_state(); - self.selection_autoscroll_deadline = None; - } - pub(crate) fn can_render_now(&self, now: Instant) -> bool { match self.last_render_at { Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL, @@ -551,7 +95,10 @@ impl App { } pub(crate) fn run_auto_update_check(&mut self) { - if !background_update_check_enabled(self.no_session, self.update_version_check_enabled) { + if !background_update_check_enabled( + self.policy.background_updates, + self.update_version_check_enabled, + ) { self.next_auto_update_check = None; return; } @@ -571,7 +118,10 @@ impl App { } pub(crate) fn run_agent_manifest_update_check(&mut self) { - if !background_update_check_enabled(self.no_session, self.update_manifest_check_enabled) { + if !background_update_check_enabled( + self.policy.background_updates, + self.update_manifest_check_enabled, + ) { self.next_agent_manifest_update_check = None; return; } @@ -582,26 +132,12 @@ impl App { std::thread::spawn(move || crate::detect::manifest_update::auto_update(manifest_update_tx)); } - pub(crate) fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> { - self.next_loop_deadline_with_resize_poll(now, needs_render, true, true) - } - pub(crate) fn next_headless_loop_deadline_with_git_refresh( &self, now: Instant, needs_render: bool, include_git_refresh: bool, ) -> Option<Instant> { - self.next_loop_deadline_with_resize_poll(now, needs_render, false, include_git_refresh) - } - - fn next_loop_deadline_with_resize_poll( - &self, - now: Instant, - needs_render: bool, - include_resize_poll: bool, - include_git_refresh: bool, - ) -> Option<Instant> { let render_deadline = if needs_render { self.last_render_at .map(|last_render_at| last_render_at + MIN_RENDER_INTERVAL) @@ -611,12 +147,10 @@ impl App { }; [ - include_resize_poll.then_some(self.next_resize_poll), self.config_diagnostic_deadline, self.toast_deadline, self.state.next_pending_agent_notification_deadline(), self.state.next_managed_agent_deadline(), - self.copy_feedback_deadline, include_git_refresh .then(|| self.git_refresh_deadline()) .flatten(), @@ -625,8 +159,6 @@ impl App { self.agent_metadata_deadline, self.pending_agent_resume_deadline, self.session_save_deadline, - self.selection_autoscroll_deadline, - self.selection_highlight_clear_deadline, self.next_tab_bar_status_deadline(), render_deadline, ] @@ -635,11 +167,13 @@ impl App { .min() } + #[cfg(test)] pub(crate) fn drain_internal_events(&mut self) -> bool { self.drain_internal_events_up_to(super::APP_EVENT_DRAIN_LIMIT) .1 } + #[cfg(test)] pub(crate) fn drain_all_internal_events(&mut self) -> bool { let mut changed = false; loop { @@ -653,6 +187,7 @@ impl App { changed } + #[cfg(test)] fn drain_internal_events_up_to(&mut self, limit: usize) -> (bool, bool) { let mut had_event = false; let mut changed = false; @@ -661,7 +196,7 @@ impl App { break; }; had_event = true; - changed |= self.handle_internal_event_with_prefix_sync(ev); + changed |= self.handle_internal_event_with_render_impact(ev); } (had_event, changed) } @@ -670,7 +205,6 @@ impl App { #[cfg(test)] mod tests { use super::*; - use crate::app::state; use crate::workspace::Workspace; #[test] @@ -697,7 +231,7 @@ mod tests { fn test_app_with_pane() -> (super::super::App, crate::layout::PaneId) { let mut app = super::super::App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, tokio::sync::mpsc::unbounded_channel().1, crate::api::EventHub::default(), @@ -716,264 +250,4 @@ mod tests { }); (app, pane_id) } - - #[test] - fn tick_selection_autoscroll_stops_when_metrics_unavailable() { - // Without a runtime, pane_scroll_metrics returns None. - // Fail-closed: stop autoscroll instead of rescheduling forever. - let (mut app, pane_id) = test_app_with_pane(); - let now = Instant::now(); - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - // Drag to a different cell so it becomes Dragging - sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 5, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - // Should stop because no runtime metrics available - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_when_selection_done() { - let (mut app, pane_id) = test_app_with_pane(); - let now = Instant::now(); - // Create a selection that is already finished (not in progress) - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - // Drag to a different cell so it becomes visible, then finish - sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); - sel.finish(); // now it's Done, not in progress - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_when_selection_cleared() { - let (mut app, _pane_id) = test_app_with_pane(); - let now = Instant::now(); - app.state.selection = None; - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[test] - fn tick_selection_autoscroll_stops_when_selection_anchored() { - // Anchored (click, no drag) should not keep the timer running. - let (mut app, pane_id) = test_app_with_pane(); - let now = Instant::now(); - app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None)); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 0, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - /// Creates an app with a real TerminalRuntime (no PTY) so scroll_metrics - /// returns meaningful data. Uses test_with_scrollback_bytes. - fn test_app_with_runtime( - cols: u16, - rows: u16, - bytes: &[u8], - ) -> (super::super::App, crate::layout::PaneId) { - let mut app = super::super::App::new( - &crate::config::Config::default(), - true, - None, - tokio::sync::mpsc::unbounded_channel().1, - crate::api::EventHub::default(), - ); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let runtime = - crate::terminal::TerminalRuntime::test_with_scrollback_bytes(cols, rows, 0, bytes); - ws.tabs[0].runtimes.insert(pane_id, runtime); - app.state.workspaces.push(ws); - app.state.active = Some(0); - app.state.view.pane_infos.push(crate::layout::PaneInfo { - id: pane_id, - rect: ratatui::layout::Rect::new(0, 0, cols, rows), - inner_rect: ratatui::layout::Rect::new(0, 0, cols, rows), - scrollbar_rect: None, - borders: ratatui::widgets::Borders::NONE, - is_focused: true, - }); - (app, pane_id) - } - - #[tokio::test] - async fn tick_selection_autoscroll_stops_at_scrollback_top() { - // Create a runtime with no scrollback content — we're already at - // the top (offset_from_bottom == max_offset_from_bottom). - let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]); - let now = Instant::now(); - let mut sel = crate::selection::Selection::anchor(pane_id, 5, 5, None); - sel.drag(0, 0, ratatui::layout::Rect::new(0, 0, 80, 24), None); - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Up, - last_mouse_screen_col: 0, - last_mouse_screen_row: 0, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - // At scrollback top, can't scroll further up — should stop - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[tokio::test] - async fn tick_selection_autoscroll_stops_at_scrollback_bottom() { - // Create a runtime with no scrollback content — we're already at - // the bottom (offset_from_bottom == 0). - let (mut app, pane_id) = test_app_with_runtime(80, 24, &[]); - let now = Instant::now(); - let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None); - sel.drag(5, 5, ratatui::layout::Rect::new(0, 0, 80, 24), None); - app.state.selection = Some(sel); - app.state.selection_autoscroll = Some(state::SelectionAutoscroll { - direction: state::SelectionAutoscrollDirection::Down, - last_mouse_screen_col: 5, - last_mouse_screen_row: 23, - inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24), - }); - app.selection_autoscroll_deadline = Some(now); - app.tick_selection_autoscroll(now); - // At scrollback bottom, can't scroll further down — should stop - assert!(app.state.selection_autoscroll.is_none()); - assert!(app.selection_autoscroll_deadline.is_none()); - } - - #[tokio::test] - async fn passive_mouse_motion_does_not_request_monolithic_render() { - let (mut app, _) = test_app_with_pane(); - app.state.mode = crate::app::Mode::Terminal; - let motion = || { - crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::Moved, - column: 10, - row: 5, - modifiers: crossterm::event::KeyModifiers::empty(), - }) - }; - - assert!(!app.handle_raw_input_event(motion()).await); - app.state.mode = crate::app::Mode::GlobalMenu; - assert!(app.handle_raw_input_event(motion()).await); - } - - #[tokio::test] - async fn raw_input_batch_does_not_start_pending_agent_resume_before_render() { - let (mut app, pane_id) = test_app_with_pane(); - app.state.ensure_test_terminals(); - let terminal_id = app.state.workspaces[0] - .terminal_id(pane_id) - .cloned() - .expect("test pane should have a terminal"); - app.state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - - assert!( - app.handle_raw_input_batch(crate::raw_input::RawInputEvent::HostDefaultColor { - kind: crate::terminal_theme::DefaultColorKind::Foreground, - color: crate::terminal_theme::RgbColor { - r: 220, - g: 220, - b: 220, - }, - }) - .await - ); - assert!( - app.terminal_runtimes.get(&terminal_id).is_none(), - "raw input can mutate active geometry; pending resumes must wait for render to refresh pane_infos" - ); - assert!(app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_some()); - } - - #[tokio::test] - async fn scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() { - let (mut app, pane_id) = test_app_with_pane(); - app.state.ensure_test_terminals(); - app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { - foreground: Some(crate::terminal_theme::RgbColor { - r: 220, - g: 220, - b: 220, - }), - background: Some(crate::terminal_theme::RgbColor { - r: 20, - g: 20, - b: 20, - }), - ..Default::default() - }; - let terminal_id = app.state.workspaces[0] - .terminal_id(pane_id) - .cloned() - .expect("test pane should have a terminal"); - app.state - .terminals - .get_mut(&terminal_id) - .expect("test terminal should exist") - .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], - dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), - }); - app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); - - assert!(!app.handle_scheduled_tasks(Instant::now(), true)); - assert!(app.terminal_runtimes.get(&terminal_id).is_none()); - assert!(app - .state - .terminals - .get(&terminal_id) - .expect("test terminal should still exist") - .pending_agent_resume_plan - .is_some()); - assert!(app.pending_agent_resume_deadline.is_none()); - } } diff --git a/src/app/runtime_mutations.rs b/src/app/runtime_mutations.rs deleted file mode 100644 index d3d767e7..00000000 --- a/src/app/runtime_mutations.rs +++ /dev/null @@ -1,198 +0,0 @@ -use crate::api::schema::{ - EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneInputSetParams, - PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, - PaneZoomParams, TabCreateParams, TabMoveParams, TabRenameParams, TabTarget, - WorkspaceCloseParams, WorkspaceCreateParams, WorkspaceMoveBlockParams, WorkspaceMoveParams, - WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, WorktreeOpenParams, - WorktreeRemoveParams, -}; - -use super::App; - -impl App { - pub(crate) fn dispatch_runtime_mutation(&mut self, id: &'static str, method: Method) -> String { - self.dispatch_api_request(id, method) - } - - pub(crate) fn dispatch_deferred_runtime_mutation( - &mut self, - id: &'static str, - method: Method, - ) -> Option<String> { - self.dispatch_deferred_api_request(id, method) - } - - pub(crate) fn runtime_workspace_focus( - &mut self, - id: &'static str, - workspace_id: String, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceFocus(WorkspaceTarget { workspace_id })) - } - - pub(crate) fn runtime_workspace_create( - &mut self, - id: &'static str, - params: WorkspaceCreateParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceCreate(params)) - } - - pub(crate) fn runtime_workspace_rename( - &mut self, - id: &'static str, - params: WorkspaceRenameParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceRename(params)) - } - - pub(crate) fn runtime_workspace_move( - &mut self, - id: &'static str, - params: WorkspaceMoveParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceMove(params)) - } - - pub(crate) fn runtime_workspace_move_block( - &mut self, - id: &'static str, - params: WorkspaceMoveBlockParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorkspaceMoveBlock(params)) - } - - pub(crate) fn runtime_workspace_close_group( - &mut self, - id: &'static str, - workspace_id: String, - ) -> String { - self.dispatch_runtime_mutation( - id, - Method::WorkspaceClose(WorkspaceCloseParams { - workspace_id, - close_group: true, - }), - ) - } - - pub(crate) fn runtime_tab_create( - &mut self, - id: &'static str, - params: TabCreateParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::TabCreate(params)) - } - - pub(crate) fn runtime_tab_focus(&mut self, id: &'static str, tab_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::TabFocus(TabTarget { tab_id })) - } - - pub(crate) fn runtime_tab_rename( - &mut self, - id: &'static str, - params: TabRenameParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::TabRename(params)) - } - - pub(crate) fn runtime_tab_move(&mut self, id: &'static str, params: TabMoveParams) -> String { - self.dispatch_runtime_mutation(id, Method::TabMove(params)) - } - - pub(crate) fn runtime_tab_close(&mut self, id: &'static str, tab_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::TabClose(TabTarget { tab_id })) - } - - pub(crate) fn runtime_server_reload_config(&mut self, id: &'static str) -> String { - self.dispatch_runtime_mutation(id, Method::ServerReloadConfig(EmptyParams::default())) - } - - pub(crate) fn runtime_pane_focus(&mut self, id: &'static str, pane_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::PaneFocus(PaneTarget { pane_id })) - } - - pub(crate) fn runtime_pane_close(&mut self, id: &'static str, pane_id: String) -> String { - self.dispatch_runtime_mutation(id, Method::PaneClose(PaneTarget { pane_id })) - } - - pub(crate) fn runtime_pane_rename( - &mut self, - id: &'static str, - params: PaneRenameParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneRename(params)) - } - - pub(crate) fn runtime_pane_input_set( - &mut self, - id: &'static str, - params: PaneInputSetParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneInputSet(params)) - } - - pub(crate) fn runtime_pane_focus_direction( - &mut self, - id: &'static str, - params: PaneFocusDirectionParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneFocusDirection(params)) - } - - pub(crate) fn runtime_pane_resize( - &mut self, - id: &'static str, - params: PaneResizeParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneResize(params)) - } - - pub(crate) fn runtime_pane_swap(&mut self, id: &'static str, params: PaneSwapParams) -> String { - self.dispatch_runtime_mutation(id, Method::PaneSwap(params)) - } - - pub(crate) fn runtime_pane_split( - &mut self, - id: &'static str, - params: PaneSplitParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::PaneSplit(params)) - } - - pub(crate) fn runtime_pane_zoom(&mut self, id: &'static str, params: PaneZoomParams) -> String { - self.dispatch_runtime_mutation(id, Method::PaneZoom(params)) - } - - pub(crate) fn runtime_layout_set_split_ratio( - &mut self, - id: &'static str, - params: LayoutSetSplitRatioParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::LayoutSetSplitRatio(params)) - } - - pub(crate) fn runtime_worktree_create_deferred( - &mut self, - id: &'static str, - params: WorktreeCreateParams, - ) -> Option<String> { - self.dispatch_deferred_runtime_mutation(id, Method::WorktreeCreate(params)) - } - - pub(crate) fn runtime_worktree_open( - &mut self, - id: &'static str, - params: WorktreeOpenParams, - ) -> String { - self.dispatch_runtime_mutation(id, Method::WorktreeOpen(params)) - } - - pub(crate) fn runtime_worktree_remove_deferred( - &mut self, - id: &'static str, - params: WorktreeRemoveParams, - ) -> Option<String> { - self.dispatch_deferred_runtime_mutation(id, Method::WorktreeRemove(params)) - } -} diff --git a/src/app/session.rs b/src/app/session.rs index 60d49d52..07bc80a9 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -12,7 +12,7 @@ enum SessionSaveJob { impl App { pub(super) fn schedule_session_save(&mut self) { - if !self.no_session { + if self.policy.persist_session { self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE); } } @@ -46,9 +46,6 @@ impl App { &self.terminal_runtimes, self.state.active, self.state.selected, - self.state.sidebar_width, - self.state.sidebar_section_split, - self.state.collapsed_space_keys.clone(), ); let history = self.persist_pane_history.then(|| { crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes) @@ -58,7 +55,7 @@ impl App { } pub(crate) fn start_background_session_save(&mut self) { - if self.no_session { + if !self.policy.persist_session { self.session_save_deadline = None; return; } @@ -88,7 +85,7 @@ impl App { let _ = thread.join(); } - if self.no_session { + if !self.policy.persist_session { self.session_save_deadline = None; return; } diff --git a/src/app/state.rs b/src/app/state.rs index 782b8ae5..0ce9fb6d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,13 +1,10 @@ -use crate::config::{ - Keybinds, NewTerminalCwdConfig, SoundConfig, TabBarPositionConfig, ToastConfig, ToastDelivery, -}; +use crate::config::{Keybinds, NewTerminalCwdConfig, SoundConfig, ToastConfig}; use crossterm::event::{KeyCode, KeyModifiers}; -use ratatui::layout::{Direction, Rect}; +use ratatui::layout::Rect; use ratatui::style::Color; use crate::detect::AgentState; -use crate::layout::{PaneId, PaneInfo, SplitBorder}; -use crate::selection::Selection; +use crate::layout::{PaneId, PaneInfo}; pub(crate) type InstalledPluginRegistry = std::collections::HashMap<String, crate::api::schema::InstalledPluginInfo>; @@ -25,36 +22,6 @@ pub(crate) struct PopupPaneState { pub height: Option<crate::popup_size::PopupSize>, } -// --------------------------------------------------------------------------- -// Selection autoscroll types -// --------------------------------------------------------------------------- - -/// Direction of automatic scrolling during text selection drag. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum SelectionAutoscrollDirection { - Up, - Down, -} - -/// State for automatic scrolling during text selection drag. -/// -/// When the cursor hovers in the 1-row hot zone at the top or bottom edge -/// of a pane (or outside the pane), this struct captures the direction and -/// last known mouse position so a recurring 30ms tick can continue scrolling -/// and extending the selection even when the mouse is not moving. -#[derive(Clone, Debug)] -pub(crate) struct SelectionAutoscroll { - pub direction: SelectionAutoscrollDirection, - pub last_mouse_screen_col: u16, - pub last_mouse_screen_row: u16, - pub inner_rect: Rect, -} - -#[derive(Clone)] -pub(crate) struct RightClickPassthroughGesture { - pub pane_info: PaneInfo, - pub modifiers: KeyModifiers, -} use crate::terminal_theme::{HostAppearance, TerminalTheme}; use crate::workspace::Workspace; @@ -65,7 +32,6 @@ use crate::workspace::Workspace; /// All colors used by the UI. Derived from a base accent color for now, /// but structured so a full theme system can replace it later. #[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] // all fields defined for theming — some used later pub struct Palette { /// Primary accent (highlight, active borders). pub accent: Color, @@ -709,364 +675,16 @@ impl Palette { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct WorkspaceCardArea { - pub ws_idx: usize, - pub rect: Rect, - pub indented: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeCreateState { - pub source_workspace_id: String, - pub source_checkout_path: std::path::PathBuf, - pub source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>, - pub source_repo_root: std::path::PathBuf, - pub repo_key: String, - pub repo_name: String, - pub branch: String, - pub checkout_path: std::path::PathBuf, - pub error: Option<String>, - pub creating: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeRemoveState { - pub workspace_id: String, - pub repo_root: std::path::PathBuf, - pub path: std::path::PathBuf, - pub error: Option<String>, - pub removing: bool, - pub force_confirmation: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeOpenEntry { - pub path: std::path::PathBuf, - pub branch: Option<String>, - pub is_linked_worktree: bool, - pub already_open_ws_idx: Option<usize>, -} - -impl WorktreeOpenEntry { - pub(crate) fn display_name(&self) -> String { - self.branch.clone().unwrap_or_else(|| { - self.path - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_owned) - .unwrap_or_else(|| self.path.display().to_string()) - }) - } - - pub(crate) fn status_label(&self) -> &'static str { - if self.already_open_ws_idx.is_some() { - "open" - } else if self.branch.is_some() { - "" - } else if self.is_linked_worktree { - "detached" - } else { - "root" - } - } - - fn search_text(&self) -> String { - format!( - "{} {} {} {}", - self.display_name(), - self.path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(), - self.path.display(), - self.status_label() - ) - .to_lowercase() - } - - fn matches_query(&self, query: &str) -> bool { - text_matches_query(query, &self.search_text()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorktreeOpenState { - pub source_workspace_id: String, - pub source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>, - pub source_checkout_path: std::path::PathBuf, - pub source_repo_root: std::path::PathBuf, - pub repo_key: String, - pub repo_name: String, - pub entries: Vec<WorktreeOpenEntry>, - pub selected: usize, - pub query: String, - pub search_focused: bool, - pub error: Option<String>, -} - -impl WorktreeOpenState { - pub(crate) fn filtered_indices(&self) -> Vec<usize> { - let query = self.query.trim(); - self.entries - .iter() - .enumerate() - .filter_map(|(idx, entry)| { - (query.is_empty() || entry.matches_query(query)).then_some(idx) - }) - .collect() - } - - pub(crate) fn selected_entry_index(&self) -> Option<usize> { - let indices = self.filtered_indices(); - if indices.contains(&self.selected) { - Some(self.selected) - } else { - indices.first().copied() - } - } - - pub(crate) fn normalize_selection(&mut self) { - if let Some(selected) = self.selected_entry_index() { - self.selected = selected; - } - } - - pub(crate) fn select_previous_filtered(&mut self) { - let indices = self.filtered_indices(); - let Some(current) = self.selected_entry_index() else { - return; - }; - let pos = indices.iter().position(|idx| *idx == current).unwrap_or(0); - self.selected = indices[pos.saturating_sub(1)]; - } - - pub(crate) fn select_next_filtered(&mut self) { - let indices = self.filtered_indices(); - let Some(current) = self.selected_entry_index() else { - return; - }; - let pos = indices.iter().position(|idx| *idx == current).unwrap_or(0); - self.selected = indices[(pos + 1).min(indices.len().saturating_sub(1))]; - } -} - -pub(crate) fn text_matches_query(query: &str, text: &str) -> bool { - let haystack = text.to_lowercase(); - query - .to_lowercase() - .split_whitespace() - .all(|needle| haystack.contains(needle)) -} - -/// Computed view geometry — derived from AppState + terminal size. -/// Updated before each render, consumed by render and mouse handling. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ViewLayout { - Desktop, - Mobile, -} - +/// Geometry for the server-rendered active-tab pane surface. pub struct ViewState { - pub layout: ViewLayout, - pub sidebar_rect: Rect, - pub workspace_card_areas: Vec<WorkspaceCardArea>, - pub tab_bar_rect: Rect, - pub tab_hit_areas: Vec<Rect>, - pub tab_scroll_left_hit_area: Rect, - pub tab_scroll_right_hit_area: Rect, - pub new_tab_hit_area: Rect, pub terminal_area: Rect, - pub mobile_header_rect: Rect, - pub mobile_menu_hit_area: Rect, - pub toast_hit_area: Rect, pub pane_infos: Vec<PaneInfo>, - pub split_borders: Vec<SplitBorder>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { - Onboarding, - ReleaseNotes, - ProductAnnouncement, Navigate, - Prefix, - Copy, Terminal, - RenameWorkspace, - RenameTab, - RenamePane, - NewLinkedWorktree, - OpenExistingWorktree, - ConfirmRemoveWorktree, - Resize, - ConfirmClose, - ContextMenu, - Settings, - GlobalMenu, - KeybindHelp, - Navigator, -} - -impl Mode { - pub(crate) fn mouse_motion_changes_view(self) -> bool { - matches!(self, Self::GlobalMenu | Self::ContextMenu | Self::Navigator) - } - - /// Whether keys in this mode are commands/navigation (an ASCII input source is wanted) rather - /// than free text. This is an explicit **allowlist** of the prefix command/navigation realm: - /// any mode NOT listed defaults to leaving the user's IME alone (the safe default), so adding a - /// new text-entry or overlay mode can never silently force ASCII. Used by - /// `sync_prefix_input_source` (gated by `switch_ascii_input_source_in_prefix`) so multi-level - /// prefix commands keep ASCII until they return to the terminal. - /// - /// Known limitation: the search boxes in `Navigator` and `KeybindHelp` are also held on ASCII, - /// since this `Mode`-level predicate can't see `search_focused` (non-ASCII filtering there - /// would need a runtime check). - pub(crate) fn wants_ascii_input(self) -> bool { - matches!( - self, - Mode::Prefix - | Mode::Navigate - | Mode::Navigator - | Mode::Copy - | Mode::Resize - | Mode::ConfirmClose - | Mode::ConfirmRemoveWorktree - | Mode::ContextMenu - | Mode::GlobalMenu - | Mode::KeybindHelp - ) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum NavigatorTarget { - Workspace { - ws_idx: usize, - }, - Tab { - ws_idx: usize, - tab_idx: usize, - }, - Pane { - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct NavigatorRow { - pub target: NavigatorTarget, - pub depth: u8, - pub label: String, - pub meta: String, - pub status: AgentState, - pub seen: bool, - pub is_current: bool, - pub is_workspace: bool, - pub is_tab: bool, - pub expanded: bool, - pub search_text: String, - /// Whether this row itself matched the active query/state filter, as - /// opposed to being included as ancestor context or cascaded subtree of a - /// matching workspace or tab. Always true when no filter is active. - pub matched: bool, -} - -/// One rendered line in the navigator body. Spacer lines separate workspace -/// groups visually and are not selectable. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NavigatorDisplayLine { - Spacer, - Row(usize), -} - -pub(crate) fn navigator_display_lines(rows: &[NavigatorRow]) -> Vec<NavigatorDisplayLine> { - let mut lines = Vec::with_capacity(rows.len().saturating_mul(2)); - for (idx, row) in rows.iter().enumerate() { - if row.is_workspace && !lines.is_empty() { - lines.push(NavigatorDisplayLine::Spacer); - } - lines.push(NavigatorDisplayLine::Row(idx)); - } - lines -} - -pub(crate) fn navigator_display_index_of_row( - lines: &[NavigatorDisplayLine], - row_idx: usize, -) -> Option<usize> { - lines - .iter() - .position(|line| *line == NavigatorDisplayLine::Row(row_idx)) -} - -pub(crate) fn navigator_first_row_at_or_after( - lines: &[NavigatorDisplayLine], - line_idx: usize, -) -> Option<usize> { - lines.get(line_idx..)?.iter().find_map(|line| match line { - NavigatorDisplayLine::Row(idx) => Some(*idx), - NavigatorDisplayLine::Spacer => None, - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NavigatorStateFilter { - Blocked, - Working, - Idle, - Done, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct NavigatorState { - pub query: String, - pub selected: usize, - pub scroll: usize, - pub search_focused: bool, - pub state_filter: Option<NavigatorStateFilter>, - pub expanded_workspaces: std::collections::HashSet<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CopyModeState { - pub pane_id: PaneId, - pub cursor_row: u16, - pub cursor_col: u16, - pub entry_offset_from_bottom: usize, - pub selection: Option<CopyModeSelection>, - pub search: CopyModeSearchState, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CopyModeSelection { - Character, - Linewise { anchor_row: u32 }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CopyModeSearchDirection { - Forward, - Backward, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct CopyModeSearchPrompt { - pub direction: CopyModeSearchDirection, - pub query: String, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct CopyModeSearchState { - pub prompt: Option<CopyModeSearchPrompt>, - pub query: String, - pub direction: Option<CopyModeSearchDirection>, - pub matches: Vec<crate::pane::TerminalTextMatch>, - pub current: Option<usize>, - pub geometry: Option<(u16, u16)>, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -1076,98 +694,6 @@ pub enum AgentPanelSort { Priority, } -// --------------------------------------------------------------------------- -// Settings UI state -// --------------------------------------------------------------------------- - -/// Which section of the settings panel is focused. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SettingsSection { - Theme, - Indicators, - Sound, - Toast, - PaneLabels, - Integrations, -} - -impl SettingsSection { - pub const ALL: &[Self] = &[ - Self::Theme, - Self::Indicators, - Self::Sound, - Self::Toast, - Self::PaneLabels, - Self::Integrations, - ]; - - pub fn label(self) -> &'static str { - match self { - Self::Theme => "theme", - Self::Indicators => "indicators", - Self::Sound => "sound", - Self::Toast => "toasts", - Self::PaneLabels => "pane labels", - Self::Integrations => "integrations", - } - } -} - -/// All built-in theme names in display order. -pub const THEME_NAMES: &[&str] = crate::config::THEME_NAMES; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MenuListState { - pub highlighted: usize, -} - -impl MenuListState { - pub fn new(highlighted: usize) -> Self { - Self { highlighted } - } - - pub fn move_prev(&mut self) { - self.highlighted = self.highlighted.saturating_sub(1); - } - - pub fn move_next(&mut self, item_count: usize) { - if item_count > 0 { - self.highlighted = (self.highlighted + 1).min(item_count - 1); - } - } - - pub fn hover(&mut self, idx: Option<usize>) { - if let Some(idx) = idx { - self.highlighted = idx; - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SelectionListState { - pub selected: usize, -} - -impl SelectionListState { - pub fn new(selected: usize) -> Self { - Self { selected } - } - - pub fn move_prev(&mut self) { - self.selected = self.selected.saturating_sub(1); - } - - pub fn move_next(&mut self, item_count: usize) { - if item_count > 0 { - self.selected = (self.selected + 1).min(item_count - 1); - } - } - - pub fn select(&mut self, idx: usize) { - self.selected = idx; - } -} - #[derive(Debug, Clone)] pub struct ThemeRuntimeConfig { pub manual_name: String, @@ -1178,167 +704,6 @@ pub struct ThemeRuntimeConfig { pub legacy_accent: Option<String>, } -pub struct SettingsState { - /// Which section tab is active. - pub section: SettingsSection, - /// Selected item index within the current section. - pub list: SelectionListState, - /// The palette before opening settings (for cancel/restore). - pub original_palette: Option<Palette>, - /// The theme name before opening settings. - pub original_theme: Option<String>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum WorkspaceDropTarget { - Before(usize), - End, -} - -pub(crate) enum DragTarget { - WorkspaceReorder { - source_id: crate::app::InputSourceId, - source_ws_idx: usize, - drop_target: Option<WorkspaceDropTarget>, - }, - TabReorder { - source_id: crate::app::InputSourceId, - ws_idx: usize, - source_tab_idx: usize, - insert_idx: Option<usize>, - }, - WorkspaceListScrollbar { - grab_row_offset: u16, - }, - AgentPanelScrollbar { - grab_row_offset: u16, - }, - PaneSplit { - path: Vec<bool>, - direction: Direction, - area: Rect, - grab_offset: u16, - }, - PaneScrollbar { - pane_id: crate::layout::PaneId, - grab_row_offset: u16, - }, - ReleaseNotesScrollbar { - grab_row_offset: u16, - }, - ProductAnnouncementScrollbar { - grab_row_offset: u16, - }, - KeybindHelpScrollbar { - grab_row_offset: u16, - }, - SidebarDivider, - SidebarSectionDivider, -} - -/// Active mouse drag on a split border or sidebar divider. -pub(crate) struct DragState { - pub target: DragTarget, -} - -pub(crate) struct WorkspacePressState { - pub ws_idx: usize, - pub start_col: u16, - pub start_row: u16, -} - -pub(crate) struct TabPressState { - pub ws_idx: usize, - pub tab_idx: usize, - pub start_col: u16, - pub start_row: u16, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ContextMenuKind { - Workspace { - ws_idx: usize, - }, - GitWorkspace { - ws_idx: usize, - is_linked_worktree: bool, - has_worktree_children: bool, - collapsed: bool, - }, - Tab { - ws_idx: usize, - tab_idx: usize, - }, - Pane { - ws_idx: usize, - tab_idx: usize, - pane_id: PaneId, - source_pane_id: Option<PaneId>, - has_manual_label: bool, - right_click_passthrough: bool, - }, -} - -/// Right-click context menu state. -pub struct ContextMenuState { - pub kind: ContextMenuKind, - pub x: u16, - pub y: u16, - pub list: MenuListState, -} - -impl ContextMenuState { - pub fn items(&self) -> Vec<&'static str> { - match self.kind { - ContextMenuKind::Workspace { .. } => vec!["Rename", "Close"], - ContextMenuKind::GitWorkspace { - is_linked_worktree: false, - has_worktree_children: false, - .. - } => vec!["Rename", "Close", "New worktree", "Open worktree..."], - ContextMenuKind::GitWorkspace { - is_linked_worktree: true, - .. - } => vec!["Rename", "Close", "Delete worktree checkout..."], - ContextMenuKind::GitWorkspace { - is_linked_worktree: false, - has_worktree_children: true, - collapsed, - .. - } => vec![ - "Rename", - "Close group", - "New worktree", - "Open worktree...", - if collapsed { "Expand" } else { "Collapse" }, - ], - ContextMenuKind::Tab { .. } => vec!["New tab", "Rename", "Close"], - ContextMenuKind::Pane { - source_pane_id, - has_manual_label, - right_click_passthrough, - .. - } => { - let mut items = vec!["Rename pane"]; - if has_manual_label { - items.push("Clear pane name"); - } - if source_pane_id.is_some() { - items.push("Swap with focused pane"); - } - items.extend(["Split right", "Split down", "Zoom"]); - items.push(if right_click_passthrough { - "Use Herdr right-click menu" - } else { - "Send right-clicks to pane" - }); - items.push("Close pane"); - items - } - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToastKind { NeedsAttention, @@ -1389,6 +754,7 @@ pub struct CopyFeedback { pub message: String, } +#[derive(Debug)] pub struct ReleaseNotesState { pub version: String, pub body: String, @@ -1396,6 +762,7 @@ pub struct ReleaseNotesState { pub preview: bool, } +#[derive(Debug)] pub struct ProductAnnouncementState { pub version: String, pub id: String, @@ -1405,20 +772,6 @@ pub struct ProductAnnouncementState { pub preview: bool, } -#[derive(Default)] -pub struct KeybindHelpState { - pub scroll: u16, - pub query: String, - pub search_focused: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SidebarWidthSource { - ConfigDefault, - Persisted, - Manual, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PaneFocusTarget { pub workspace_id: String, @@ -1445,61 +798,16 @@ pub struct AppState { pub(crate) previous_pane_focus: Option<PaneFocusTarget>, pub selected: usize, pub mode: Mode, - /// Stable workspace identity captured when the close confirmation opens. - pub(crate) confirm_close_workspace_id: Option<String>, pub should_quit: bool, - /// In monolithic --no-session mode, detach exits the app because there is no server to detach from. - pub detach_exits: bool, - /// Set when the current client should detach from the persistent session. - /// The server's event loop checks this and handles client detach. - pub detach_requested: bool, - pub request_new_workspace: bool, - pub request_new_tab: bool, - pub request_new_linked_worktree: Option<usize>, - pub request_open_existing_worktree: Option<usize>, - pub request_new_workspace_cwd: Option<std::path::PathBuf>, - pub request_remove_linked_worktree: Option<usize>, - pub request_submit_worktree_create: bool, - pub request_submit_worktree_open: bool, - pub request_submit_worktree_remove: bool, - pub request_reload_config: bool, /// Set when the headless server should ask attached clients to reload /// their client-local sound config from disk. pub request_client_config_reload: bool, - /// Set when UI interaction requested a clipboard write that must be - /// handled by the outer App/event loop instead of directly from AppState. - pub request_clipboard_write: Option<Vec<u8>>, - pub creating_new_tab: bool, - pub requested_new_tab_name: Option<String>, - pub pending_workspace_create_cwd: Option<std::path::PathBuf>, - pub rename_pane_target: Option<PaneId>, - pub worktree_create: Option<WorktreeCreateState>, - pub worktree_open: Option<WorktreeOpenState>, - pub worktree_remove: Option<WorktreeRemoveState>, pub worktree_directory: std::path::PathBuf, - pub collapsed_space_keys: std::collections::HashSet<String>, - pub request_complete_onboarding: bool, - pub name_input: String, - pub name_input_replace_on_type: bool, - pub release_notes: Option<ReleaseNotesState>, + /// Latest endpoint-owned release notes, cached outside render paths. + pub latest_release_notes: Option<crate::release_notes::ReleaseNotes>, pub product_announcement: Option<ProductAnnouncementState>, - pub keybind_help: KeybindHelpState, - pub navigator: NavigatorState, - pub copy_mode: Option<CopyModeState>, - pub workspace_scroll: usize, - pub agent_panel_scroll: usize, - pub tab_scroll: usize, - pub tab_scroll_follow_active: bool, - pub mobile_switcher_scroll: usize, - // View geometry (computed before render, consumed by render + mouse) + // Geometry of the most recently computed server pane surface. pub view: ViewState, - pub(crate) drag: Option<DragState>, - pub(crate) workspace_presses: - std::collections::HashMap<crate::app::InputSourceId, WorkspacePressState>, - pub(crate) tab_presses: std::collections::HashMap<crate::app::InputSourceId, TabPressState>, - pub selection: Option<Selection>, - pub selection_autoscroll: Option<SelectionAutoscroll>, - pub context_menu: Option<ContextMenuState>, // Notifications pub update_available: Option<String>, pub update_install_command: String, @@ -1508,7 +816,6 @@ pub struct AppState { pub config_diagnostic: Option<String>, pub toast: Option<ToastNotification>, pub pending_agent_notifications: std::collections::HashMap<PaneId, PendingAgentNotification>, - pub copy_feedback: Option<CopyFeedback>, /// Last reported focus state for the outer terminal hosting herdr. /// None means unsupported or not yet reported, which preserves active-pane suppression. pub outer_terminal_focus: Option<bool>, @@ -1517,45 +824,20 @@ pub struct AppState { pub prefix_mods: KeyModifiers, /// Virtual terminal size (columns, rows) used when no client is attached. pub(crate) headless_size: (u16, u16), - pub default_sidebar_width: u16, - pub sidebar_width: u16, - pub sidebar_min_width: u16, - pub sidebar_max_width: u16, - pub mobile_width_threshold: u16, - pub sidebar_width_source: SidebarWidthSource, - pub sidebar_width_auto: bool, - pub sidebar_collapsed: bool, - pub sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig, - /// Ratio of sidebar height allocated to the workspaces section. - pub sidebar_section_split: f32, pub agent_panel_sort: AgentPanelSort, - pub status_indicators: crate::config::StatusIndicatorStyle, /// Transient session-wide projection override for the built-in Agents view. pub agent_view_override: Option<crate::api::schema::AgentViewSetParams>, pub sidebar_agents: crate::config::AgentsSidebarConfig, pub sidebar_spaces: crate::config::SpacesSidebarConfig, pub next_agent_state_change_seq: u64, - /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only - /// captures mouse while the focused pane app requests mouse reporting. - pub mouse_capture: bool, - pub copy_on_select: bool, - pub right_click_passthrough_modifiers: Option<KeyModifiers>, - pub right_click_passthrough: Option<RightClickPassthroughGesture>, - pub redraw_on_focus_gained: bool, - pub mouse_scroll_lines: usize, pub confirm_close: bool, - pub prompt_new_tab_name: bool, - pub prompt_new_workspace_name: bool, pub pane_borders: bool, pub pane_outer_borders: bool, pub pane_scrollbars: bool, pub pane_gaps: bool, pub show_agent_labels_on_pane_borders: bool, - pub hide_tab_bar_when_single_tab: bool, - pub tab_bar_position: TabBarPositionConfig, pub tab_bar_right: Vec<TabBarStatusSegment>, pub tab_bar_right_separator: String, - pub pane_history_persistence: bool, /// Expose the focused pane's cursor anchor to the outer terminal even when /// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`. pub reveal_hidden_cursor_for_cjk_ime: bool, @@ -1565,20 +847,12 @@ pub struct AppState { pub cjk_ime_agents: Vec<crate::detect::Agent>, /// DECSCUSR shape parameter (1–6) for the IME anchor cursor. pub cjk_ime_cursor_shape: u8, - /// While prefix mode is active, switch the macOS host input source to an - /// ASCII-capable layout so prefix commands register as ASCII even when a - /// CJK IME is active. macOS only; a no-op elsewhere. See - /// `[experimental] switch_ascii_input_source_in_prefix`. - pub switch_ascii_input_source_in_prefix: bool, pub kitty_graphics_enabled: bool, pub default_shell: String, pub shell_mode: crate::config::ShellModeConfig, pub new_terminal_cwd: NewTerminalCwdConfig, pub pane_scrollback_limit_bytes: usize, - #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth - pub accent: Color, pub sound: SoundConfig, - pub local_sound_playback: bool, pub toast_config: ToastConfig, pub keybinds: Keybinds, /// UI color palette — all sidebar/UI colors centralized for theming. @@ -1591,16 +865,11 @@ pub struct AppState { pub host_terminal_appearance: Option<HostAppearance>, /// True when the foreground host explicitly reported appearance via Mode 2031. pub host_terminal_appearance_explicit: bool, - /// Settings panel state. - pub settings: SettingsState, - /// Cached integration recommendations for onboarding/settings UI. + /// Cached integration recommendations and detection manifest summaries. pub integration_recommendations: Vec<crate::integration::IntegrationRecommendation>, - /// Cached detection manifest source/version summaries for runtime/API status. pub agent_manifest_summaries: Vec<crate::detect::manifest::AgentManifestSummary>, /// Cached remote detection manifest update diagnostics for runtime/API status. pub agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus, - /// Result messages from the latest integration install action. - pub integration_install_messages: Vec<String>, /// Installed or linked plugins known to this running Herdr instance. pub(crate) installed_plugins: InstalledPluginRegistry, /// Pane ids opened through the plugin pane API. @@ -1611,14 +880,10 @@ pub struct AppState { pub(crate) plugin_command_logs: Vec<crate::api::schema::PluginCommandLogInfo>, pub(crate) next_plugin_command_log_id: u64, pub(crate) plugin_commands_in_flight: usize, - /// Highlight state for the bottom-right global launcher menu. - pub global_menu: MenuListState, /// Resolved host terminal default colors for theming embedded panes. pub host_terminal_theme: TerminalTheme, /// Last known foreground host terminal cell size in pixels. pub(crate) host_cell_size: crate::kitty_graphics::HostCellSize, - /// Exact pixel provenance only while one confirmed SGR report is dispatched. - pub(crate) host_mouse_pixels: Option<crate::input::mouse::HostPixels>, /// Set when a persisted session snapshot would change. pub session_dirty: bool, /// Terminal runtimes that should be shut down by the app/runtime layer @@ -1635,18 +900,6 @@ impl AppState { self.pane_id_aliases.remove(&pane_id.raw()); } - pub fn sound_enabled(&self) -> bool { - self.sound.enabled - } - - pub fn toast_delivery(&self) -> ToastDelivery { - self.toast_config.delivery - } - - pub fn agent_border_labels_enabled(&self) -> bool { - self.show_agent_labels_on_pane_borders - } - pub(crate) fn pane_exposes_host_cursor( &self, _ws_idx: usize, @@ -1655,27 +908,17 @@ impl AppState { true } - pub(crate) fn integration_updates_available(&self) -> bool { - self.integration_recommendations - .iter() - .any(|item| item.state == crate::integration::IntegrationStatusKind::Outdated) - } - pub(crate) fn refresh_agent_manifest_summaries(&mut self) { self.agent_manifest_summaries = crate::detect::manifest::manifest_summaries(); } - pub(crate) fn global_menu_attention_badge_visible(&self) -> bool { - self.update_available.is_some() || self.integration_updates_available() - } - - pub(crate) fn global_menu_item_has_badge(&self, item: &str) -> bool { - (item == "update ready" && self.update_available.is_some()) - || (item == "settings" && self.integration_updates_available()) - } - - pub(crate) fn settings_section_has_badge(&self, section: SettingsSection) -> bool { - section == SettingsSection::Integrations && self.integration_updates_available() + pub(crate) fn integration_updates_available(&self) -> bool { + self.integration_recommendations + .iter() + .any(|recommendation| { + recommendation.state == crate::integration::IntegrationStatusKind::Outdated + && recommendation.needs_install() + }) } pub(crate) fn app_surface_pane_ids(&self) -> std::collections::HashSet<PaneId> { @@ -1702,24 +945,9 @@ impl AppState { &self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ) -> bool { - self.mode == Mode::Terminal - && self - .active - .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) - .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled) - } - - pub(crate) fn should_capture_host_mouse_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> bool { - self.mouse_capture - || self.popup_pane.is_some() - || self.focused_pane_requests_mouse_capture_from(terminal_runtimes) - } - - pub fn is_prefix_key(&self, key: &crate::input::TerminalKey) -> bool { - crate::config::terminal_key_matches_combo(key, (self.prefix_code, self.prefix_mods)) + self.active + .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) + .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled) } pub fn estimate_pane_size(&self) -> (u16, u16) { @@ -1756,26 +984,6 @@ impl AppState { terminal_runtimes.get(terminal_id) } - #[cfg(test)] - pub(crate) fn runtime_for_pane<'a>( - &'a self, - terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, - pane_id: crate::layout::PaneId, - ) -> Option<&'a crate::terminal::TerminalRuntime> { - self.workspaces.iter().find_map(|ws| { - #[cfg(test)] - if let Some(runtime) = ws.test_runtimes.get(&pane_id) { - return Some(runtime); - } - #[cfg(test)] - if let Some(runtime) = ws.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) { - return Some(runtime); - } - let terminal_id = ws.terminal_id(pane_id)?; - terminal_runtimes.get(terminal_id) - }) - } - pub(crate) fn focused_runtime_in_workspace<'a>( &'a self, terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, @@ -1786,6 +994,28 @@ impl AppState { self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) } + pub(crate) fn pane_visible_on_active_surface( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> bool { + if self.active != Some(ws_idx) { + return false; + } + let Some(tab) = self + .workspaces + .get(ws_idx) + .and_then(|workspace| workspace.active_tab()) + else { + return false; + }; + if tab.zoomed { + tab.layout.focused() == pane_id + } else { + tab.layout.pane_ids().contains(&pane_id) + } + } + pub fn is_active_pane( &self, ws_idx: usize, @@ -1838,66 +1068,15 @@ impl AppState { previous_pane_focus: None, selected: 0, mode: Mode::Navigate, - confirm_close_workspace_id: None, should_quit: false, - detach_exits: false, - detach_requested: false, - request_new_workspace: false, - request_new_tab: false, - request_new_linked_worktree: None, - request_open_existing_worktree: None, - request_new_workspace_cwd: None, - request_remove_linked_worktree: None, - request_submit_worktree_create: false, - request_submit_worktree_open: false, - request_submit_worktree_remove: false, - request_reload_config: false, request_client_config_reload: false, - request_clipboard_write: None, - creating_new_tab: false, - requested_new_tab_name: None, - pending_workspace_create_cwd: None, - rename_pane_target: None, - worktree_create: None, - worktree_open: None, - worktree_remove: None, worktree_directory: std::path::PathBuf::from("/tmp/herdr-worktrees"), - collapsed_space_keys: std::collections::HashSet::new(), - request_complete_onboarding: false, - name_input: String::new(), - name_input_replace_on_type: false, - release_notes: None, + latest_release_notes: None, product_announcement: None, - keybind_help: KeybindHelpState::default(), - navigator: NavigatorState::default(), - copy_mode: None, - workspace_scroll: 0, - agent_panel_scroll: 0, - tab_scroll: 0, - tab_scroll_follow_active: true, - mobile_switcher_scroll: 0, view: ViewState { - layout: ViewLayout::Desktop, - sidebar_rect: Rect::default(), - workspace_card_areas: Vec::new(), - tab_bar_rect: Rect::default(), - tab_hit_areas: Vec::new(), - tab_scroll_left_hit_area: Rect::default(), - tab_scroll_right_hit_area: Rect::default(), - new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), - mobile_header_rect: Rect::default(), - mobile_menu_hit_area: Rect::default(), - toast_hit_area: Rect::default(), pane_infos: Vec::new(), - split_borders: Vec::new(), }, - drag: None, - workspace_presses: std::collections::HashMap::new(), - tab_presses: std::collections::HashMap::new(), - selection: None, - selection_autoscroll: None, - context_menu: None, update_available: None, update_install_command: "herdr update".into(), latest_release_notes_available: false, @@ -1905,7 +1084,6 @@ impl AppState { config_diagnostic: None, toast: None, pending_agent_notifications: std::collections::HashMap::new(), - copy_feedback: None, outer_terminal_focus: None, prefix_code: KeyCode::Char('b'), prefix_mods: KeyModifiers::CONTROL, @@ -1913,57 +1091,32 @@ impl AppState { crate::config::DEFAULT_HEADLESS_COLS, crate::config::DEFAULT_HEADLESS_ROWS, ), - default_sidebar_width: 26, - sidebar_width: 26, - sidebar_min_width: 18, - sidebar_max_width: 36, - mobile_width_threshold: crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD, - sidebar_width_source: SidebarWidthSource::ConfigDefault, - sidebar_width_auto: false, - sidebar_collapsed: false, - sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact, - sidebar_section_split: 0.5, agent_panel_sort: AgentPanelSort::Spaces, - status_indicators: crate::config::StatusIndicatorStyle::Dots, agent_view_override: None, sidebar_agents: crate::config::AgentsSidebarConfig::default(), sidebar_spaces: crate::config::SpacesSidebarConfig::default(), next_agent_state_change_seq: 0, - mouse_capture: true, - copy_on_select: true, - right_click_passthrough_modifiers: None, - right_click_passthrough: None, - redraw_on_focus_gained: true, - mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES, confirm_close: true, - prompt_new_tab_name: true, - prompt_new_workspace_name: false, pane_borders: true, pane_outer_borders: true, pane_scrollbars: true, pane_gaps: false, show_agent_labels_on_pane_borders: false, - hide_tab_bar_when_single_tab: false, - tab_bar_position: TabBarPositionConfig::Top, tab_bar_right: Vec::new(), tab_bar_right_separator: " ".into(), - pane_history_persistence: false, reveal_hidden_cursor_for_cjk_ime: false, cjk_ime_agent_filter_configured: false, cjk_ime_agents: Vec::new(), cjk_ime_cursor_shape: 2, // steady_block - switch_ascii_input_source_in_prefix: false, kitty_graphics_enabled: false, default_shell: String::new(), shell_mode: crate::config::ShellModeConfig::Auto, new_terminal_cwd: NewTerminalCwdConfig::Follow, pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES, - accent: Color::Cyan, sound: SoundConfig { enabled: false, ..SoundConfig::default() }, - local_sound_playback: false, toast_config: ToastConfig::default(), keybinds: Keybinds::default(), palette: Palette::catppuccin(), @@ -1978,27 +1131,18 @@ impl AppState { }, host_terminal_appearance: None, host_terminal_appearance_explicit: false, - settings: SettingsState { - section: SettingsSection::Theme, - list: SelectionListState::new(0), - original_palette: None, - original_theme: None, - }, integration_recommendations: Vec::new(), agent_manifest_summaries: Vec::new(), agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus::default(), - integration_install_messages: Vec::new(), installed_plugins: std::collections::HashMap::new(), plugin_panes: std::collections::HashMap::new(), popup_pane: None, plugin_command_logs: Vec::new(), next_plugin_command_log_id: 1, plugin_commands_in_flight: 0, - global_menu: MenuListState::new(0), host_terminal_theme: TerminalTheme::default(), host_cell_size: crate::kitty_graphics::HostCellSize::default(), - host_mouse_pixels: None, session_dirty: false, terminal_runtime_shutdowns: Vec::new(), } @@ -2063,52 +1207,12 @@ impl AppState { self.pending_agent_notifications.is_empty(), "empty app state must not keep pending agent notifications" ); - assert!( - self.copy_mode.is_none(), - "empty app state must not keep copy mode" - ); - assert!( - self.rename_pane_target.is_none(), - "empty app state must not keep rename pane target" - ); - assert!( - self.selection.is_none(), - "empty app state must not keep text selection" - ); - assert!( - self.selection_autoscroll.is_none(), - "empty app state must not keep selection autoscroll" - ); if let Some(toast) = &self.toast { assert!( toast.target.is_none(), "empty app state must not keep pane-targeted toast" ); } - assert!( - self.right_click_passthrough.is_none(), - "empty app state must not keep right-click passthrough gesture" - ); - assert!( - self.drag.is_none(), - "empty app state must not keep drag state" - ); - assert!( - self.workspace_presses.is_empty(), - "empty app state must not keep workspace press state" - ); - assert!( - self.tab_presses.is_empty(), - "empty app state must not keep tab press state" - ); - assert!( - self.context_menu.is_none(), - "empty app state must not keep context menu" - ); - assert!( - self.host_mouse_pixels.is_none(), - "empty app state must not keep host mouse pixel provenance" - ); return; } @@ -2183,25 +1287,6 @@ impl AppState { workspace_id ); }; - let assert_workspace_index = |ws_idx: usize, context: &str| { - assert!( - ws_idx < self.workspaces.len(), - "{context} references workspace index {} out of bounds for {} workspaces", - ws_idx, - self.workspaces.len() - ); - }; - let assert_tab_index = |ws_idx: usize, tab_idx: usize, context: &str| { - assert_workspace_index(ws_idx, context); - assert!( - tab_idx < self.workspaces[ws_idx].tabs.len(), - "{context} references tab index {} out of bounds for workspace {} with {} tabs", - tab_idx, - ws_idx, - self.workspaces[ws_idx].tabs.len() - ); - }; - for (&raw, &pane_id) in &self.pane_id_aliases { assert_live_pane(pane_id, &format!("raw pane alias {raw}")); } @@ -2243,96 +1328,6 @@ impl AppState { for &pane_id in self.plugin_panes.keys() { assert_live_pane(pane_id, "plugin pane record"); } - if let Some(copy_mode) = &self.copy_mode { - assert_live_pane(copy_mode.pane_id, "copy mode"); - } - if let Some(pane_id) = self.rename_pane_target { - assert_live_pane(pane_id, "rename pane target"); - } - if let Some(selection) = &self.selection { - assert_live_pane(selection.pane_id, "text selection"); - } else { - assert!( - self.selection_autoscroll.is_none(), - "selection autoscroll must not remain without an active text selection" - ); - } - if let Some(gesture) = &self.right_click_passthrough { - assert_live_pane(gesture.pane_info.id, "right-click passthrough gesture"); - } - if let Some(drag) = &self.drag { - match &drag.target { - DragTarget::WorkspaceReorder { - source_ws_idx, - drop_target, - .. - } => { - assert_workspace_index(*source_ws_idx, "workspace drag source"); - if let Some(WorkspaceDropTarget::Before(ws_idx)) = drop_target { - assert_workspace_index(*ws_idx, "workspace drag target"); - } - } - DragTarget::TabReorder { - ws_idx, - source_tab_idx, - insert_idx, - .. - } => { - assert_tab_index(*ws_idx, *source_tab_idx, "tab drag source"); - if let Some(insert_idx) = insert_idx { - assert!( - *insert_idx <= self.workspaces[*ws_idx].tabs.len(), - "tab drag insert index {} out of bounds for workspace {} with {} tabs", - insert_idx, - ws_idx, - self.workspaces[*ws_idx].tabs.len() - ); - } - } - DragTarget::PaneScrollbar { pane_id, .. } => { - assert_live_pane(*pane_id, "pane scrollbar drag") - } - _ => {} - } - } - for press in self.workspace_presses.values() { - assert_workspace_index(press.ws_idx, "workspace press"); - } - for press in self.tab_presses.values() { - assert_tab_index(press.ws_idx, press.tab_idx, "tab press"); - } - if let Some(menu) = &self.context_menu { - match menu.kind { - ContextMenuKind::Workspace { ws_idx } - | ContextMenuKind::GitWorkspace { ws_idx, .. } => { - assert_workspace_index(ws_idx, "context menu workspace") - } - ContextMenuKind::Tab { ws_idx, tab_idx } => { - assert_tab_index(ws_idx, tab_idx, "context menu tab") - } - ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id, - source_pane_id, - .. - } => { - assert_tab_index(ws_idx, tab_idx, "context menu pane tab"); - assert!( - self.workspaces[ws_idx].tabs[tab_idx] - .panes - .contains_key(&pane_id), - "context menu pane references pane {:?} outside workspace {} tab {}", - pane_id, - ws_idx, - tab_idx - ); - if let Some(source_pane_id) = source_pane_id { - assert_live_pane(source_pane_id, "context menu source pane"); - } - } - } - } } pub fn insert_test_runtime( @@ -2400,81 +1395,6 @@ mod tests { state.assert_invariants_for_test(); } - fn navigator_row_for_display(is_workspace: bool) -> NavigatorRow { - NavigatorRow { - target: NavigatorTarget::Workspace { ws_idx: 0 }, - depth: if is_workspace { 0 } else { 1 }, - label: String::new(), - meta: String::new(), - status: crate::detect::AgentState::Idle, - seen: true, - is_current: false, - is_workspace, - is_tab: false, - expanded: true, - search_text: String::new(), - matched: true, - } - } - - #[test] - fn navigator_display_lines_separate_workspace_groups() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - navigator_row_for_display(true), - navigator_row_for_display(false), - ]; - assert_eq!( - navigator_display_lines(&rows), - vec![ - NavigatorDisplayLine::Row(0), - NavigatorDisplayLine::Row(1), - NavigatorDisplayLine::Spacer, - NavigatorDisplayLine::Row(2), - NavigatorDisplayLine::Row(3), - ] - ); - } - - #[test] - fn navigator_display_lines_have_no_leading_spacer() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - ]; - assert_eq!( - navigator_display_lines(&rows), - vec![NavigatorDisplayLine::Row(0), NavigatorDisplayLine::Row(1)] - ); - assert!(navigator_display_lines(&[]).is_empty()); - } - - #[test] - fn navigator_display_index_maps_row_to_line() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - navigator_row_for_display(true), - ]; - let lines = navigator_display_lines(&rows); - assert_eq!(navigator_display_index_of_row(&lines, 2), Some(3)); - assert_eq!(navigator_display_index_of_row(&lines, 9), None); - } - - #[test] - fn navigator_first_row_skips_spacer_lines() { - let rows = vec![ - navigator_row_for_display(true), - navigator_row_for_display(false), - navigator_row_for_display(true), - ]; - let lines = navigator_display_lines(&rows); - // Line 2 is the spacer before the second workspace. - assert_eq!(navigator_first_row_at_or_after(&lines, 2), Some(2)); - assert_eq!(navigator_first_row_at_or_after(&lines, 4), None); - } - fn rgb_luminance(color: Color) -> f64 { let Color::Rgb(r, g, b) = color else { panic!("expected RGB color, got {color:?}"); @@ -2501,7 +1421,7 @@ mod tests { #[test] fn built_in_theme_names_resolve() { - for name in THEME_NAMES { + for name in crate::config::THEME_NAMES { assert!( Palette::from_name(name).is_some(), "theme should resolve: {name}" @@ -2511,7 +1431,7 @@ mod tests { #[test] fn built_in_active_rows_remain_visible_with_matching_terminal_backgrounds() { - for name in THEME_NAMES + for name in crate::config::THEME_NAMES .iter() .copied() .filter(|name| *name != "terminal") @@ -2533,7 +1453,7 @@ mod tests { #[test] fn built_in_selection_rows_stay_distinct_from_background_and_active_rows() { - for name in THEME_NAMES + for name in crate::config::THEME_NAMES .iter() .copied() .filter(|name| *name != "terminal") @@ -2559,7 +1479,7 @@ mod tests { #[test] fn built_in_themes_leave_sidebar_background_unset() { - for name in THEME_NAMES { + for name in crate::config::THEME_NAMES { let palette = Palette::from_name(name).unwrap(); assert_eq!( palette.sidebar_bg, @@ -2620,70 +1540,4 @@ mod tests { KeyModifiers::SHIFT, )); } - - #[test] - fn linked_worktree_context_menu_keeps_safe_close_and_explicit_remove() { - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: true, - has_worktree_children: false, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - - assert_eq!( - menu.items(), - &["Rename", "Close", "Delete worktree checkout..."] - ); - } - - #[test] - fn git_workspace_context_menu_keeps_remove_for_managed_worktrees_only() { - let menu = ContextMenuState { - kind: ContextMenuKind::GitWorkspace { - ws_idx: 0, - is_linked_worktree: false, - has_worktree_children: false, - collapsed: false, - }, - x: 0, - y: 0, - list: MenuListState::new(0), - }; - - assert_eq!( - menu.items(), - &["Rename", "Close", "New worktree", "Open worktree..."] - ); - } - - #[test] - fn parent_worktree_context_menu_uses_repo_actions() { - 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), - }; - - assert_eq!( - menu.items(), - &[ - "Rename", - "Close group", - "New worktree", - "Open worktree...", - "Collapse" - ] - ); - } } diff --git a/src/app/tab_bar_status.rs b/src/app/tab_bar_status.rs index eca5a314..596c2117 100644 --- a/src/app/tab_bar_status.rs +++ b/src/app/tab_bar_status.rs @@ -524,7 +524,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs index 0ed2a0f7..94932f4a 100644 --- a/src/app/terminal_titles.rs +++ b/src/app/terminal_titles.rs @@ -88,7 +88,13 @@ mod tests { async fn sync_keeps_latest_raw_title_and_emits_only_for_stripped_changes() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; app.state.active = Some(0); app.state.ensure_test_terminals(); @@ -161,7 +167,13 @@ mod tests { async fn syncing_pending_titles_preserves_sidebar_render_impact() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.workspaces = vec![Workspace::test_new("one")]; app.state.active = Some(0); app.state.ensure_test_terminals(); @@ -189,7 +201,13 @@ mod tests { fn sidebar_redraws_only_for_the_configured_title_form() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; app.state.sidebar_agents.rows_by_agent.insert( "claude".into(), diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index 58f95bec..ed0f632d 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -1,70 +1,6 @@ use super::App; impl App { - #[cfg(not(windows))] - pub(super) fn query_host_terminal_appearance(&self) { - use std::io::Write; - - let _ = std::io::stdout() - .write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes()); - let _ = std::io::stdout().flush(); - } - - pub(super) fn query_host_terminal_theme(&self) { - use std::io::Write; - - let query = crate::terminal_theme::host_terminal_theme_query_sequence( - crate::platform::should_query_host_terminal_palette(), - ); - let _ = std::io::stdout().write_all(query.as_bytes()); - let _ = std::io::stdout().flush(); - } - - pub(super) fn update_host_terminal_theme( - &mut self, - kind: crate::terminal_theme::DefaultColorKind, - color: crate::terminal_theme::RgbColor, - ) -> bool { - let mut changed = false; - if matches!(kind, crate::terminal_theme::DefaultColorKind::Background) - && !self.state.host_terminal_appearance_explicit - { - changed |= self.set_host_terminal_appearance(color.inferred_appearance(), false); - } - let next_theme = self.state.host_terminal_theme.with_color(kind, color); - changed | self.set_host_terminal_theme(next_theme) - } - - pub(super) fn update_host_terminal_palette_colors( - &mut self, - colors: &[(u8, crate::terminal_theme::RgbColor)], - ) -> bool { - let mut next_theme = self.state.host_terminal_theme; - for &(index, color) in colors { - next_theme = next_theme.with_palette_color(index, color); - } - self.set_host_terminal_theme(next_theme) - } - - pub(super) fn set_host_terminal_appearance( - &mut self, - appearance: crate::terminal_theme::HostAppearance, - explicit: bool, - ) -> bool { - if self.state.host_terminal_appearance == Some(appearance) - && self.state.host_terminal_appearance_explicit == explicit - { - return false; - } - if self.state.host_terminal_appearance_explicit && !explicit { - return false; - } - self.state.host_terminal_appearance = Some(appearance); - self.state.host_terminal_appearance_explicit = explicit; - self.apply_host_terminal_appearance_to_panes(); - self.refresh_effective_app_theme() - } - pub(crate) fn set_host_terminal_appearance_state( &mut self, appearance: Option<crate::terminal_theme::HostAppearance>, diff --git a/src/app/window_title.rs b/src/app/window_title.rs index 841c6a92..09d2f850 100644 --- a/src/app/window_title.rs +++ b/src/app/window_title.rs @@ -107,7 +107,13 @@ mod tests { fn test_app() -> App { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.workspaces = vec![Workspace::test_new("herd")]; app.state.active = Some(0); app.state.ensure_test_terminals(); diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index 9993a70e..b4ac1482 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -1,972 +1,6 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -use super::{ - state::{WorktreeCreateState, WorktreeOpenEntry, WorktreeOpenState, WorktreeRemoveState}, - App, Mode, -}; -#[cfg(test)] -use crate::events::AppEvent; -use crate::events::{WorktreeAddResult, WorktreeRemoveResult}; +use super::App; impl App { - fn worktree_source_metadata( - &self, - ws_idx: usize, - ) -> Result< - ( - Option<crate::workspace::WorktreeSpaceMembership>, - crate::workspace::GitSpaceMetadata, - std::path::PathBuf, - String, - ), - String, - > { - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return Err("Workspace not found.".into()); - }; - let existing_membership = ws.worktree_space().cloned(); - if existing_membership - .as_ref() - .is_some_and(|membership| membership.is_linked_worktree) - { - return Err( - "New and open worktree actions start from the repo parent workspace.".into(), - ); - } - - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - if git_space - .as_ref() - .is_some_and(|metadata| metadata.is_linked_worktree) - { - return Err( - "New and open worktree actions start from the repo parent workspace.".into(), - ); - } - - let space = existing_membership - .as_ref() - .map_or(git_space, |membership| { - Some(crate::workspace::GitSpaceMetadata { - key: membership.key.clone(), - checkout_key: membership.checkout_path.display().to_string(), - repo_name: membership.label.clone(), - repo_root: membership.repo_root.clone(), - is_linked_worktree: membership.is_linked_worktree, - }) - }) - .ok_or_else(|| { - "Herdr worktree actions require a workspace inside a Git work tree.".to_string() - })?; - let source_checkout_path = existing_membership - .as_ref() - .map(|membership| membership.checkout_path.clone()) - .unwrap_or_else(|| space.repo_root.clone()); - let source_workspace_id = self.state.workspaces[ws_idx].id.clone(); - Ok(( - existing_membership, - space, - source_checkout_path, - source_workspace_id, - )) - } - - pub(crate) fn open_new_linked_worktree_dialog(&mut self, ws_idx: usize) { - let (existing_membership, space, source_checkout_path, source_workspace_id) = - match self.worktree_source_metadata(ws_idx) { - Ok(metadata) => metadata, - Err(err) => { - self.state.config_diagnostic = Some(err); - return; - } - }; - - let repo_name = space.repo_name.clone(); - let seed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64) - .unwrap_or(0); - let branch = crate::worktree::generated_branch_slug(seed); - let checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &repo_name, - &branch, - ); - - tracing::info!( - ws_idx, - repo_root = %space.repo_root.display(), - branch, - checkout_path = %checkout_path.display(), - "opening worktree dialog" - ); - self.state.selected = ws_idx; - self.state.name_input = branch.clone(); - self.state.name_input_replace_on_type = true; - self.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path, - source_existing_membership: existing_membership, - source_repo_root: space.repo_root, - repo_key: space.key, - repo_name, - branch, - checkout_path, - error: None, - creating: false, - }); - self.state.mode = Mode::NewLinkedWorktree; - } - - pub(crate) fn open_remove_linked_worktree_confirmation(&mut self, ws_idx: usize) { - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return; - }; - if !ws - .worktree_space() - .is_some_and(|space| space.is_linked_worktree) - { - self.state.config_diagnostic = - Some("This workspace is not a Herdr-managed worktree checkout.".into()); - return; - } - let Some(space) = ws.worktree_space().cloned() else { - return; - }; - self.state.selected = ws_idx; - self.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: ws.id.clone(), - repo_root: space.repo_root, - path: space.checkout_path, - error: None, - removing: false, - force_confirmation: false, - }); - self.state.mode = Mode::ConfirmRemoveWorktree; - } - - pub(crate) fn open_existing_worktree_dialog(&mut self, ws_idx: usize) { - let (existing_membership, space, source_checkout_path, source_workspace_id) = - match self.worktree_source_metadata(ws_idx) { - Ok(metadata) => metadata, - Err(err) => { - self.state.config_diagnostic = Some(err); - return; - } - }; - - let list = match crate::worktree::list_existing_worktrees(&space.repo_root, false) { - Ok(list) => list, - Err(err) => { - self.state.config_diagnostic = Some(err); - return; - } - }; - let entries = list - .into_iter() - .filter(|entry| !entry.is_bare && !entry.is_prunable) - .map(|entry| { - let entry_checkout_path = crate::worktree::canonical_or_original(&entry.path); - let entry_checkout_key = entry_checkout_path.display().to_string(); - let repo_checkout_path = crate::worktree::canonical_or_original(&space.repo_root); - let already_open_ws_idx = self.state.workspaces.iter().position(|ws| { - if let Some(membership) = ws.worktree_space() { - return crate::worktree::canonical_or_original(&membership.checkout_path) - == entry_checkout_path; - } - - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from( - &self.state.terminals, - &self.terminal_runtimes, - ) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - if git_space - .as_ref() - .is_some_and(|metadata| metadata.checkout_key == entry_checkout_key) - { - return true; - } - - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - .as_deref() - .is_some_and(|cwd| { - crate::worktree::canonical_or_original(cwd) == entry_checkout_path - }) - }); - WorktreeOpenEntry { - is_linked_worktree: entry_checkout_path != repo_checkout_path, - path: entry.path, - branch: entry.branch, - already_open_ws_idx, - } - }) - .collect::<Vec<_>>(); - - if entries.is_empty() { - self.state.config_diagnostic = Some("No Git worktrees found for this repo.".into()); - return; - } - - self.state.selected = ws_idx; - self.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: existing_membership, - source_checkout_path, - source_repo_root: space.repo_root, - repo_key: space.key, - repo_name: space.repo_name, - entries, - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - self.state.mode = Mode::OpenExistingWorktree; - } - - pub(crate) fn handle_worktree_create_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - if self - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.creating) - { - return; - } - self.close_worktree_create_dialog(); - } - KeyCode::Enter => self.submit_worktree_create_via_api(), - KeyCode::Backspace => { - if self.state.name_input_replace_on_type { - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - } else { - self.state.name_input.pop(); - } - self.sync_worktree_branch_from_input(); - } - KeyCode::Char(c) => { - self.insert_worktree_create_text(&c.to_string()); - } - _ => {} - } - } - - pub(crate) fn insert_worktree_create_text(&mut self, text: &str) { - if self.state.name_input_replace_on_type { - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - } - self.state.name_input.push_str(text); - self.sync_worktree_branch_from_input(); - } - - pub(crate) fn handle_worktree_open_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.worktree_open = None; - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - KeyCode::Up => { - if let Some(open) = &mut self.state.worktree_open { - open.select_previous_filtered(); - } - } - KeyCode::Down => { - if let Some(open) = &mut self.state.worktree_open { - open.select_next_filtered(); - } - } - KeyCode::Char('/') => { - if let Some(open) = &mut self.state.worktree_open { - if open.search_focused { - open.query.push('/'); - open.normalize_selection(); - } else { - open.search_focused = true; - } - } - } - KeyCode::Char(ch) - if self - .state - .worktree_open - .as_ref() - .is_some_and(|open| open.search_focused) - && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) - && !ch.is_control() => - { - self.insert_worktree_open_search_text(&ch.to_string()); - } - KeyCode::Backspace - if self - .state - .worktree_open - .as_ref() - .is_some_and(|open| open.search_focused) => - { - if let Some(open) = &mut self.state.worktree_open { - open.query.pop(); - open.normalize_selection(); - } - } - KeyCode::Enter => self.submit_worktree_open_via_api(), - _ => {} - } - } - - pub(crate) fn insert_worktree_open_search_text(&mut self, text: &str) { - let Some(open) = &mut self.state.worktree_open else { - return; - }; - if !open.search_focused { - return; - } - open.query.push_str(text); - open.normalize_selection(); - } - - #[cfg(test)] - pub(crate) fn open_selected_existing_worktree(&mut self) { - let Some(open) = self.state.worktree_open.as_ref() else { - return; - }; - let Some(entry_idx) = open.selected_entry_index() else { - return; - }; - let Some(entry) = open.entries.get(entry_idx).cloned() else { - return; - }; - let source_workspace_id = open.source_workspace_id.clone(); - let source_existing_membership = open.source_existing_membership.clone(); - let source_checkout_path = open.source_checkout_path.clone(); - let source_repo_root = open.source_repo_root.clone(); - let repo_key = open.repo_key.clone(); - let repo_name = open.repo_name.clone(); - self.state.worktree_open = None; - - if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&entry.path) { - self.mark_opened_existing_worktree_membership( - &source_workspace_id, - source_existing_membership, - source_checkout_path, - source_repo_root, - repo_key, - repo_name, - ws_idx, - entry.path, - entry.is_linked_worktree, - ); - self.state.switch_workspace(ws_idx); - self.state.mode = Mode::Terminal; - self.emit_worktree_opened_for_workspace(ws_idx, true); - return; - } - - if let Some(source_ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == source_workspace_id) - { - let source_membership = source_existing_membership.clone().unwrap_or( - crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path.clone(), - is_linked_worktree: false, - }, - ); - self.set_worktree_membership(source_ws_idx, source_membership, true); - } - - match self.create_workspace_with_options(entry.path.clone(), true) { - Ok(new_ws_idx) => { - self.set_worktree_membership( - new_ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: entry.path, - is_linked_worktree: entry.is_linked_worktree, - }, - false, - ); - self.emit_workspace_open_events(new_ws_idx); - self.emit_worktree_opened_for_workspace(new_ws_idx, false); - } - Err(err) => { - self.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership, - source_checkout_path, - source_repo_root, - repo_key, - repo_name, - entries: vec![entry], - selected: 0, - query: String::new(), - search_focused: false, - error: Some(format!("failed to open worktree: {err}")), - }); - self.state.mode = Mode::OpenExistingWorktree; - } - } - } - - // The caller has already extracted the open-worktree dialog state; keeping the - // membership fields explicit here avoids borrowing AppState across workspace creation. - #[allow(clippy::too_many_arguments)] - #[cfg(test)] - fn mark_opened_existing_worktree_membership( - &mut self, - source_workspace_id: &str, - source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>, - source_checkout_path: std::path::PathBuf, - source_repo_root: std::path::PathBuf, - repo_key: String, - repo_name: String, - target_ws_idx: usize, - target_path: std::path::PathBuf, - target_is_linked_worktree: bool, - ) { - if let Some(source_ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == source_workspace_id) - { - let source_membership = - source_existing_membership.unwrap_or(crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path, - is_linked_worktree: false, - }); - self.set_worktree_membership(source_ws_idx, source_membership, true); - } - self.set_worktree_membership( - target_ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: target_path, - is_linked_worktree: target_is_linked_worktree, - }, - true, - ); - } - - fn close_worktree_create_dialog(&mut self) { - self.state.worktree_create = None; - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - - fn sync_worktree_branch_from_input(&mut self) { - let Some(create) = &mut self.state.worktree_create else { - return; - }; - create.branch = self.state.name_input.clone(); - create.checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &create.repo_name, - &create.branch, - ); - create.error = None; - } - - #[cfg(test)] - pub(crate) fn start_worktree_add(&mut self) { - self.sync_worktree_branch_from_input(); - let Some(create) = &mut self.state.worktree_create else { - return; - }; - let branch = create.branch.trim().to_string(); - if branch.is_empty() { - create.error = Some("branch is required".into()); - return; - } - if create.creating { - return; - } - - create.branch = branch.clone(); - self.state.name_input = branch.clone(); - create.checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &create.repo_name, - &branch, - ); - create.creating = true; - create.error = None; - - let parent_dir = create - .checkout_path - .parent() - .map(std::path::Path::to_path_buf); - tracing::info!( - repo_root = %create.source_repo_root.display(), - branch = %create.branch, - checkout_path = %create.checkout_path.display(), - "starting git worktree add" - ); - let path = create.checkout_path.clone(); - let source_checkout_path = create.source_checkout_path.clone(); - let branch = create.branch.clone(); - let event_tx = self.event_tx.clone(); - std::thread::spawn(move || { - let result = if let Some(parent_dir) = parent_dir { - std::fs::create_dir_all(&parent_dir).map_err(|err| err.to_string()) - } else { - Ok(()) - } - .and_then(|()| { - crate::worktree::run_worktree_add_command( - &source_checkout_path, - &path, - &branch, - "HEAD", - false, - ) - }); - let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new( - WorktreeAddResult { - path, - api_request: None, - result, - }, - ))); - }); - } - - pub(crate) fn submit_worktree_create_via_api(&mut self) { - self.sync_worktree_branch_from_input(); - let Some(create) = &mut self.state.worktree_create else { - return; - }; - let branch = create.branch.trim().to_string(); - if branch.is_empty() { - create.error = Some("branch is required".into()); - return; - } - if create.creating { - return; - } - - create.branch = branch.clone(); - self.state.name_input = branch.clone(); - create.checkout_path = crate::worktree::default_checkout_path( - &self.state.worktree_directory, - &create.repo_name, - &branch, - ); - create.creating = true; - create.error = None; - let workspace_id = create.source_workspace_id.clone(); - let checkout_path = create.checkout_path.display().to_string(); - - let immediate_response = self.runtime_worktree_create_deferred( - "tui.worktree.create", - crate::api::schema::WorktreeCreateParams { - workspace_id: Some(workspace_id), - cwd: None, - branch: Some(branch), - path: Some(checkout_path), - base: Some("HEAD".into()), - focus: true, - label: None, - trust_repository: false, - }, - ); - if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) { - if let Some(create) = &mut self.state.worktree_create { - create.creating = false; - create.error = Some(message); - } - } - } - - pub(crate) fn handle_worktree_remove_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - if self - .state - .worktree_remove - .as_ref() - .is_some_and(|remove| remove.removing) - { - return; - } - self.state.worktree_remove = None; - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - KeyCode::Enter => self.submit_worktree_remove_via_api(), - _ => {} - } - } - - #[cfg(test)] - pub(crate) fn start_worktree_remove(&mut self) { - let Some((workspace_id, repo_root, path, force)) = - self.state.worktree_remove.as_mut().and_then(|remove| { - if remove.removing { - return None; - } - #[cfg(windows)] - if !remove.force_confirmation - && crate::worktree::checkout_has_dirty_files(&remove.path, false) - .unwrap_or(false) - { - remove.force_confirmation = true; - remove.error = None; - return None; - } - remove.removing = true; - remove.error = None; - Some(( - remove.workspace_id.clone(), - remove.repo_root.clone(), - remove.path.clone(), - remove.force_confirmation, - )) - }) - else { - return; - }; - - if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(force) { - if let Some(ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == workspace_id) - { - self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx); - } - } - - let (workspace_snapshot, worktree_snapshot) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == workspace_id) - .map(|ws_idx| { - let workspace = Box::new(self.workspace_info(ws_idx)); - let worktree = self.state.workspaces[ws_idx] - .worktree_space() - .cloned() - .map(|space| Box::new(self.worktree_info_for_membership(&space, None))); - (Some(workspace), worktree) - }) - .unwrap_or((None, None)); - - let command = - crate::worktree::build_worktree_remove_command(&repo_root, &path, force, false); - tracing::info!(workspace_id = %workspace_id, path = %path.display(), force, "starting git worktree remove"); - let event_tx = self.event_tx.clone(); - std::thread::spawn(move || { - let result = crate::worktree::run_worktree_remove_command_with_recovery( - &command, &repo_root, &path, force, false, - ); - let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new( - WorktreeRemoveResult { - workspace_id, - path, - workspace: workspace_snapshot, - worktree: worktree_snapshot, - forced: force, - api_request: None, - result, - }, - ))); - }); - } - - pub(crate) fn submit_worktree_open_via_api(&mut self) { - let Some(open) = self.state.worktree_open.as_ref() else { - return; - }; - let Some(entry_idx) = open.selected_entry_index() else { - return; - }; - let Some(entry) = open.entries.get(entry_idx).cloned() else { - return; - }; - let source_workspace_id = open.source_workspace_id.clone(); - - let response = self.runtime_worktree_open( - "tui.worktree.open", - crate::api::schema::WorktreeOpenParams { - workspace_id: Some(source_workspace_id), - cwd: None, - path: Some(entry.path.display().to_string()), - branch: None, - focus: true, - label: None, - trust_repository: false, - }, - ); - if serde_json::from_str::<crate::api::schema::SuccessResponse>(&response).is_ok() { - self.state.worktree_open = None; - self.state.mode = Mode::Terminal; - } else if let Ok(error) = - serde_json::from_str::<crate::api::schema::ErrorResponse>(&response) - { - if let Some(open) = &mut self.state.worktree_open { - open.error = Some(error.error.message); - } - } - } - - pub(crate) fn submit_worktree_remove_via_api(&mut self) { - let Some(remove) = self.state.worktree_remove.as_mut() else { - return; - }; - if remove.removing { - return; - } - #[cfg(windows)] - if !remove.force_confirmation - && crate::worktree::checkout_has_dirty_files(&remove.path, false).unwrap_or(false) - { - remove.force_confirmation = true; - remove.error = None; - return; - } - - remove.removing = true; - remove.error = None; - let workspace_id = remove.workspace_id.clone(); - let force = remove.force_confirmation; - let immediate_response = self.runtime_worktree_remove_deferred( - "tui.worktree.remove", - crate::api::schema::WorktreeRemoveParams { - workspace_id, - force, - trust_repository: false, - }, - ); - if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) { - if let Some(remove) = &mut self.state.worktree_remove { - remove.removing = false; - remove.error = Some(message); - } - } - } - - pub(crate) fn handle_worktree_add_finished(&mut self, result: WorktreeAddResult) { - if result.api_request.is_some() { - self.handle_api_worktree_add_finished(result); - return; - } - let Some(create) = &mut self.state.worktree_create else { - return; - }; - if create.checkout_path != result.path { - return; - } - - match result.result { - Ok(()) => { - tracing::info!(checkout_path = %create.checkout_path.display(), "git worktree add completed"); - let path = create.checkout_path.clone(); - let source_workspace_id = create.source_workspace_id.clone(); - let source_checkout_path = create.source_checkout_path.clone(); - let source_existing_membership = create.source_existing_membership.clone(); - let repo_key = create.repo_key.clone(); - let repo_name = create.repo_name.clone(); - let source_repo_root = create.source_repo_root.clone(); - self.state.worktree_create = None; - self.state.name_input.clear(); - self.state.name_input_replace_on_type = false; - let source_membership = source_existing_membership.unwrap_or( - crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path, - is_linked_worktree: false, - }, - ); - if let Some(source_ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == source_workspace_id) - { - self.set_worktree_membership(source_ws_idx, source_membership, true); - } - if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&path) { - self.set_worktree_membership( - ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: path, - is_linked_worktree: true, - }, - true, - ); - self.state.switch_workspace(ws_idx); - self.state.mode = Mode::Terminal; - if let Some(worktree) = self.worktree_info_for_workspace(ws_idx) { - self.emit_worktree_created_event(ws_idx, worktree); - } - } else { - match self.create_workspace_with_options(path.clone(), true) { - Ok(ws_idx) => { - self.set_worktree_membership( - ws_idx, - crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: path, - is_linked_worktree: true, - }, - false, - ); - self.emit_workspace_open_events(ws_idx); - if let Some(worktree) = self.worktree_info_for_workspace(ws_idx) { - self.emit_worktree_created_event(ws_idx, worktree); - } - } - Err(err) => { - self.state.config_diagnostic = Some(format!( - "created worktree but failed to open workspace: {err}" - )); - self.state.mode = Mode::Navigate; - } - } - } - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - Err(message) => { - tracing::warn!(checkout_path = %create.checkout_path.display(), error = %message, "git worktree add failed"); - create.creating = false; - create.error = Some(message); - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - } - } - pub(crate) fn handle_worktree_remove_finished(&mut self, result: WorktreeRemoveResult) { - if result.api_request.is_some() { - self.handle_api_worktree_remove_finished(result); - return; - } - let Some(remove) = &mut self.state.worktree_remove else { - return; - }; - if remove.workspace_id != result.workspace_id || remove.path != result.path { - return; - } - - match result.result { - Ok(()) => { - tracing::info!(workspace_id = %result.workspace_id, path = %result.path.display(), "git worktree remove completed"); - let forced = result.forced; - self.state.worktree_remove = None; - let mut workspace_id = result.workspace_id.clone(); - let mut workspace_snapshot = result.workspace.as_deref().cloned(); - let mut worktree = result.worktree.as_deref().cloned(); - if let Some(ws_idx) = self - .state - .workspaces - .iter() - .position(|ws| ws.id == result.workspace_id) - { - workspace_id = self.public_workspace_id(ws_idx); - workspace_snapshot.get_or_insert_with(|| self.workspace_info(ws_idx)); - if worktree.is_none() { - worktree = self.state.workspaces[ws_idx] - .worktree_space() - .cloned() - .map(|space| self.worktree_info_for_membership(&space, None)); - } - let still_same_linked_worktree = self.state.workspaces[ws_idx] - .worktree_space() - .is_some_and(|space| { - space.is_linked_worktree && space.checkout_path == result.path - }); - if still_same_linked_worktree { - self.close_removed_linked_worktree_workspace(ws_idx); - self.shutdown_detached_terminal_runtimes(); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceClosed, - data: crate::api::schema::EventData::WorkspaceClosed { - workspace_id: workspace_id.clone(), - workspace: workspace_snapshot.clone(), - }, - }); - } - } else if let Some(snapshot) = workspace_snapshot.as_ref() { - workspace_id = snapshot.workspace_id.clone(); - } - if let Some(worktree) = worktree { - self.emit_worktree_removed_event( - workspace_id, - workspace_snapshot, - worktree, - forced, - ); - } - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - Err(message) => { - tracing::warn!(workspace_id = %result.workspace_id, path = %result.path.display(), error = %message, "git worktree remove failed"); - remove.removing = false; - if !remove.force_confirmation - && crate::worktree::is_dirty_worktree_remove_error(&message) - { - remove.force_confirmation = true; - remove.error = None; - } else { - remove.error = Some(message); - } - self.render_dirty.request_generic(); - self.render_notify.notify_one(); - } - } - } - pub(crate) fn should_shutdown_workspace_terminal_runtimes_for_worktree_remove( force: bool, ) -> bool { @@ -1017,1384 +51,9 @@ impl App { } } -fn immediate_api_error_message(response: Option<&str>) -> Option<String> { - response - .and_then(|response| { - serde_json::from_str::<crate::api::schema::ErrorResponse>(response).ok() - }) - .map(|response| response.error.message) -} - #[cfg(test)] mod tests { - use super::*; - - 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())) - } - - fn run_git(repo: &std::path::Path, args: &[&str]) { - let status = std::process::Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .status() - .unwrap(); - assert!( - status.success(), - "git command failed: git -C {} {}", - repo.display(), - args.join(" ") - ); - } - - fn create_committed_repo(name: &str) -> std::path::PathBuf { - let repo = unique_temp_path(name); - std::fs::create_dir_all(&repo).unwrap(); - run_git(&repo, &["init", "--quiet"]); - run_git(&repo, &["config", "user.email", "herdr@example.invalid"]); - run_git(&repo, &["config", "user.name", "Herdr Test"]); - std::fs::write(repo.join("README.md"), "test\n").unwrap(); - run_git(&repo, &["add", "README.md"]); - run_git(&repo, &["commit", "--quiet", "-m", "initial"]); - repo - } - - fn wait_for_worktree_event(app: &mut App) -> AppEvent { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if let Ok(event) = app.event_rx.try_recv() { - return event; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - panic!("timed out waiting for worktree event"); - } - - fn app_for_worktree_tests() -> App { - app_for_worktree_tests_with_event_hub(crate::api::EventHub::default()) - } - - fn app_for_worktree_tests_with_event_hub(event_hub: crate::api::EventHub) -> App { - App::new( - &crate::config::Config::default(), - true, - None, - tokio::sync::mpsc::unbounded_channel().1, - event_hub, - ) - } - - fn event_kinds(event_hub: &crate::api::EventHub) -> Vec<crate::api::schema::EventKind> { - event_hub - .events_after(0) - .into_iter() - .map(|(_, event)| event.event) - .collect() - } - - fn shutdown_test_runtimes(app: &mut App) { - for (_, runtime) in app.terminal_runtimes.drain() { - runtime.shutdown(); - } - } - - #[tokio::test] - async fn ui_create_workspace_emits_initial_workspace_tab_and_pane_events() { - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - - app.create_workspace(); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - ] - ); - shutdown_test_runtimes(&mut app); - } - - #[tokio::test] - async fn ui_create_tab_emits_tab_and_pane_events() { - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.create_workspace_with_options(std::env::temp_dir(), true) - .unwrap(); - - app.create_tab(); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - ] - ); - shutdown_test_runtimes(&mut app); - } - - #[test] - fn worktree_create_replaces_prefilled_branch_on_paste_and_syncs_state() { - let mut app = app_for_worktree_tests(); - app.state.name_input = "generated-branch".into(); - app.state.name_input_replace_on_type = true; - app.state.worktree_create = Some(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.insert_worktree_create_text("feature/linear-302"); - - assert_eq!(app.state.name_input, "feature/linear-302"); - assert!(!app.state.name_input_replace_on_type); - assert_eq!( - app.state - .worktree_create - .as_ref() - .map(|create| create.branch.as_str()), - Some("feature/linear-302") - ); - } - - #[test] - fn worktree_open_search_accepts_pasted_text_when_focused() { - let mut app = app_for_worktree_tests(); - app.state.worktree_open = Some(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![ - WorktreeOpenEntry { - path: "/repo/herdr-main".into(), - branch: Some("main".into()), - is_linked_worktree: false, - already_open_ws_idx: None, - }, - WorktreeOpenEntry { - path: "/repo/feature-linear-302".into(), - branch: Some("feature/linear-302".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - ], - selected: 0, - query: String::new(), - search_focused: true, - error: None, - }); - - app.insert_worktree_open_search_text("linear-302"); - - let open = app.state.worktree_open.as_ref().unwrap(); - assert_eq!(open.query, "linear-302"); - assert_eq!(open.selected_entry_index(), Some(1)); - } - - #[test] - fn worktree_open_search_ignores_paste_when_search_is_not_focused() { - let mut app = app_for_worktree_tests(); - app.state.worktree_open = Some(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::new(), - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.insert_worktree_open_search_text("linear-302"); - - assert_eq!( - app.state - .worktree_open - .as_ref() - .map(|open| open.query.as_str()), - Some("") - ); - } - - #[test] - fn open_selected_existing_worktree_focuses_already_open_workspace() { - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("main"), - crate::workspace::Workspace::test_new("issue"), - ]; - app.state.workspaces[1].identity_cwd = "/repo/herdr-issue".into(); - app.state.active = Some(0); - app.state.selected = 0; - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id: app.state.workspaces[0].id.clone(), - 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![WorktreeOpenEntry { - path: "/repo/herdr-issue".into(), - branch: Some("worktree/issue".into()), - is_linked_worktree: true, - already_open_ws_idx: Some(1), - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.open_selected_existing_worktree(); - - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.selected, 1); - assert!(app.state.worktree_open.is_none()); - assert!(app.state.workspaces[0].worktree_space().is_some()); - let target_membership = app.state.workspaces[1].worktree_space().unwrap(); - assert_eq!(target_membership.key, "repo-key"); - assert_eq!( - target_membership.checkout_path, - std::path::PathBuf::from("/repo/herdr-issue") - ); - assert!(target_membership.is_linked_worktree); - } - - #[tokio::test] - async fn ui_worktree_open_new_workspace_emits_api_parity_events() { - let checkout = unique_temp_path("app-worktree-open-event-checkout"); - std::fs::create_dir_all(&checkout).unwrap(); - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }; - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: Some(source_membership), - source_checkout_path: "/repo/herdr".into(), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![WorktreeOpenEntry { - path: checkout.clone(), - branch: Some("worktree/open-event".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.open_selected_existing_worktree(); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeOpened, - ] - ); - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(checkout); - } - - #[tokio::test] - async fn open_selected_existing_worktree_recomputes_stale_already_open_state() { - let checkout = unique_temp_path("app-worktree-stale-open-checkout"); - std::fs::create_dir_all(&checkout).unwrap(); - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("source"), - crate::workspace::Workspace::test_new("other"), - ]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - 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![WorktreeOpenEntry { - path: checkout.clone(), - branch: Some("worktree/stale-open".into()), - is_linked_worktree: true, - already_open_ws_idx: Some(1), - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.open_selected_existing_worktree(); - - assert_eq!(app.state.workspaces.len(), 3); - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceUpdated, - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeOpened, - ] - ); - let opened = event_hub - .events_after(0) - .into_iter() - .find_map(|(_, event)| match event.data { - crate::api::schema::EventData::WorktreeOpened { already_open, .. } => { - Some(already_open) - } - _ => None, - }) - .expect("worktree.opened should be emitted"); - assert!(!opened); - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(checkout); - } - - #[test] - fn worktree_open_search_filters_entries() { - let mut app = app_for_worktree_tests(); - app.state.worktree_open = Some(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![ - WorktreeOpenEntry { - path: "/repo/herdr".into(), - branch: Some("main".into()), - is_linked_worktree: false, - already_open_ws_idx: Some(0), - }, - WorktreeOpenEntry { - path: "/repo/fd-cleanup".into(), - branch: Some("fd-cleanup".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - WorktreeOpenEntry { - path: "/repo/bell-forward-macos-bounce".into(), - branch: Some("bell-forward-macos-bounce".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }, - ], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('/'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('f'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('d'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('-'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('c'), - crossterm::event::KeyModifiers::empty(), - )); - app.handle_worktree_open_key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('l'), - crossterm::event::KeyModifiers::empty(), - )); - - let open = app.state.worktree_open.as_ref().unwrap(); - assert_eq!(open.query, "fd-cl"); - assert_eq!(open.filtered_indices(), vec![1]); - assert_eq!(open.selected_entry_index(), Some(1)); - } - - #[test] - fn open_existing_worktree_detects_already_open_checkout_from_subdirectory() { - let repo = create_committed_repo("app-worktree-open-existing-repo"); - let checkout = unique_temp_path("app-worktree-open-existing-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/open-existing", - checkout.to_str().unwrap(), - "HEAD", - ], - ); - let subdir = checkout.join("nested"); - std::fs::create_dir_all(&subdir).unwrap(); - - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("main"), - crate::workspace::Workspace::test_new("nested"), - ]; - app.state.workspaces[0].identity_cwd = repo; - app.state.workspaces[1].identity_cwd = subdir; - - app.open_existing_worktree_dialog(0); - - let open = app.state.worktree_open.as_ref().unwrap(); - let checkout = crate::worktree::canonical_or_original(&checkout); - let entry = open - .entries - .iter() - .find(|entry| crate::worktree::canonical_or_original(&entry.path) == checkout) - .unwrap_or_else(|| panic!("missing checkout in entries: {:?}", open.entries)); - assert_eq!(entry.already_open_ws_idx, Some(1)); - } - - #[test] - fn worktree_create_and_open_dialogs_reject_linked_child_source() { - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - app.state.mode = Mode::Navigate; - app.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-issue".into(), - is_linked_worktree: true, - }); - - app.open_new_linked_worktree_dialog(0); - - assert_eq!(app.state.mode, Mode::Navigate); - assert!(app.state.worktree_create.is_none()); - assert_eq!( - app.state.config_diagnostic.as_deref(), - Some("New and open worktree actions start from the repo parent workspace.") - ); - - app.state.config_diagnostic = None; - app.open_existing_worktree_dialog(0); - - assert!(app.state.worktree_open.is_none()); - assert_eq!( - app.state.config_diagnostic.as_deref(), - Some("New and open worktree actions start from the repo parent workspace.") - ); - } - - #[test] - fn sync_worktree_branch_updates_derived_path() { - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = std::path::PathBuf::from("/w"); - app.state.name_input = "issue/137".into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: std::path::PathBuf::from("/repo/herdr"), - source_existing_membership: None, - source_repo_root: std::path::PathBuf::from("/repo/herdr"), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: "old".into(), - checkout_path: std::path::PathBuf::from("/old"), - error: Some("old error".into()), - creating: false, - }); - - app.sync_worktree_branch_from_input(); - - let create = app.state.worktree_create.unwrap(); - assert_eq!(create.branch, "issue/137"); - assert_eq!( - create.checkout_path, - std::path::PathBuf::from("/w/herdr/issue-137") - ); - assert_eq!(create.error, None); - } - - #[test] - fn worktree_create_enter_submits_through_api_path() { - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = std::path::PathBuf::from("/w"); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr".into(), - is_linked_worktree: false, - }; - let branch = "issue/195"; - let checkout_path = - crate::worktree::default_checkout_path(&app.state.worktree_directory, "herdr", branch); - let checkout_key = crate::worktree::canonical_or_original(&checkout_path); - app.pending_api_worktree_creates.insert(checkout_key, 1); - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.mode = Mode::NewLinkedWorktree; - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path: "/repo/herdr".into(), - source_existing_membership: Some(source_membership), - source_repo_root: "/repo/herdr".into(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path, - error: None, - creating: false, - }); - - app.handle_worktree_create_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - let create = app.state.worktree_create.as_ref().unwrap(); - assert!(!create.creating); - assert_eq!( - create.error.as_deref(), - Some("worktree operation is already in progress for this checkout") - ); - } - - #[tokio::test] - async fn worktree_open_enter_submits_through_api_path() { - let repo = create_committed_repo("app-worktree-open-enter-repo"); - let checkout = unique_temp_path("app-worktree-open-enter-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/open-enter", - checkout.to_str().unwrap(), - "HEAD", - ], - ); - - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: repo.clone(), - checkout_path: repo.clone(), - is_linked_worktree: false, - }; - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.mode = Mode::OpenExistingWorktree; - app.state.worktree_open = Some(WorktreeOpenState { - source_workspace_id, - source_existing_membership: Some(source_membership), - source_checkout_path: repo.clone(), - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - entries: vec![WorktreeOpenEntry { - path: checkout.clone(), - branch: Some("worktree/open-enter".into()), - is_linked_worktree: true, - already_open_ws_idx: None, - }], - selected: 0, - query: String::new(), - search_focused: false, - error: None, - }); - - app.handle_worktree_open_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - assert!(app.state.worktree_open.is_none()); - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeOpened, - ] - ); - shutdown_test_runtimes(&mut app); - run_git( - &repo, - &["worktree", "remove", "--force", checkout.to_str().unwrap()], - ); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn worktree_remove_enter_submits_through_api_path() { - let mut app = app_for_worktree_tests(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - let workspace_id = app.state.workspaces[0].id.clone(); - app.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-issue".into(), - is_linked_worktree: true, - }); - app.open_remove_linked_worktree_confirmation(0); - app.pending_api_worktree_removes - .insert(workspace_id.clone(), 1); - - app.handle_worktree_remove_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); - - let remove = app.state.worktree_remove.as_ref().unwrap(); - assert_eq!(remove.workspace_id, workspace_id); - assert!(!remove.removing); - assert_eq!( - remove.error.as_deref(), - Some("worktree operation is already in progress for this checkout") - ); - } - - #[tokio::test] - async fn ui_worktree_create_emits_api_parity_events_after_membership_is_committed() { - let repo = create_committed_repo("app-worktree-create-event-repo"); - let worktree_root = unique_temp_path("app-worktree-create-event-root"); - let branch = "worktree/ui-create-event"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - branch, - checkout.to_str().unwrap(), - "HEAD", - ], - ); - - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - let source_membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: repo.clone(), - checkout_path: repo.clone(), - is_linked_worktree: false, - }; - app.state.workspaces[0].worktree_space = Some(source_membership.clone()); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - source_checkout_path: repo.clone(), - source_existing_membership: Some(source_membership), - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: true, - }); - let plugin_root = unique_temp_path("app-worktree-create-plugin"); - std::fs::create_dir_all(&plugin_root).unwrap(); - let manifest_path = plugin_root.join("herdr-plugin.toml"); - std::fs::write(&manifest_path, "id = 'example.ui-worktree-create'\n").unwrap(); - app.state.installed_plugins.insert( - "example.ui-worktree-create".into(), - crate::api::schema::InstalledPluginInfo { - plugin_id: "example.ui-worktree-create".into(), - name: "UI Worktree Create".into(), - version: "0.1.0".into(), - min_herdr_version: "0.7.0".into(), - description: None, - manifest_path: manifest_path.display().to_string(), - plugin_root: plugin_root.display().to_string(), - enabled: true, - platforms: None, - build: Vec::new(), - startup: Vec::new(), - actions: Vec::new(), - events: vec![crate::api::schema::PluginManifestEventHook { - on: "worktree.created".into(), - platforms: None, - command: vec!["sh".into(), "-c".into(), "true".into()], - }], - panes: Vec::new(), - link_handlers: Vec::new(), - source: crate::api::schema::PluginSourceInfo::default(), - warnings: Vec::new(), - }, - ); - - app.handle_worktree_add_finished(WorktreeAddResult { - path: checkout.clone(), - api_request: None, - result: Ok(()), - }); - - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceCreated, - crate::api::schema::EventKind::TabCreated, - crate::api::schema::EventKind::PaneCreated, - crate::api::schema::EventKind::LayoutUpdated, - crate::api::schema::EventKind::WorktreeCreated, - ] - ); - let events = event_hub.events_after(0); - let workspace_created = events - .iter() - .find(|(_, event)| event.event == crate::api::schema::EventKind::WorkspaceCreated) - .map(|(_, event)| event) - .expect("workspace.created should be emitted"); - let crate::api::schema::EventData::WorkspaceCreated { workspace } = &workspace_created.data - else { - panic!("unexpected event data"); - }; - let checkout_path = checkout.display().to_string(); - assert_eq!( - workspace - .worktree - .as_ref() - .map(|worktree| worktree.checkout_path.as_str()), - Some(checkout_path.as_str()) - ); - let worktree_created = events - .iter() - .find(|(_, event)| event.event == crate::api::schema::EventKind::WorktreeCreated) - .map(|(_, event)| event) - .expect("worktree.created should be emitted"); - let crate::api::schema::EventData::WorktreeCreated { - workspace, - worktree, - } = &worktree_created.data - else { - panic!("unexpected event data"); - }; - assert_eq!( - workspace - .worktree - .as_ref() - .map(|worktree| worktree.checkout_path.as_str()), - Some(checkout_path.as_str()) - ); - assert_eq!( - worktree.open_workspace_id.as_deref(), - Some(workspace.workspace_id.as_str()) - ); - assert!(app.state.plugin_command_logs.iter().any(|log| { - log.event.as_deref() == Some("worktree.created") - && log.status == crate::api::schema::PluginCommandStatus::Running - })); - - shutdown_test_runtimes(&mut app); - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - let _ = std::fs::remove_dir_all(plugin_root); - } - - #[test] - fn worktree_create_finished_reuses_checkout_opened_before_result() { - let checkout = unique_temp_path("app-worktree-create-race-checkout"); - std::fs::create_dir_all(&checkout).unwrap(); - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("source"), - crate::workspace::Workspace::test_new("opened-by-race"), - ]; - let source_workspace_id = app.state.workspaces[0].id.clone(); - app.state.workspaces[1].identity_cwd = checkout.clone(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id, - 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: "worktree/create-race".into(), - checkout_path: checkout.clone(), - error: None, - creating: true, - }); - - app.handle_worktree_add_finished(WorktreeAddResult { - path: checkout.clone(), - api_request: None, - result: Ok(()), - }); - - assert_eq!(app.state.workspaces.len(), 2); - let kinds = event_kinds(&event_hub); - assert!(!kinds.contains(&crate::api::schema::EventKind::WorkspaceCreated)); - assert_eq!( - kinds - .iter() - .filter(|kind| **kind == crate::api::schema::EventKind::WorktreeCreated) - .count(), - 1 - ); - assert_eq!( - app.state.workspaces[1] - .worktree_space() - .map(|membership| membership.checkout_path.as_path()), - Some(checkout.as_path()) - ); - shutdown_test_runtimes(&mut app); - let _ = std::fs::remove_dir_all(checkout); - } - - #[test] - fn start_worktree_add_runs_git_on_worker_and_emits_result() { - let repo = create_committed_repo("app-worktree-add-repo"); - let worktree_root = unique_temp_path("app-worktree-add-root"); - let branch = "worktree/app-worker"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: repo.clone(), - source_existing_membership: None, - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: false, - }); - - app.start_worktree_add(); - - assert!(app - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.creating)); - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - assert!(checkout.join("README.md").exists()); - - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn start_worktree_add_existing_branch_checks_out_branch() { - let repo = create_committed_repo("app-worktree-add-existing-branch-repo"); - let worktree_root = unique_temp_path("app-worktree-add-existing-branch-root"); - let branch = "foo"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - run_git(&repo, &["branch", branch]); - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: repo.clone(), - source_existing_membership: None, - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: false, - }); - - app.start_worktree_add(); - - assert!(app - .state - .worktree_create - .as_ref() - .is_some_and(|create| create.creating)); - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - - assert!(checkout.join("README.md").exists()); - let branch_name = std::process::Command::new("git") - .arg("-C") - .arg(&checkout) - .args(["branch", "--show-current"]) - .output() - .unwrap(); - assert!(branch_name.status.success()); - assert_eq!( - String::from_utf8(branch_name.stdout).unwrap().trim(), - branch - ); - - let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn open_new_worktree_dialog_supports_standalone_bare_repo_source() { - let repo = create_committed_repo("app-worktree-dialog-bare-origin"); - let bare = unique_temp_path("app-worktree-dialog-bare-repo"); - run_git( - &repo, - &["clone", "--quiet", "--bare", ".", bare.to_str().unwrap()], - ); - let worktree_root = unique_temp_path("app-worktree-dialog-bare-root"); - - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; - app.state.workspaces[0].identity_cwd = bare.clone(); - - app.open_new_linked_worktree_dialog(0); - - assert_eq!(app.state.mode, Mode::NewLinkedWorktree); - assert!(app.state.config_diagnostic.is_none()); - let create = app.state.worktree_create.as_ref().unwrap(); - assert_eq!(create.source_checkout_path, bare); - assert_eq!(create.source_repo_root, create.source_checkout_path); - let source_checkout_path = create.source_checkout_path.clone(); - - let branch = "worktree/from-bare-source"; - let repo_name = create.repo_name.clone(); - let checkout = crate::worktree::default_checkout_path(&worktree_root, &repo_name, branch); - app.state.name_input = branch.into(); - - app.start_worktree_add(); - - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - assert!(checkout.join("README.md").exists()); - - let remove_new = crate::worktree::build_worktree_remove_command( - &source_checkout_path, - &checkout, - false, - false, - ); - crate::worktree::run_worktree_command(&remove_new).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(source_checkout_path); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn start_worktree_add_uses_source_checkout_head_as_base() { - let repo = create_committed_repo("app-worktree-add-source-repo"); - let source_checkout = unique_temp_path("app-worktree-add-source-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/source-base", - source_checkout.to_str().unwrap(), - "HEAD", - ], - ); - std::fs::write(source_checkout.join("SOURCE.md"), "source branch\n").unwrap(); - run_git(&source_checkout, &["add", "SOURCE.md"]); - run_git(&source_checkout, &["commit", "--quiet", "-m", "source"]); - - let worktree_root = unique_temp_path("app-worktree-add-from-source-root"); - let branch = "worktree/from-source"; - let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); - let mut app = app_for_worktree_tests(); - app.state.worktree_directory = worktree_root.clone(); - app.state.name_input = branch.into(); - app.state.worktree_create = Some(WorktreeCreateState { - source_workspace_id: "source".into(), - source_checkout_path: source_checkout.clone(), - source_existing_membership: None, - source_repo_root: repo.clone(), - repo_key: "repo-key".into(), - repo_name: "herdr".into(), - branch: branch.into(), - checkout_path: checkout.clone(), - error: None, - creating: false, - }); - - app.start_worktree_add(); - - let event = wait_for_worktree_event(&mut app); - match event { - AppEvent::WorktreeAddFinished(result) => { - let result = *result; - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - } - other => panic!("unexpected event: {other:?}"), - } - assert!(checkout.join("SOURCE.md").exists()); - - let remove_new = - crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); - crate::worktree::run_worktree_command(&remove_new).unwrap(); - let remove_source = - crate::worktree::build_worktree_remove_command(&repo, &source_checkout, false, false); - crate::worktree::run_worktree_command(&remove_source).unwrap(); - let _ = std::fs::remove_dir_all(worktree_root); - let _ = std::fs::remove_dir_all(repo); - } - - #[test] - fn dirty_worktree_remove_failure_requests_force_confirmation() { - let path = std::path::PathBuf::from("/w/herdr/dirty"); - let mut app = app_for_worktree_tests(); - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: "ws".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - path: path.clone(), - error: None, - removing: true, - force_confirmation: false, - }); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: "ws".into(), - path, - workspace: None, - worktree: None, - forced: false, - api_request: None, - result: Err( - "fatal: '/w/herdr/dirty' contains modified or untracked files, use --force to delete it" - .into(), - ), - }); - - let remove = app.state.worktree_remove.unwrap(); - assert!(!remove.removing); - assert!(remove.force_confirmation); - assert_eq!(remove.error, None); - } - - #[test] - fn non_dirty_worktree_remove_failure_keeps_error_message() { - let path = std::path::PathBuf::from("/w/herdr/missing"); - let mut app = app_for_worktree_tests(); - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: "ws".into(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - path: path.clone(), - error: None, - removing: true, - force_confirmation: false, - }); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: "ws".into(), - path, - workspace: None, - worktree: None, - forced: false, - api_request: None, - result: Err("fatal: '/w/herdr/missing' is not a working tree".into()), - }); - - let remove = app.state.worktree_remove.unwrap(); - assert!(!remove.removing); - assert!(!remove.force_confirmation); - assert_eq!( - remove.error, - Some("fatal: '/w/herdr/missing' is not a working tree".into()) - ); - } - - #[test] - fn worktree_remove_finished_focuses_parent_workspace() { - let mut app = app_for_worktree_tests(); - let checkout = std::path::PathBuf::from("/repo/herdr-issue"); - app.state.workspaces = vec![ - crate::workspace::Workspace::test_new("parent"), - crate::workspace::Workspace::test_new("issue"), - crate::workspace::Workspace::test_new("sibling"), - ]; - app.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, - }); - app.state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout.clone(), - is_linked_worktree: true, - }); - app.state.workspaces[2].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: "/repo/herdr-sibling".into(), - is_linked_worktree: true, - }); - let child_id = app.state.workspaces[1].id.clone(); - let parent_id = app.state.workspaces[0].id.clone(); - app.state.active = Some(1); - app.state.selected = 1; - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: child_id.clone(), - repo_root: std::path::PathBuf::from("/repo/herdr"), - path: checkout.clone(), - error: None, - removing: true, - force_confirmation: false, - }); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: child_id, - path: checkout, - workspace: None, - worktree: None, - forced: false, - api_request: None, - result: Ok(()), - }); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.workspaces[0].id, parent_id); - assert_eq!(app.state.workspaces[1].display_name(), "sibling"); - assert!(app.state.worktree_remove.is_none()); - } - - #[test] - fn worktree_remove_finished_emits_removed_event_from_snapshot_after_workspace_closed() { - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - let internal_workspace_id = app.state.workspaces[0].id.clone(); - let checkout = std::path::PathBuf::from("/repo/herdr-issue"); - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: checkout.clone(), - is_linked_worktree: true, - }); - let workspace_snapshot = app.workspace_info(0); - let worktree_snapshot = crate::api::schema::WorktreeInfo { - path: checkout.display().to_string(), - branch: Some("worktree/issue".into()), - is_bare: false, - is_detached: false, - is_prunable: false, - is_linked_worktree: true, - open_workspace_id: None, - label: "herdr".into(), - }; - app.state.worktree_remove = Some(WorktreeRemoveState { - workspace_id: internal_workspace_id.clone(), - repo_root: "/repo/herdr".into(), - path: checkout.clone(), - error: None, - removing: true, - force_confirmation: true, - }); - app.state.workspaces.clear(); - - app.handle_worktree_remove_finished(WorktreeRemoveResult { - workspace_id: internal_workspace_id, - path: checkout, - workspace: Some(Box::new(workspace_snapshot.clone())), - worktree: Some(Box::new(worktree_snapshot)), - forced: true, - api_request: None, - result: Ok(()), - }); - - assert_eq!( - event_kinds(&event_hub), - vec![crate::api::schema::EventKind::WorktreeRemoved] - ); - assert!(event_hub.events_after(0).iter().any(|(_, event)| { - matches!( - &event.data, - crate::api::schema::EventData::WorktreeRemoved { - workspace_id, - workspace: Some(workspace), - worktree, - forced, - } if workspace_id == &workspace_snapshot.workspace_id - && workspace.workspace_id == workspace_snapshot.workspace_id - && worktree.branch.as_deref() == Some("worktree/issue") - && *forced - ) - })); - } - - #[test] - fn dirty_worktree_remove_retries_with_force_and_closes_workspace() { - let repo = create_committed_repo("app-worktree-dirty-remove-repo"); - let checkout = unique_temp_path("app-worktree-dirty-remove-checkout"); - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - "worktree/dirty-remove", - checkout.to_str().unwrap(), - "HEAD", - ], - ); - std::fs::write(checkout.join("README.md"), "dirty\n").unwrap(); - - let event_hub = crate::api::EventHub::default(); - let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; - let workspace_id = app.state.workspaces[0].id.clone(); - app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), - label: "herdr".into(), - repo_root: repo.clone(), - checkout_path: checkout.clone(), - is_linked_worktree: true, - }); - app.state.active = Some(0); - app.state.selected = 0; - app.open_remove_linked_worktree_confirmation(0); - - app.start_worktree_remove(); - - #[cfg(not(windows))] - { - let safe_event = wait_for_worktree_event(&mut app); - match safe_event { - AppEvent::WorktreeRemoveFinished(result) => { - let result = *result; - assert_eq!(result.workspace_id, workspace_id); - assert_eq!(result.path, checkout); - assert!(result.result.is_err()); - app.handle_worktree_remove_finished(result); - } - other => panic!("unexpected event: {other:?}"), - } - } - - let remove = app.state.worktree_remove.as_ref().unwrap(); - assert!(!remove.removing); - assert!(remove.force_confirmation); - assert!(checkout.exists()); - - app.start_worktree_remove(); - let force_event = wait_for_worktree_event(&mut app); - match force_event { - AppEvent::WorktreeRemoveFinished(result) => { - let result = *result; - assert_eq!(result.workspace_id, workspace_id); - assert_eq!(result.path, checkout); - assert_eq!(result.result, Ok(())); - app.handle_worktree_remove_finished(result); - } - other => panic!("unexpected event: {other:?}"), - } - - assert!(!checkout.exists()); - assert!(app.state.worktree_remove.is_none()); - assert!(app.state.workspaces.is_empty()); - assert_eq!( - event_kinds(&event_hub), - vec![ - crate::api::schema::EventKind::WorkspaceClosed, - crate::api::schema::EventKind::WorktreeRemoved, - ] - ); - assert!(event_hub.events_after(0).iter().any(|(_, event)| { - matches!( - &event.data, - crate::api::schema::EventData::WorktreeRemoved { worktree, .. } - if worktree.branch.as_deref() == Some("worktree/dirty-remove") - && !worktree.is_detached - ) - })); - - let _ = std::fs::remove_dir_all(repo); - } + use super::App; #[test] fn worktree_remove_runtime_shutdown_policy_preserves_windows_safe_remove() { |
