aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs2165
-rw-r--r--src/app/agent_resume.rs2
-rw-r--r--src/app/agent_view.rs27
-rw-r--r--src/app/agents.rs2
-rw-r--r--src/app/api.rs435
-rw-r--r--src/app/api/agent_view.rs4
-rw-r--r--src/app/api/agents.rs2
-rw-r--r--src/app/api/integrations.rs29
-rw-r--r--src/app/api/layouts.rs2
-rw-r--r--src/app/api/pane_graphics.rs16
-rw-r--r--src/app/api/panes.rs867
-rw-r--r--src/app/api/plugins/context.rs24
-rw-r--r--src/app/api/plugins/mod.rs196
-rw-r--r--src/app/api/responses.rs4
-rw-r--r--src/app/api/session.rs4
-rw-r--r--src/app/api/tabs.rs66
-rw-r--r--src/app/api/workspaces.rs171
-rw-r--r--src/app/api/worktrees.rs17
-rw-r--r--src/app/api/worktrees/deferred.rs38
-rw-r--r--src/app/config_io.rs128
-rw-r--r--src/app/creation.rs145
-rw-r--r--src/app/custom_commands.rs782
-rw-r--r--src/app/git_refresh.rs2
-rw-r--r--src/app/ids.rs2
-rw-r--r--src/app/input/clipboard.rs370
-rw-r--r--src/app/input/copy_mode.rs2153
-rw-r--r--src/app/input/lease.rs465
-rw-r--r--src/app/input/mod.rs1051
-rw-r--r--src/app/input/modal.rs2421
-rw-r--r--src/app/input/mouse.rs4778
-rw-r--r--src/app/input/navigate.rs3898
-rw-r--r--src/app/input/overlays.rs831
-rw-r--r--src/app/input/selection.rs306
-rw-r--r--src/app/input/settings.rs693
-rw-r--r--src/app/input/sidebar.rs1936
-rw-r--r--src/app/input/terminal.rs2181
-rw-r--r--src/app/mod.rs4139
-rw-r--r--src/app/pane_graphics.rs1
-rw-r--r--src/app/popup.rs14
-rw-r--r--src/app/runtime.rs752
-rw-r--r--src/app/runtime_mutations.rs198
-rw-r--r--src/app/session.rs9
-rw-r--r--src/app/state.rs1240
-rw-r--r--src/app/tab_bar_status.rs2
-rw-r--r--src/app/terminal_titles.rs24
-rw-r--r--src/app/theme_sync.rs64
-rw-r--r--src/app/window_title.rs8
-rw-r--r--src/app/worktrees.rs2345
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(&params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.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, &params.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, &params.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(&params.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(&params) {
+ 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(&params.pane_id) else {
+ return pane_not_found(id, &params.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, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.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, &params.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(
+ &params.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(&params.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(&copy_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(&params.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(&params) {
+ 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() {